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.
155 lines
3.9 KiB
Go
155 lines
3.9 KiB
Go
package mcp
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"sync"
|
|
)
|
|
|
|
// Poster is the HTTP seam: internal/webfetch.Fetcher satisfies it. The
|
|
// transport takes it as an interface so a test can serve a fake without a
|
|
// listener, and so that the ONLY implementation wired in production is the
|
|
// guarded fetcher — an MCP endpoint cannot get a bare http.Client this way.
|
|
type Poster interface {
|
|
Post(ctx context.Context, rawURL, contentType string, body []byte, hdr map[string]string) (*PostResponse, error)
|
|
}
|
|
|
|
// PostResponse is the shape webfetch returns, restated here so this package
|
|
// does not depend on it structurally.
|
|
type PostResponse struct {
|
|
Status int
|
|
ContentType string
|
|
Body []byte
|
|
Header map[string]string
|
|
}
|
|
|
|
// httpTransport speaks streamable HTTP: every request is a POST to one
|
|
// endpoint, and the reply is either a JSON object or a text/event-stream frame
|
|
// carrying one. Both are accepted — servers pick per response, and the two the
|
|
// LAN runs disagree about which.
|
|
type httpTransport struct {
|
|
poster Poster
|
|
url string
|
|
|
|
mu sync.Mutex
|
|
session string // Mcp-Session-Id, echoed back when the server issues one
|
|
}
|
|
|
|
func newHTTPTransport(post Poster, endpoint string) *httpTransport {
|
|
return &httpTransport{poster: post, url: endpoint}
|
|
}
|
|
|
|
func (t *httpTransport) Call(ctx context.Context, req *rpcRequest) (*rpcResponse, error) {
|
|
body, err := t.send(ctx, req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
frame, err := decodeFrame(body)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var resp rpcResponse
|
|
if err := json.Unmarshal(frame, &resp); err != nil {
|
|
return nil, fmt.Errorf("mcp: decode response: %w", err)
|
|
}
|
|
return &resp, nil
|
|
}
|
|
|
|
func (t *httpTransport) Notify(ctx context.Context, method string, params any) error {
|
|
_, err := t.send(ctx, &rpcRequest{JSONRPC: "2.0", Method: method, Params: params})
|
|
return err
|
|
}
|
|
|
|
func (t *httpTransport) send(ctx context.Context, req *rpcRequest) ([]byte, error) {
|
|
req.JSONRPC = "2.0"
|
|
raw, err := json.Marshal(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
hdr := map[string]string{"Accept": "application/json, text/event-stream"}
|
|
t.mu.Lock()
|
|
if t.session != "" {
|
|
hdr["Mcp-Session-Id"] = t.session
|
|
}
|
|
t.mu.Unlock()
|
|
|
|
resp, err := t.poster.Post(ctx, t.url, "application/json", raw, hdr)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if sid := headerGet(resp.Header, "Mcp-Session-Id"); sid != "" {
|
|
t.mu.Lock()
|
|
t.session = sid
|
|
t.mu.Unlock()
|
|
}
|
|
return resp.Body, nil
|
|
}
|
|
|
|
func (t *httpTransport) Close() error {
|
|
t.mu.Lock()
|
|
t.session = ""
|
|
t.mu.Unlock()
|
|
return nil
|
|
}
|
|
|
|
func headerGet(h map[string]string, key string) string {
|
|
if h == nil {
|
|
return ""
|
|
}
|
|
if v, ok := h[key]; ok {
|
|
return v
|
|
}
|
|
lower := strings.ToLower(key)
|
|
for k, v := range h {
|
|
if strings.ToLower(k) == lower {
|
|
return v
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// decodeFrame pulls the JSON object out of a body that is either raw JSON or
|
|
// SSE. For SSE we take the LAST data: payload that parses, which is the
|
|
// response — earlier frames on the stream are progress notifications.
|
|
func decodeFrame(body []byte) ([]byte, error) {
|
|
trimmed := bytes.TrimSpace(body)
|
|
if len(trimmed) == 0 {
|
|
return nil, errors.New("mcp: empty response body")
|
|
}
|
|
if trimmed[0] == '{' || trimmed[0] == '[' {
|
|
return trimmed, nil
|
|
}
|
|
var last []byte
|
|
sc := bufio.NewScanner(bytes.NewReader(trimmed))
|
|
sc.Buffer(make([]byte, 0, 64<<10), maxLine)
|
|
for sc.Scan() {
|
|
line := strings.TrimSpace(sc.Text())
|
|
if !strings.HasPrefix(line, "data:") {
|
|
continue
|
|
}
|
|
payload := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
|
|
if payload == "" {
|
|
continue
|
|
}
|
|
var probe map[string]json.RawMessage
|
|
if json.Unmarshal([]byte(payload), &probe) != nil {
|
|
continue
|
|
}
|
|
if _, isResp := probe["id"]; isResp {
|
|
last = []byte(payload)
|
|
}
|
|
}
|
|
if err := sc.Err(); err != nil {
|
|
return nil, fmt.Errorf("mcp: read event stream: %w", err)
|
|
}
|
|
if last == nil {
|
|
return nil, errors.New("mcp: no JSON-RPC response in event stream")
|
|
}
|
|
return last, nil
|
|
}
|