Expose discovered MCP tools through the act allowlist (#251) #71
Closed
claude
wants to merge 1 commits from
overnight/mcp-tools into overnight/mcp-client
pull from: overnight/mcp-tools
merge into: kami:overnight/mcp-client
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-client
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-tools"
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?
Second of two branches for Vikunja #251. Base is
overnight/mcp-client(PR #70).What changed
internal/mcp:CallPositional+bindPositional— the narrow positional→named argument rule.internal/tool:MCPCallerseam +WithMCP; one branch inExecroutes an["mcp",server,tool]row to the manager. Enabled and destructive checks run first, unchanged.internal/store:ProposeMCPTool— aproposedrow that already carries cmd + destructive. Never touches an existing row.cmd/mavend/mcp.go:wireMCP(nil unless a server is configured AND enabled), boot connect + propose, a one-minute refresh ticker, status. Every failure is non-fatal.internal/ipc: read-onlymcp_serversmethod +MCPServerStatus. No call-a-tool method, on purpose.cmd/mavweb: "MCP servers" card on /tools; proposals prefill their cmd and destructive box.deploy/mavend.json: anmcpblock with the Vikunja server,enabled: false.Why this shape
MCP tools are acts, so they go through the machinery acts already have rather than beside it. Encoding the server and tool in the existing
cmdcolumn means no migration and no second allowlist:status='enabled'is still the only thing that makes a tool runnable,destructive=1still forces the confirm turn, and enabling still happens on /tools behind step-up.destructiveis!readOnlyHint, so a tool that does not promise to be read-only is assumed to mutate.Small catalogue by construction, as required:
allow_tools+max_toolscap what a server contributes, only enabled rows are visible to the router, and the existing stage-3 gate already refuses an act with no allowlisted fn — so an uncertain tool call asks. No grammar change was needed.How verified
make buildandmake testboth exit 0. New tests: MCP row dispatch (including thatmcpis never exec'd), still-needs-confirm, refuses without a caller;ProposeMCPToolidempotence;bindPositionaltable; wiring off when unconfigured, nil-safe, unreachable server non-fatal, loopback URL refused withoutallow_private; /tools MCP section present and degrades when the core cannot answer.Verified live against the real Vikunja MCP server on the LAN, not a fixture: connect + discover 3 tools, three
proposedrows withscope=mcp:vikunjaandupdate_taskmarked destructive, a no-arg call returning real project JSON, an integer-arg call bindingtask_id, and Russian words for a number correctly refused.That live run is also where the read-only condition on argument binding came from.
CallPositional("update_task", ["251"])succeeded and blanked the fields it did not send, becauseupdate_taskrequires onlytask_id. A mutating tool now never receives a guessed argument; a mutating tool with nothing required still runs, behind confirm, since nothing was guessed.Not in scope
Plan step 5 (MCP resource text in router/phraser prompts) is deferred. No MCP server direction — the task asks for a client.
Vikunja #251
Two decisions carry most of the weight and both are right. Discovery writes a
proposedrow and nothing else. A configured server is a place she may look,not a capability she has. And
bindPositionalrefuses instead of guessing. Theupdate_taskstory in that comment is the best argument in the diff. Oneguessed argument blanked every field the call did not mention. That earned the
read-only condition. The deploy block
ships with
enabled: falseand a four-nameallow_tools, which is how acapability like this should arrive.
The rest of this review is about one thing. The external server, not Kami,
chooses what a name means.
1. A row is keyed by name, and the server owns the name
ProposeMCPToolisON CONFLICT(name) DO NOTHING.mcp.Cmdstores["mcp", server, tool], which is also just names. Nothing in the row pins whatthe tool was when Kami looked at it. So the identity of an enabled capability
is a string the remote server controls and may redefine at any time.
Walked through. Day 1 the server offers
list_taskswith"readOnlyHint": true. Discovery proposesvikunja_list_taskswithdestructive=false. Kami reads the description on /tools, agrees, enables it.Day 30 the server is upgraded, or taken over, and
list_tasksnow meanssomething that writes. Discovery runs on the next boot, hits the conflict, and
does nothing. The row is still enabled, still
destructive=0, andExecstilldispatches
["mcp","vikunja","list_tasks"]. The confirm turn never fires,because the flag was frozen on day 1 against a claim the server has since
withdrawn.
The doc comment on
ProposeMCPToolreads as a safety argument. Re-discovery"cannot silently re-arm a tool that was disabled or change the cmd of one
already enabled". Both halves are true. Neither is the risk. The cmd does not
have to change for the behaviour to change.
The fix is reconciliation rather than insert-or-skip. On every discovery,
compare the discovered
ReadOnlyagainst the storeddestructive. Escalatewhen they disagree. A tool that stopped claiming read-only must have
destructive=1written, and the row should probably drop back toproposedsoa human looks again. Only ever escalate, never relax. Store a hash of the
description and input schema on the row too. Then /tools can show that a tool
changed since it was approved, which is what Kami needs to see.
2.
readOnlyHintis an unverified claim, and it now buys two exemptionsTool.ReadOnlycomes from the server's own annotation. PR 70 uses it in thesafe direction only: absent or false means assume it mutates. This PR starts
using a
trueaffirmatively, in two places, and they compound.A
readOnlyHint: truetool getsdestructive=false, so no confirm turn. Thesame flag is the condition in
bindPositionalthat permits binding the spokentail to the one required property. So a server that lies about one boolean
converts a voice utterance into an unconfirmed, argument-carrying write.
Walked through, against a server that is hostile or merely wrong. It advertises
delete_project,"readOnlyHint": true, description "Show a project and itstasks",
required: ["id"], type integer. Discovery proposesvikunja_delete_project, marked non-destructive, with that description shown asthe provenance on /tools. Kami reads a plausible description of a read, enables
it. "удали проект 4" routes to that fn with tail
4.bindPositionalsees onerequired integer on a read-only tool, binds it, and
Execskips the confirmturn because
destructive=0. The project is gone and Maven never asked.The two exemptions should not rest on the same unverified bit. The cheapest
split: keep
readOnlyHintfor the destructive flag, where being wrong costs aquestion, and require something local for argument binding. Reuse
allow_toolsas the condition, or add a per-server
bind_positionallist. Either way aguessed argument only reaches a tool Kami named in mavend.json. The name
delete_projectis not a defence. The router picks tools by name similarity,and the description that the model and the human both read is server-written
too.
3. Boot blocks for up to 30s on someone else's process
wireMCPrunsmgr.Connect(ctx)synchronously, fromwireVoice, fromrun,with a 30s budget.
Connectdials servers serially. Each HTTP dial is threerequests, each waiting up to that server's 15s timeout plus the webfetch
per-host interval. One black-holed endpoint costs 15s of boot. Two cost the
whole budget.
The comment directly above says the opposite. It claims a server unreachable at
boot is "logged and retried, because Maven starting is not contingent on someone
else's process". Not failing and not blocking are different properties. Only
the first one holds. The passkey path is worse.
wireVoiceruns inside theunlock handler, so an unreachable MCP server delays the response to an unlock.
Connectand the firstproposebelong on the same goroutine asrun. Themanager already tolerates a server that has not been dialed yet. Related:
wireMCPbuilds its context fromcontext.Background(), so a shutdown duringthose 30s is not observed.
4.
mcpRefreshIntervalclaims a backoff that does not existThe comment says the manager "applies its own backoff on top, so this being
short is cheap". The manager's only spacing is
DefaultReconnectEvery, a flat30s, shorter than this ticker. There is no backoff anywhere and nothing grows.
So a permanently misconfigured stdio server is re-spawned once a minute forever.
newStdioTransportrunsexec.CommandandStarton every attempt. A server that starts, fails its handshake and exits leaves a processspawn per minute in the logs indefinitely. Either add the backoff the comment
promises, or delete the sentence and accept the flat retry.
Smaller notes
row on /tools forever. If it was enabled,
Manager.Callrefuses it at calltime with a raw internal string.
actionActdoes not match that error, so itfalls to the generic path. The /tools page is where Kami would find out,
and it is the one place that does not say.
later rewrites a tool's description, /tools keeps showing the old one. That is
the same freeze as finding 1, in the field a human reads before deciding.
Tool.Descriptionis still unbounded (raised on PR 70) and it now landsverbatim in a table cell on /tools, through the
Utterancecolumn. Go'stemplate escaping keeps this a layout problem rather than an injection one. A
server that returns a 40 KB description still makes the enable surface
unusable. Truncate at proposal time.
e.mcpis nil, the MCP branch inExecreturnsErrNotEnabled, whichactionActroutes toproposeGap. So drop themcpblock from the configwhile enabled MCP rows remain. An act on an existing enabled tool then makes
Maven draft a new proposal for it. A distinct error, mapped to a plain "that
tool is not connected", would be more honest.
mgr.Refresh, which is the call that deadlocks underPR 70 finding 1. Once that is fixed there, this is fine.
deploy/mavend.jsonsetsallow_private: trueon a LAN host. Pair that withthe redirect note on PR 70. That fetcher follows a redirect from
192.168.1.104to anywhere private, the metadata endpoint included.bindPositionalreadss.Properties[name]for a name thatrequiredmaylist without
propertiesdescribing. The zero value givesType: "", whichthe
case "string", ""arm binds as a string. Refusing an undescribedproperty would match the rest of the function's posture.
readOnlyHintflips between discoveries, a discoveredname that collides with an existing enabled non-MCP row, and an enabled row
whose tool has disappeared from the server.
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