feat(mcp)!: reach app tools through list/describe/call built-ins - #75
Merged
V3RON merged 3 commits intoSep 21, 2026
Merged
Conversation
The MCP server no longer lists the app's tools as MCP tools. An agent
reaches them through three built-ins that mirror the CLI:
appduct_list_tools (signatures and policy, with filter/limit/offset),
appduct_describe_tool (the full descriptor) and appduct_call_tool
({ selector?, name, args?, timeoutMs? }). tools/list is now a fixed set,
so a large registry no longer grows an agent's context, and nothing
about sessions or registries changes it.
Removes the per-tool mapper, <alias>__<name> namespacing and the
list_changed plumbing. Schemas travel as data, so the React Native SDK
only warns about non-object input schemas now.
BREAKING CHANGE: app tools can no longer be called by name through
tools/call, and <alias>__<name> names are gone; use appduct_call_tool
with a selector. The server no longer advertises listChanged.
Address review of #75: - Resolve the selector once, then route tools.list, tools.call, the progress subscription and cancels by session id, so a call never lands on a new device that inherited a departed device's alias. - Reject unknown parameters on the three built-ins instead of dropping them, treat null as absent, and reject an out-of-range timeoutMs instead of clamping it. - Default appduct_list_tools to 50 tools per page. - Warn about an input schema only when its root type rules out an object; unions and intersections of objects are callable. Drop the now-unused isObjectRootedSchema. - Document policy.destructive: "prompt" as the way to keep per-call approval now that client permissions cover appduct_call_tool as a whole, and fix leftover references.
…en-only Address the second review of #75: - A client cancel that arrives while the consent prompt is open now stops the call before tools.call, even if the prompt is accepted. - timeoutMs can only shorten the tool's own deadline, since the app stops the handler at its declared timeout; a longer one is rejected. - appduct_list_tools caps summaries at the CLI's 120 characters, via a summarizeToolDescription helper now shared by both. - Built-in schemas declare minLength for selector/name and the daemon's filter cap. - Correct the policy.destructive guidance (it covers tools annotated destructiveHint, and a daemon restart drops devices and a running appduct mcp), and fix stale schema docs and comments.
V3RON
added this pull request to stack #76
September 21, 2026 12:04
This was referenced Sep 21, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stacked on #73; review that first. This PR's own diff is the last three commits: the feature, then two rounds of review fixes.
Summary
The MCP server no longer lists the app's tools as MCP tools. An agent reaches them the way the CLI does, through three built-ins:
appduct_list_tools{ selector?, filter?, limit?, offset? }→{ session, total, tools: [{ name, signature, summary, policy, annotations? }] }appduct toolsappduct_describe_tool{ selector?, name }→ the full descriptor plussession,signatureandpolicyappduct tools <name>appduct_call_tool{ selector?, name, args?, timeoutMs? }→ the tool's resultappduct invoketools/listis now a fixed set of 7 built-ins. An app with hundreds of tools adds three definitions to an agent's context, not hundreds. Sessions connecting and registries changing never changetools/list.What changed
mcp/app-tools.ts: the three built-ins.selectorwithsessions.describefirst. That gives the CLI's rules (alias or session id, else the sole session, elseambiguous_session/no_session).tools.list,tools.call, the progress subscription, a cancel) is routed by session id. The daemon gives a departed device's alias to the next device of the same model, so this is what stops a call, possibly already approved, from landing on a different device. Results name the session by alias.invalid_request, andnullcounts as absent.appduct_list_toolsreturns 50 tools at a time unless givenlimit.timeoutMscan only shorten the tool's own deadline, because the app stops a tool at its declared timeout. A longer value, or one outside 1000–600000, is rejected rather than silently clamped.tools.call, even if the user then accepts.summarizeToolDescriptionhelper now shared by the CLI and MCP.filter,limitandoffsetgo straight totools.list, so the daemon validates them exactly as it does for the CLI.renderToolSignatureintroduced in feat(cli): agent-friendly output — signature tools listing with filter/paging, table-driven global flags, --pretty/--verbose, compact JSON #67.mcp/server.ts:appduct_call_toolruns through the existing call path: elicitation consent from refactor(mcp)!: make elicitation the only "prompt" consent channel #73, the progress stream, and cancellation.timeoutMsoverrides the tool's declared deadline.tool_not_found, pointing atappduct_list_toolsandappduct_call_tool.listChangedand never sendslist_changed.mcp/tool-mapping.ts: per-tool mapping, the schema gate from A tool with a non-objectoutputSchemabreaks the entire MCPtools/list, and non-object results violate thestructuredContentcontract #26, and output-schema enforcement.mcp/tool-namespace.ts:<alias>__<name>naming.fetchEffectiveTools, and thelist_changedrefresh and event subscription.typerules out an object (z.string(),z.array(...)), since a call's args are always a JSON object. Unions and intersections of objects are callable and don't warn.@appduct/shareddrops the now-unusedisObjectRootedSchema.appductREADME (MCP section),ARCHITECTURE.md§9 and §14,TOOLS.md("Keep input schemas object-rooted"),SECURITY.md, the React Native README, the appduct skill, and a CHANGELOG entry.Breaking changes
tools/callwith an app tool's own name, or with an<alias>__<name>name, returnstool_not_found. Useappduct_call_tool, withselectorwhen more than one device is connected.notifications/tools/list_changed, and nolistChangedcapability.mcp__appduct__seed_cart) no longer match anything. The client's permission now coversappduct_call_toolas a whole, so "always allow" there approves every app tool. The README, SECURITY.md and CHANGELOG point operators atpolicy.destructive: "prompt"to keep a person approving destructive calls. Per-call consent for"prompt"tools is unchanged, via elicitation (refactor(mcp)!: make elicitation the only "prompt" consent channel #73).@appduct/sharedno longer exportsisObjectRootedSchema.Testing
mcp-server.test.ts, rewritten, 19 tests against the in-memory daemon:tools/listis exactly the built-ins, with a snapshot of their input schemas.list_changedis sent when sessions or registries change.totalcounted before paging, and alias and session-id selectors.tool_not_foundand a missing name.tool_errorpassthrough,tool_not_foundnever reachingtools.call, selector routing, and thetimeoutMsoverride versus the declared deadline.appduct_call_tool. Elicitation consent, audit and denial behaviour are unchanged. A new check confirmsappduct_list_toolsreportspolicy: "prompt".e2e/mcp.e2e.test.ts: runs a realappduct mcpsubprocess with two fake devices through list, describe and call. It checks thattools/listdoesn't change when a second device connects and thatselectorroutes the call to the right device.unknown_sessioninstead of reaching the new device that took its alias.arguments, top-level tool params,timeout_ms) are rejected before anytools.call, andnulloptional fields are accepted.timeoutMsis rejected.appduct_list_toolsdefaults to 50 tools.limit/offsetcoming through MCP.pnpm turbo run typecheck test --filter=appduct --filter=@appduct/shared --filter=@appduct/react-nativepasses: shared 259 (down from 272 withisObjectRootedSchema's tests), react-native 247, appduct 729 plus 1 skipped. Typecheck is clean andcheck:linkspasses.Review
An adversarial review (fresh context, given only this PR) found no regression in
"prompt"consent, progress, cancellation, error types or audit. Its findings were:appduct_call_toolparams were dropped, and the tool ran with{}invalid_request, listing the allowed keys. Tested.appduct_call_toolas a whole, and the default policy is"allow"policy.destructive: "prompt"in the README, SECURITY.md and CHANGELOG. The default is unchanged.typerules out an object. TOOLS.md, ARCHITECTURE.md and the skill are updated.timeoutMswas clamped silentlynullwas rejected for optional fieldsnullcounts as absentlimitdefaults to 50useAppductTool, PROTOCOL.md, "proxied" wording, selector described as alias-only)Second round
A second fresh review of the combined result confirmed that routing by session id holds on every path. That includes a probe that gave the alias to a new device while the consent prompt was open: the call failed with
unknown_session. Its findings:timeoutMsclaimed to override the deadline, but the app stops a tool at its own declared timeout, so a longer value only led to a retry that ran the tool againtimeoutMscan only shorten the deadline, and a longer one is rejected with the tool's actual deadline. Description and docs updated, with a test covering declared and default deadlines.tools.call. The regression test fails without the fix.appduct_list_toolssummaries had no length cap, so 50 long descriptions made a 200 KB pageappduct tools. Tested.rpc.ts, and thecreate-clihelpappduct daemon stopadvice didn't say the restart drops every device and breaks a runningappduct mcp. Thepolicy.destructivescope was also stated too broadly.destructiveHintminLengthor the filter cap