95ae900a58
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.
73 lines
3.1 KiB
Go
73 lines
3.1 KiB
Go
// Package mcp is Maven's Model Context Protocol CLIENT. She is a host: she
|
|
// connects OUT to MCP servers, discovers the tools and resources they offer,
|
|
// and hands them to the parts of her that already exist for this — the tool
|
|
// allowlist in the store, the confirm turn for anything that mutates, the
|
|
// stage-3 gate that makes an uncertain act ask instead of run.
|
|
//
|
|
// She is not an MCP server. Nothing here exposes her own capabilities to an
|
|
// outside caller; docs/plans/06-mcp-support.md asks for the host direction only.
|
|
//
|
|
// Boundaries, in code rather than in prose:
|
|
//
|
|
// - OFF unless configured. No mcp_servers block ⇒ no manager, no goroutine,
|
|
// no socket.
|
|
// - A remote server is reached through internal/webfetch, so the SSRF guard,
|
|
// the size cap, the redirect cap and the per-host rate limit all apply to
|
|
// an MCP endpoint exactly as they do to a news feed. Reaching a loopback
|
|
// or LAN server means explicitly setting allow_private on THAT server —
|
|
// a different trust level, spelled out per server rather than globally.
|
|
// - Only the tool name and the arguments the router produced are sent. This
|
|
// package never sees his notes, facts, history or the persona block, and
|
|
// has no API through which a caller could pass them.
|
|
// - Discovery proposes, it does not enable. A discovered tool lands as a
|
|
// 'proposed' row; a human enables it on the authed surface.
|
|
package mcp
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
)
|
|
|
|
// ProtocolVersion — the spec revision we ask for in the initialize handshake.
|
|
// A server that answers with a different one is accepted (the spec says the
|
|
// client may proceed if it can support what came back); we only refuse when it
|
|
// answers with no version at all, which means it is not an MCP server.
|
|
const ProtocolVersion = "2025-06-18"
|
|
|
|
// rpcRequest / rpcResponse — JSON-RPC 2.0. Deliberately hand-rolled: the wire
|
|
// format is four fields, and the repo vendors its dependencies, so pulling a
|
|
// library in for this would cost more than it saves.
|
|
type rpcRequest struct {
|
|
JSONRPC string `json:"jsonrpc"`
|
|
ID int64 `json:"id,omitempty"`
|
|
Method string `json:"method"`
|
|
Params any `json:"params,omitempty"`
|
|
}
|
|
|
|
type rpcResponse struct {
|
|
JSONRPC string `json:"jsonrpc"`
|
|
ID *int64 `json:"id"`
|
|
Result json.RawMessage `json:"result,omitempty"`
|
|
Error *rpcError `json:"error,omitempty"`
|
|
}
|
|
|
|
type rpcError struct {
|
|
Code int `json:"code"`
|
|
Message string `json:"message"`
|
|
}
|
|
|
|
func (e *rpcError) Error() string { return fmt.Sprintf("mcp: rpc error %d: %s", e.Code, e.Message) }
|
|
|
|
// transport carries one JSON-RPC conversation. Implementations: stdioTransport
|
|
// (a subprocess on this box) and httpTransport (streamable HTTP, guarded by
|
|
// webfetch). Both must be safe for concurrent use by the Client.
|
|
type transport interface {
|
|
// Call sends a request and returns the matching response.
|
|
Call(ctx context.Context, req *rpcRequest) (*rpcResponse, error)
|
|
// Notify sends a notification (no id, no reply expected).
|
|
Notify(ctx context.Context, method string, params any) error
|
|
// Close releases the transport (kills the subprocess, drops the session).
|
|
Close() error
|
|
}
|