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.
47 lines
1.5 KiB
Go
47 lines
1.5 KiB
Go
package mcp
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
|
|
"github.com/kami/maven/internal/webfetch"
|
|
)
|
|
|
|
// WebfetchDoor builds the PosterFactory used in production: one guarded
|
|
// webfetch.Fetcher per url server, with that server's allow_private and the
|
|
// shared host lists and limits.
|
|
//
|
|
// One fetcher PER server is the point. allow_private is a hole in the
|
|
// private-address guard, and a hole punched for the Vikunja server on loopback
|
|
// must not become a hole for some public endpoint that happens to redirect at
|
|
// the LAN. Rate limiting is per fetcher too, which is the right shape here:
|
|
// separate servers are separate hosts.
|
|
func WebfetchDoor(limits webfetch.Config) PosterFactory {
|
|
return func(cfg ServerConfig) (Poster, error) {
|
|
c := limits
|
|
c.AllowPrivate = cfg.AllowPrivate
|
|
if c.Timeout <= 0 && cfg.Timeout > 0 {
|
|
c.Timeout = cfg.Timeout
|
|
}
|
|
return fetcherPoster{webfetch.New(c)}, nil
|
|
}
|
|
}
|
|
|
|
// fetcherPoster adapts webfetch.Fetcher to Poster. It exists so this package
|
|
// does not have to know webfetch's Response type, and so a test can substitute
|
|
// a fake without a listener.
|
|
type fetcherPoster struct{ f *webfetch.Fetcher }
|
|
|
|
func (p fetcherPoster) Post(ctx context.Context, rawURL, contentType string, body []byte, hdr map[string]string) (*PostResponse, error) {
|
|
resp, err := p.f.Post(ctx, rawURL, contentType, body, hdr)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("mcp: post %s: %w", rawURL, err)
|
|
}
|
|
return &PostResponse{
|
|
Status: resp.Status,
|
|
ContentType: resp.ContentType,
|
|
Body: resp.Body,
|
|
Header: resp.Header,
|
|
}, nil
|
|
}
|