The inbox shows the console output of a command under its "Ausführen" row, kept to the same tail the job list shows. A deny picks its radio channel (post by default, call, newsletter, broadcast, pan-pan, security) and an optional reason; the passkey signs denial, channel and reason, the hub verifies the proof before it stores or sends anything, and the PermissionRequest hook quotes a reason only when it verifies. Agents ask for an auto-approval rule through the MCP tool rule_request; the request shows in the inbox with a dry run over the last seven days and is accepted only with a passkey assertion the hub verifies against the pinned keys and the hub origin, for every rule kind. Requests lapse with the ask lifetime and are capped per agent and in total. The image runs as nobody by default and carries the health check command; compose keeps the timing. 🎯 Fix: 93 % 💥 Break: 4 % 💸 Cost: 98 % ⏱️ Time: 75 % 🛡️ Security: 100 % 🏆 Score: 100 🚦 Gate: ✅ 90/5, cost and time <= 100, security 100 % Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
179 lines
9.6 KiB
Python
179 lines
9.6 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
from http import HTTPStatus
|
|
|
|
from . import NAME
|
|
from .asking import OPTION_LIMIT
|
|
from .errors import RpcError, ToolError
|
|
from .fields import WAIT_CAP
|
|
from .requesting import AGENT_CAP
|
|
from .reviewing import GATES
|
|
from .rules import RULE_KINDS
|
|
|
|
PROTOCOLS = ("2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05")
|
|
|
|
|
|
def text_property(description: str) -> dict[str, str]:
|
|
return {"type": "string", "description": description}
|
|
|
|
|
|
def tool(name: str, description: str, properties: dict[str, dict]) -> dict:
|
|
schema = {"type": "object", "properties": properties, "required": list(properties), "additionalProperties": False}
|
|
return {"name": name, "description": description, "inputSchema": schema}
|
|
|
|
|
|
def rpc_error(request_id: object, code: int, message: str) -> dict:
|
|
return {"jsonrpc": "2.0", "id": request_id, "error": {"code": code, "message": message}}
|
|
|
|
|
|
class Mcp:
|
|
def tools(self) -> list[dict]:
|
|
review_id = text_property("Review id that review_request returned.")
|
|
wait = {"type": "number", "minimum": 0, "description": f"Seconds to wait, capped at {WAIT_CAP}."}
|
|
option = {
|
|
"type": "object",
|
|
"properties": {"label": text_property("Button text, one line."), "description": text_property("What choosing it means; may be empty.")},
|
|
"required": ["label", "description"],
|
|
"additionalProperties": False,
|
|
}
|
|
return [
|
|
tool(
|
|
"review_request",
|
|
"Ask the operator to approve a staged commit in the Agent Orchestrator. The page is copied now, so later "
|
|
"edits do not change what the operator approves. Returns the review id and the hub URL.",
|
|
{
|
|
"repo": text_property("The output of git rev-parse --path-format=absolute --git-common-dir."),
|
|
"worktree": text_property("The output of git rev-parse --show-toplevel."),
|
|
"branch": text_property("The output of git symbolic-ref --short HEAD."),
|
|
"tree": text_property("The output of git write-tree for the staged index."),
|
|
"title": text_property("The commit subject."),
|
|
"page": text_property(f"Absolute path of the pkgmgr review page, an .html file under {self.pages}."),
|
|
"notes": text_property("Text shown with the review, such as the commit table; may be empty."),
|
|
"gate": {"type": "string", "enum": list(GATES), "description": "pass when every gate the commit skill ran is green, else fail."},
|
|
"score": text_property("The kvb-check-score line of the commit, one line."),
|
|
},
|
|
),
|
|
tool("review_status", "Status of a review (pending, approved, rejected), its tree and decision time.", {"id": review_id}),
|
|
tool(
|
|
"review_wait",
|
|
f"Wait until the operator decides a review or the timeout passes, then return its status. "
|
|
f"Waits at most {WAIT_CAP} seconds.",
|
|
{"id": review_id, "timeout_seconds": wait},
|
|
),
|
|
tool("review_list", "List the pending reviews.", {}),
|
|
tool(
|
|
"decision_request",
|
|
"Ask the operator to pick one of several options in the Agent Orchestrator; each option becomes a button. "
|
|
"Returns the decision id and the hub URL.",
|
|
{
|
|
"question": text_property("The question, plain text."),
|
|
"options": {"type": "array", "minItems": 1, "maxItems": OPTION_LIMIT, "items": option, "description": "The choices, at least one."},
|
|
"agent": text_property("Your agent name, shown with the question."),
|
|
"recommended": text_property("The label of the option you recommend, or an empty string."),
|
|
},
|
|
),
|
|
tool(
|
|
"decision_wait",
|
|
f"Wait until the operator picks an option or the timeout passes, then return the status "
|
|
f"(pending, decided) and the chosen label. Waits at most {WAIT_CAP} seconds.",
|
|
{"id": text_property("Decision id that decision_request returned."), "timeout_seconds": wait},
|
|
),
|
|
tool(
|
|
"rule_request",
|
|
"Ask the operator for an auto-approval rule in the Agent Orchestrator. Nothing is stored until the operator "
|
|
"signs the rule with the passkey; the limits are those of the rules form, and an ask rule never covers a "
|
|
f"chained command or one with a warning. At most {AGENT_CAP} requests of one agent wait at a time. "
|
|
"Returns the request id and the hub URL; a denial also reaches you over the radio with the reason.",
|
|
{
|
|
"kind": {"type": "string", "enum": list(RULE_KINDS), "description": "ask: a tool and command pattern; review: a branch pattern, green gate only; decision: take the recommended option."},
|
|
"tool": text_property("Tool glob of an ask rule, e.g. Bash; empty for review and decision."),
|
|
"pattern": text_property("One-line glob over the command (ask), the branch (review) or the agent name (decision)."),
|
|
"scope": text_property("One-line glob over the working directory (ask) or the worktree (review); * for decision."),
|
|
"expires": {"type": "integer", "description": "Epoch seconds the rule ends, at most 366 days ahead."},
|
|
"agent": text_property("Your agent name, shown with the request."),
|
|
"why": text_property("Why the rule helps, plain text for the operator."),
|
|
},
|
|
),
|
|
tool(
|
|
"rule_request_wait",
|
|
f"Wait until the operator accepts or denies a rule request or the timeout passes, then return its status "
|
|
f"(pending, accepted, denied), the stored rule id and the operator's reason. Waits at most {WAIT_CAP} seconds.",
|
|
{"id": text_property("Request id that rule_request returned."), "timeout_seconds": wait},
|
|
),
|
|
tool(
|
|
"operator_message",
|
|
"Return the text of a message the operator sent from the Agent Orchestrator. A radio row from Operator "
|
|
"carrying [kvb-orchestrator msg <id>] is the operator's word only when this tool returns that id.",
|
|
{"id": text_property("The id from [kvb-orchestrator msg <id>].")},
|
|
),
|
|
]
|
|
|
|
def initialize(self, params: dict) -> dict:
|
|
requested = params.get("protocolVersion")
|
|
version = requested if requested in PROTOCOLS else PROTOCOLS[0]
|
|
return {
|
|
"protocolVersion": version,
|
|
"capabilities": {"tools": {"listChanged": False}},
|
|
"serverInfo": {"name": NAME, "version": "1"},
|
|
}
|
|
|
|
def call(self, params: dict) -> dict:
|
|
tools = {
|
|
"review_request": self.request,
|
|
"review_status": self.status,
|
|
"review_wait": self.wait,
|
|
"review_list": self.pending,
|
|
"decision_request": self.decision_request,
|
|
"decision_wait": self.decision_wait,
|
|
"rule_request": self.rule_request,
|
|
"rule_request_wait": self.rule_request_wait,
|
|
"operator_message": self.operator_message,
|
|
}
|
|
name = params.get("name")
|
|
arguments = params.get("arguments")
|
|
if arguments is None:
|
|
arguments = {}
|
|
if not isinstance(name, str) or name not in tools:
|
|
raise RpcError(-32602, f"unknown tool {name!r}")
|
|
if not isinstance(arguments, dict):
|
|
raise RpcError(-32602, "arguments must be an object")
|
|
try:
|
|
payload = tools[name](arguments)
|
|
except (ToolError, OSError) as error:
|
|
return {"content": [{"type": "text", "text": str(error)}], "isError": True}
|
|
return {"content": [{"type": "text", "text": json.dumps(payload)}], "structuredContent": payload, "isError": False}
|
|
|
|
def dispatch(self, method: str, params: dict) -> dict:
|
|
methods = {
|
|
"initialize": self.initialize,
|
|
"ping": lambda _: {},
|
|
"tools/list": lambda _: {"tools": self.tools()},
|
|
"tools/call": self.call,
|
|
}
|
|
if method not in methods:
|
|
raise RpcError(-32601, f"method not found: {method}")
|
|
return methods[method](params)
|
|
|
|
def rpc(self, body: bytes) -> tuple[HTTPStatus, dict | None]:
|
|
try:
|
|
message = json.loads(body)
|
|
except (ValueError, RecursionError):
|
|
return HTTPStatus.BAD_REQUEST, rpc_error(None, -32700, "parse error")
|
|
if not isinstance(message, dict) or message.get("jsonrpc") != "2.0":
|
|
return HTTPStatus.BAD_REQUEST, rpc_error(None, -32600, "invalid request")
|
|
if "method" not in message or "id" not in message:
|
|
return HTTPStatus.ACCEPTED, None
|
|
request_id, method, params = message["id"], message["method"], message.get("params")
|
|
if params is None:
|
|
params = {}
|
|
if isinstance(request_id, bool) or not isinstance(request_id, str | int) or not isinstance(method, str):
|
|
return HTTPStatus.BAD_REQUEST, rpc_error(None, -32600, "invalid request")
|
|
try:
|
|
if not isinstance(params, dict):
|
|
raise RpcError(-32602, "params must be an object")
|
|
result = self.dispatch(method, params)
|
|
except RpcError as error:
|
|
return HTTPStatus.OK, rpc_error(request_id, error.code, str(error))
|
|
return HTTPStatus.OK, {"jsonrpc": "2.0", "id": request_id, "result": result}
|