An allowlist row stores cmd ["mcp", server, tool]. That is a late-bound
reference to a name the far end owns, so the row pins nothing about
behaviour: a server could redefine an enabled read-only list_tasks into
something that writes, and Maven would keep calling it with no confirm
turn and no second approval. Discovery now stores a fingerprint of the
declared shape, name, description, input schema and readOnlyHint, and
compares it on every refresh. A mismatch drops the row back to proposed
and, if it stopped claiming read-only, marks it destructive. destructive
is only ever raised. A row predating the column adopts its fingerprint
silently, because an upgrade is not a redefinition.
Nothing retracted a proposal either, so a tool a connected server no
longer offers stayed enabled and failed at call time with an internal
string. Those rows are withdrawn, with provenance saying why, and only
for servers that are actually connected so a restart does not disarm
what he approved.
Argument binding rested on readOnlyHint, which the same server writes.
A server advertising delete_project as read-only got an unconfirmed
argument-carrying call. Binding now also requires the tool be named in
allow_tools, something local, and refuses a required property the schema
never describes rather than guessing it is a string.
wireMCP dialled synchronously from run, and on the passkey path from
inside the unlock handler, so one black-holed endpoint delayed boot and
the answer to an unlock. The first dial happens on the refresh goroutine
under the daemon context. Two servers whose names flatten to one local
allowlist name no longer share a row.
Found in review of #71.
docs/plans/06-mcp-support.md asks for the host direction — Maven connects OUT
to MCP servers and consumes what they offer. This is the client half: the
protocol, the transports, the connection manager, the config block. Nothing is
wired into a turn yet, and nothing here exposes Maven's own capabilities to an
outside caller.
internal/mcp:
- hand-rolled JSON-RPC 2.0 (the wire format is four fields, and the repo
vendors its deps, so a library would cost more than it saves);
- two transports: a stdio subprocess on this box, and streamable HTTP, which
accepts a plain JSON reply or an SSE frame because servers disagree about
which they send;
- Client: initialize handshake, tools/list, tools/call, resources/list,
resources/read. Text content only — everything downstream is a sentence;
- Manager: lazy dial, per-server failure that never blocks boot or the other
servers, backoff reconnect, Status for a web surface, graceful Close;
- the allowlist encoding: a discovered tool becomes the store row
"vikunja_list_tasks" with cmd ["mcp","vikunja","list_tasks"], scope
"mcp:vikunja". No new column, no migration, and ProposeTool, EnableTool,
the act matcher and the confirm turn all keep working untouched.
Constraints held, in code rather than in prose:
- OFF unless configured, and a server is dark until "enabled": true.
- A url server goes through internal/webfetch, so the SSRF guard, the size
cap, the redirect cap and the per-host rate limit apply. Reaching loopback
needs allow_private on THAT server, and each server gets its own fetcher so
one loopback exemption cannot become a hole for a public endpoint.
- readOnlyHint decides destructive: no hint means "assume it mutates", which
will route the call through the existing confirm turn. Guessing wrong in
that direction only costs a question.
- The catalogue stays small on purpose — allow_tools, and max_tools=12 per
server. The resident model is a 1.7B with a 4096-token context; a tool name
it half-remembers is a wrong act.
- Only the tool name and the router's arguments are sent. There is no API
here through which a note, a fact or the persona block could travel.
webfetch grows Post (JSON-RPC cannot be a GET) and surfaces response headers
for Mcp-Session-Id. It shares Get's guards exactly: a body buys a caller
nothing, a POST to the LAN is refused for the same reason a GET is.
Verified against the real Vikunja MCP server on homesrv
(http://localhost:9100/mcp): handshake, three discovered tools with update_task
correctly NOT read-only, a live list_projects call, a tool excluded by
allow_tools refused, and the same server refused outright once allow_private
was dropped. Tests cover both transports (the stdio one against a real
subprocess), SSE and JSON framing, session echo, reconnect, and the config
validation.