MCP client: connect out to external tool servers #70
Closed
claude
wants to merge 1 commits from
overnight/mcp-client into overnight/self-update
pull from: overnight/mcp-client
merge into: kami:overnight/self-update
kami:master
kami:task/725-capability-ledger-and-empirical-baseline
kami:task/692-heads-path-may-equal-model-path-and-noth
kami:task/694-staticcheck-and-deadcode-are-still-not-i
kami:task/682-go-1-25-5-and-x-text-0-14-0-carry-20-rea
kami:task/674-caveats
kami:task/673-mavgpud-serves-the-model-to-the-whole-la
kami:task/487-capture-device-doc
kami:task/487-capture-device
kami:task/487-wake-word-deploy
kami:task/487-wake-word-threshold
kami:task/487-wake-word-stage-two
kami:task/671-mavwaked-registers-as-a-voice-consumer-i
kami:task/670-cut-claude-md-to-200-lines
kami:task/515-deploy-mavwaked-workpc
kami:task/669-prune-claude-md
kami:task/668-e4b-phrasing
kami:task/668-title-capital
kami:task/668-kiwix-answers-a-question-it-cannot-answe
kami:task/666-only-a-stage-0-grammar-may-take-the-pers
kami:task/487-mavwaked-has-no-wake-word-only-an-energy
kami:task/486-deploy-the-workstation-transcriber
kami:task/486-move-stt-and-tts-to-the-workstation-wher
kami:task/665-crisperwhisper-2-russian
kami:task/664-routing-heads-in-go
kami:task/662-usage-harness-source-badge
kami:task/661-post-merge-usage-rerun
kami:task/661-routing-heads-step-3-train-the-multi-hea
kami:task/660-router-prompt-destination
kami:task/659-destination-fixture
kami:task/655-query-source-is-a-routing-decision-made
kami:task/654-a-pending-clarify-has-no-way-out-neither
kami:task/654-week-of-usage-eval-docs
kami:task/649-needs-kami-telegram-is-the-only-reach-an
kami:task/643-memorystore-search-decodes-and-unmarshal
kami:task/641-two-maps-grow-for-the-process-lifetime-w
kami:task/644-mavcaldav-is-built-documented-as-running
kami:task/642-the-store-caps-sqlite-at-one-connection
kami:task/647-factenrichmentworker-walks-the-pending-q
kami:task/646-v-637-follow-up-telegram-intake-has-no-d
kami:task/638-no-deadline-survives-the-turn-path-from
kami:task/637-inbound-telegram-turns-and-corrections-f
kami:task/636-correcting-a-turn-from-telegram-and-from
kami:task/634-an-act-alias-resolves-the-verb-but-not-t
kami:task/630-one-gesture-correction-on-chat-v-628
kami:task/629-persist-the-routing-trace-and-record-it
kami:task/631-mode-inventory-written-from-the-handlers
kami:task/586-defaultfactparser-uses-hand-written-russ
kami:task/633-reconcile-the-seed-labels-with-the-handl
kami:task/627-reminder-verbs-has-no-alarm-verb-so-an-a
kami:task/626-the-classifier-seeds-teach-an-older-inte
kami:task/546-route-with-a-fine-tuned-e5-small-instead
kami:task/586-measure-the-fact-parser
kami:fix/gofmt-ecosystem-acts
kami:task/584-media-store-a-failed-write-leaks-its-bud
kami:task/518-no-write-path-for-a-backdated-event-so-t
kami:task/287-qa-voice-session-quality-polish
kami:task/492-qa-plan-reconcile
kami:task/530-sweep-tail-four-files-the-russian-sweep
kami:task/405-score-how-often-a-real-utterance-reaches
kami:task/529-money-and-list-pick-a-mechanism
kami:task/528-sweep-tail-the-three-files-on-467
kami:task/527-embedder-open-set-phrasings-stop-being-r
kami:task/526-morphology-a-dictionary-answers-the-gram
kami:task/525-lexicons-the-finite-russian-sets-move-to
kami:task/524-entity-reference-ask-nexus-about-every-l
kami:task/523-risk-tiers-take-hexis-s-tier-for-a-hexis
kami:task/521-review-pr-111-query-strings-declension-h
kami:task/491-llama-server-core-dumps-on-every-sigterm
kami:task/479-bug-an-unconfigured-capability-does-not
kami:task/467-bug-spoken-task-capture-is-dead-the-rout
kami:task/463-deploy-mavwaked-and-mavenclient-run-nowh
kami:task/480-hearing-no-shipped-client-can-start-a-re
kami:task/432-ambient-calendar-intake-is-fragile-and-p
kami:task/431-board-surface-maven-holds-the-work-board
kami:task/433-reactivehandler-has-30-fields-and-is-pas
kami:task/371-swap-the-embedder-for-an-asymmetric-retr
kami:task/408-review-31-07-split-the-30-method-coreapi
kami:task/410-review-31-07-hand-rolled-string-enums-st
kami:task/423-review-pr50-split-internal-ipc-server-go
kami:task/422-review-pr50-split-cmd-mavend-tick-go-860
kami:task/409-review-31-07-finish-moving-mavweb-markup
kami:task/482-ambient-ingest-reads-a-notification-s-ti
kami:task/444-kuma-a-fact-per-monitor-so-she-can-name
kami:task/452-capability-model-homelab-docker-restart
kami:task/449-destructive-confirm-policy-risk-tiers-no
kami:task/453-grocery-list-items-table-fourth-append-o
kami:task/399-run-the-persona-checks-inside-the-daemon
kami:task/448-bounded-follow-up-state-pending-candidat
kami:task/455-conversation-repair-name-the-misroute-co
kami:task/454-go-mod-tidy
kami:task/458-pronunciation-dictionary-for-piper
kami:task/456-command-history-read-only-query-over-exi
kami:task/457-clarification-templates-for-the-router-s
kami:task/474-query-source-ordering-feeds-and-calendar
kami:task/469-reminders-spelled-out-times-fail-the-bod
kami:task/475-bug-the-praxis-attention-capability-is-u
kami:task/481-bug-a-transient-complaint-is-stored-as-a
kami:task/476-bug-the-router-transliterates-latin-enti
kami:task/385-decide-whether-a-parked-clarify-question
kami:task/377-backfill-routines
kami:task/421-weather-geocoder
kami:task/390-no-read-path-for-delivery-attempts
kami:task/386-recall-fixture-filler-note-ids
kami:task/473-bug-morning-item-has-no-required-flag
kami:task/465-bug-make-simulate-routes-with-an-empty
kami:task/467-bug-spoken-task-capture-is-dead
kami:task/466-bug-a-pending-clarify-is-global-so-one-u
kami:task/468-bug-pattern-detect-has-no-minimum-interv
kami:task/462-bug-checkfeminine-flags-second-person-ma
kami:task/443-safekey-drops-cyrillic-so-russian-calend
kami:task/471-bug-agendaquerygrammars-covers-today-but
kami:task/383-slottext-in-clarify-answer-would-clobber
kami:task/323-qa-phraser-coverage-is-65-3-but-the-llam
kami:task/498-bug-and-x-reach-the-model-with-no-determ
kami:task/506-strings-family-6-summaries-and-reports-i
kami:task/504-strings-family-4-act-and-smart-home-repl
kami:task/503-strings-family-3-query-answers-and-gaps
kami:task/502-strings-family-2-capture-acknowledgement
kami:task/501-strings-family-1-phrasing-fallbacks-into
kami:task/397-phrasechat-and-phrasequery-hide-model-fa
kami:task/396-the-reply-path-can-t-be-tested-llmreplie
kami:task/496-recall-a-cross-language-question-loses-i
kami:task/495-bug-x-escapes-the-personal-boundary-and
kami:task/499-llama-server-holds-7-9gb-rss-for-a-1-1gb
kami:task/470-bug-a-question-writes-invented-knowledge
kami:task/493-bug-the-memory-index-stores-the-raw-utte
kami:task/490-name-the-gap-world-questions-through-the
kami:task/485-run-the-big-model-on-the-workstation-wit
kami:task/489-workstation-deploy-mavgpud-on-workpc-and
kami:task/488-workstation-a-supervisor-that-keeps-llam
kami:task/483-docs-offload-design
kami:task/483-design-offload-ml-to-the-workstation-kee
kami:task/459-docs-refresh-the-qa-plan-against-the-liv
kami:task/446-doc-reorg-tier-the-tree-retire-the-three
kami:fix/367-voice-parks-routine-accept
kami:task/365-dialogue-slots-and-router-slots-are-hand
kami:task/364-snooze-does-nothing-at-runtime-the-gate
kami:task/447-retire-progress-md-the-backlog-and-the-f
kami:task/445-session-workflow
kami:overnight/eco-versioned-traces
kami:overnight/eco-entity-refs
kami:overnight/eco-degraded-suite
kami:overnight/netscan
kami:overnight/smarthome
kami:overnight/replay-simulator
kami:overnight/event-envelope
kami:overnight/coldstart-unlock
kami:overnight/voice-barge-in
kami:overnight/stt-golden-audio
kami:overnight/senses-speaker
kami:overnight/senses-hearing
kami:overnight/senses-media-vision
kami:overnight/mcp-tools
kami:overnight/self-update
kami:overnight/model-swap
kami:overnight/web-crawler
kami:overnight/rss-feeds
kami:overnight/email-poller
kami:overnight/email-extract
kami:overnight/email-imap
kami:overnight/money-zenmoney
kami:overnight/task-priority
kami:overnight/task-capture
kami:overnight/behavior-profile
kami:overnight/day-plan
kami:overnight/ambient-calendar
kami:overnight/local-calendar
kami:overnight/memory-eval
kami:overnight/proactive-proposals
kami:overnight/split-voice-quiet
kami:overnight/nginx-maven-block
kami:overnight/stepup-chat-surface
kami:integration/small-batch
kami:docs/fix-drift
kami:fix/ru-wording
kami:integration/jul31
kami:overnight/resident-1.7b
kami:overnight/nudge-templates
kami:overnight/kiwix-rewrite
kami:overnight/eval-writeup
kami:overnight/fix-truncation
kami:overnight/kiwix-client
kami:overnight/ru-prompts
kami:overnight/external-data
kami:overnight/phrasing-grammar
kami:overnight/talk-eval
kami:overnight/prompt-context
kami:overnight/prompt-address
kami:overnight/eval-label-kill
kami:overnight/delivery-boundary
kami:overnight/address-check
kami:overnight/system-replies-pr
kami:overnight/clock-intent-pr
kami:overnight/embedder-backfill-pr
kami:overnight/embedder-marker-pr
kami:overnight/note-recall-pr
kami:overnight/thinking-off-pr
kami:overnight/dialogue-persist-pr
kami:overnight/persona-2p-pr
kami:overnight/clarify-expiry-pr
kami:overnight/clarify-rework
kami:overnight/phrasing
kami:overnight/bakeoff
kami:overnight/recall-margin
kami:overnight/router-on
kami:overnight/slot-extract
kami:overnight/embedder-e5
kami:overnight/router-refusal
kami:overnight/eval-rerun
kami:overnight/eval-harnesses
kami:overnight/eval-rerun-base
kami:overnight/fmt-gate
kami:overnight/routines-fire
kami:overnight/router-prompt
kami:overnight/away-leak
kami:overnight/recall-eval
kami:overnight/snooze-works
kami:overnight/clarify-wiring
kami:overnight/delivery-tests
kami:overnight/routine-accept
kami:overnight/llm-router-flag
kami:overnight/clarify-data-layer
kami:overnight/loop-rule-tests
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Delete Branch "overnight/mcp-client"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
What
The client half of docs/plans/06-mcp-support.md. Maven becomes an MCP host: she connects out to MCP servers and consumes their tools and resources. She is not an MCP server — nothing here exposes her own capabilities to an outside caller, because the plan does not ask for that direction.
New
internal/mcp/:jsonrpc.go/stdio.go/http.go— hand-rolled JSON-RPC 2.0 over two transports: a stdio subprocess on this box, and streamable HTTP that accepts either a plain JSON reply or an SSE frame (servers disagree about which they send; the Vikunja one sends SSE).client.go—initializehandshake,tools/list,tools/call,resources/list,resources/read. Text content only.manager.go— lazy dial, per-server failure that blocks neither boot nor the other servers, backoff reconnect,Status()for a web surface,Close().allowlist.go— the encoding that lets the existing allowlist carry an MCP tool with no migration: rowvikunja_list_tasks,cmd ["mcp","vikunja","list_tasks"], scopemcp:vikunja.ProposeTool,EnableTool,DisableTool, the act matcher and the confirm turn are untouched.webfetchdoor.go— one guarded fetcher per url server.internal/config: anmcpblock (servers[]withcommand/args/env/dirorurl,allow_private,allow_tools,max_tools,timeout,enabled). Absent, or with nothing enabled, normalises to nil. Bad blocks fail at startup, not at the first turn that needed the tool.internal/webfetch: aPostmethod, because JSON-RPC cannot be a GET, plus response headers onResponseforMcp-Session-Id. It sharesGet's guards exactly.Why this shape
"enabled": true.allow_privateon that server, and each server gets its own fetcher — one loopback exemption must not become a hole for a public endpoint that redirects.readOnlyHintdecidesdestructive. No hint means "assume it mutates", which routes the call through the confirm turn. Guessing wrong in that direction costs a question.allow_tools, andmax_toolsdefaults to 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./tools, on the authed surface.Verified
Against the real Vikunja MCP server on homesrv (
http://localhost:9100/mcp):Tests cover both transports (the stdio one against a real subprocess re-exec, no fixture file), SSE and JSON framing, session echo, the no-
protocolVersionrefusal, reconnect after failure,allow_tools/max_tools, and the config validation.make buildandmake testboth green.Vikunja #251
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.The trust shape is right and it is the part that is hardest to retrofit. One
webfetch.Fetcherper server instead of one shared one is the correct call:allow_privatefor the Vikunja server on loopback cannot leak into anotherserver's public URL, and the comment on
WebfetchDoorsays why. Encoding an MCProw as
cmd: ["mcp", server, tool]avoids a store migration. ProposeTool, thedestructive flag and the confirm turn keep working unchanged. That is the
cheapest way to land this. Treating a missing
readOnlyHintas"assume it mutates" is the right default direction.
1. A silent stdio server wedges the entire manager, not just its own server
stdioTransport.Callholdst.muacross the read loop, andreadLineblocks inbufio.Reader.ReadString. That read never observesctx. Thectx.Err()check at the top of the loop only fires between frames.It is never reached when the child writes nothing at all. The 15s
cfg.TimeoutthatManager.Callinstalls does nothing for this case.
Then it spreads.
Manager.Refreshrunsc.client.alive()while holdingm.mu.alive()goes tostdioTransport.alive(), which takest.mu, which the hungcall still holds. Refresh now blocks forever with
m.muheld, andTools(),Status(),Call()for every other server block behind it.Walked through: a stdio server accepts stdin and stops writing. A python server
that hit an unhandled exception in its own read loop but did not exit is the
ordinary way to get there. Turn 1 calls its tool and hangs. The next daemon
tick calls Refresh and hangs. From that point
Manager.Tools()never returns. Once PR 71 puts the discoveredcatalogue on the act path, every turn hangs, including turns that touch no MCP
tool at all.
Two separate fixes are needed. Do the read on a goroutine and select on
ctx,so a call can abandon a silent pipe. And take the client pointers out of
m.mubefore calling anything on them, in
Refreshexactly asResourcesalreadydoes.
Refreshis the one place in manager.go that calls into a client underthe lock.
2.
maxLinedoes not bound anythingThe comment says it bounds one JSON-RPC frame from a subprocess. The check is
len(line) > maxLineinreadLine, and it runs afterReadString('\n')hasalready assembled the whole line in memory.
ReadStringgrows without limit,the 64 KiB
bufiobuffer only bounds one syscall. A server that emits 500 MBwith no newline gets 500 MB allocated in mavend before the 1 MiB check rejects
it. On the deploy target that is an OOM kill of the core daemon.
The early-return path is worse.
ReadStringcan return an error with anon-empty partial line. That line is returned with no size check at all.
http.gogets this right withsc.Buffer(..., maxLine).io.LimitReaderonthe stdout pipe, or a
bufio.Scannerwith the sameBuffercall, gives stdiothe same property. Either way, the doc comment should stop claiming a bound the
code does not have.
3.
decodeFramewill accept a request from the server as the responseThe SSE branch keeps the last framed object with an
idkey. A JSON-RPCrequest from the server has an id too. Sampling and
roots/listare exactlythat. A server may send one mid-stream before it answers.
httpTransport.Callalso never checks that the id it got back is the id itsent, which the stdio transport does check.
Failure case: the server streams
{"jsonrpc":"2.0","id":7,"method":"sampling/createMessage",...}after the tool result frame.
decodeFramereturns the sampling request.Unmarshalled into
rpcResponseit has noResultand noError, socalltakes the
len(resp.Result) == 0early return and reports success withoutuntouched.
CallToolthen returns("", nil). An empty string and no error is the one answer that lies. The act is logged asdone, the tool never ran, and Maven speaks about an empty result.
Select on
resultorerrorbeing present, and matchidagainst the request,as stdio already does. The same gap makes
ListToolsreturn an empty cataloguerather than an error against a server that behaves this way.
4.
allow_privateis total for that server, and a disabled server is never validatedTwo smaller holes in the same area as the comment block.
WebfetchDoorsetsc.AllowPrivate = cfg.AllowPrivate. That disables thedialer guard for that fetcher entirely, on every hop. The comment argues a
loopback hole must not become a hole for a public endpoint that redirects at the
LAN. Across servers that holds. Within the server that has
the flag, it does not:
http://localhost:9100/mcpresponding 302 tohttp://169.254.169.254/latest/meta-data/is followed, up toMaxRedirects.For a server reached over loopback,
MaxRedirects: -1on that fetcher costsnothing and closes it. A local MCP endpoint has no business redirecting.
Second:
Config.MCPServers()skips servers withenabled: false, andvalidate()runsmcp.Validateon that filtered list. So a block with bothcommandandurl, or a bare hostname as the url, passes startup validationwhile it is dark. The doc on
Enabledsays a block can be written and reviewed before it isswitched on. The review the config layer could give is the one thing skipped. Validate the shape of every
configured server and let
Enabledgate only the dialing.Smaller notes
waitTurnblocks for
HostInterval(1s default), and a dial is three requests to onehost: initialize, the initialized notification,
tools/list. That is 2s ofpure sleeping per dial, and every later
tools/callto that server pays up to1s before the request leaves. The limit was sized for a feed poll loop. An
MCP server probably wants its own, much shorter, interval.
Tool.Descriptionis taken verbatim, unbounded, from a server Maven does notcontrol. Nothing here caps it. The resident model has 4096 tokens of context
total. PR 71 has to defend this seam. The cap belongs
here, next to the
MaxToolscap that exists already.filterToolssorts and then takes the firstMaxTools. The comment callsthat deterministic, and it is. It also hands the choice of which twelve to
the server. A server that grows a thirteenth tool named
aaa_pushes apreviously discovered tool out of the catalogue. Determinism was not the
property worth buying. Preferring already-proposed rows, or refusing to trim
at all without
allow_tools, both keep the catalogue stable.PosttakeshdrandhttpTransport.sendonly ever putsAcceptand the session id in it. A real remote MCP server needs a bearer token. The Vikunja one is reachabletoday only because it is unauthenticated on loopback.
LocalNamecan collide. Servervik, toollist_tasksand servervik_list, tooltasksboth yieldvik_list_tasks. Config-controlled, solow, but the store keys rows by name and the second proposal would land on the
first row.
server, an oversized frame, and a
tools/listoverMaxToolswhere thetrimmed tail held an enabled row.
Landed on master. The stack was one linear chain, so #84 carried every commit from #50 up, and master now contains this branch in full. Merging this PR on its own is an empty diff, so it is closed rather than merged. The review findings for it were fixed in the 2026-08-01 pass and are on master as commits on the stack tip, not on this branch.
Pull request closed