Skip to content

feat(mcp)!: reach app tools through list/describe/call built-ins - #75

Merged
V3RON merged 3 commits into
refactor/drop-requires-user-interactionfrom
feat/mcp-tool-discovery
Sep 21, 2026
Merged

V3RON merged 3 commits into
refactor/drop-requires-user-interactionfrom
feat/mcp-tool-discovery

Conversation

@V3RON

@V3RON V3RON commented Sep 21, 2026

Copy link
Copy Markdown
Contributor

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:

Built-in Input CLI equivalent
appduct_list_tools { selector?, filter?, limit?, offset? }{ session, total, tools: [{ name, signature, summary, policy, annotations? }] } appduct tools
appduct_describe_tool { selector?, name } → the full descriptor plus session, signature and policy appduct tools <name>
appduct_call_tool { selector?, name, args?, timeoutMs? } → the tool's result appduct invoke

tools/list is 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 change tools/list.

What changed

  • New mcp/app-tools.ts: the three built-ins.
    • Each resolves selector with sessions.describe first. That gives the CLI's rules (alias or session id, else the sole session, else ambiguous_session/no_session).
    • Every later daemon call (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.
    • Unknown parameters are rejected with invalid_request, and null counts as absent.
    • appduct_list_tools returns 50 tools at a time unless given limit.
    • timeoutMs can 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.
    • A client cancel that arrives while the consent prompt is open stops the call before tools.call, even if the user then accepts.
    • Summaries are capped at the CLI's 120 characters, using a summarizeToolDescription helper now shared by the CLI and MCP.
    • filter, limit and offset go straight to tools.list, so the daemon validates them exactly as it does for the CLI.
    • Signatures come from the renderToolSignature introduced 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_tool runs through the existing call path: elicitation consent from refactor(mcp)!: make elicitation the only "prompt" consent channel #73, the progress stream, and cancellation.
    • timeoutMs overrides the tool's declared deadline.
    • Calling an app tool by its own name returns tool_not_found, pointing at appduct_list_tools and appduct_call_tool.
    • The server no longer advertises listChanged and never sends list_changed.
  • Removed:
  • React Native SDK: no longer warns about a non-object output schema, which now reaches the agent intact, as data. It warns about an input schema only when its root type rules 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/shared drops the now-unused isObjectRootedSchema.
  • Docs: the appduct README (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/call with an app tool's own name, or with an <alias>__<name> name, returns tool_not_found. Use appduct_call_tool, with selector when more than one device is connected.
  • No notifications/tools/list_changed, and no listChanged capability.
  • MCP client permission rules that named individual app tools (for example mcp__appduct__seed_cart) no longer match anything. The client's permission now covers appduct_call_tool as a whole, so "always allow" there approves every app tool. The README, SECURITY.md and CHANGELOG point operators at policy.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/shared no longer exports isObjectRootedSchema.

Testing

  • mcp-server.test.ts, rewritten, 19 tests against the in-memory daemon:
    • tools/list is exactly the built-ins, with a snapshot of their input schemas.
    • No list_changed is sent when sessions or registries change.
    • Direct-name calls are rejected.
    • List: output shape, filter and paging passthrough with total counted before paging, and alias and session-id selectors.
    • Errors for an ambiguous selector, no session, and an empty selector.
    • Describe: returns every schema exactly as registered, including a non-object output schema. Also tool_not_found and a missing name.
    • Call: object and non-object results, args defaulting and validation, tool_error passthrough, tool_not_found never reaching tools.call, selector routing, and the timeoutMs override versus the declared deadline.
  • Integration and policy tests: the real-daemon integration tests (declared deadline, progress, cancellation) and the policy and audit tests now go through appduct_call_tool. Elicitation consent, audit and denial behaviour are unchanged. A new check confirms appduct_list_tools reports policy: "prompt".
  • e2e/mcp.e2e.test.ts: runs a real appduct mcp subprocess with two fake devices through list, describe and call. It checks that tools/list doesn't change when a second device connects and that selector routes the call to the right device.
  • Review-fix tests:
    • Routing by session id: a call to a departed session's id fails with unknown_session instead of reaching the new device that took its alias.
    • Unknown and misspelled parameters (arguments, top-level tool params, timeout_ms) are rejected before any tools.call, and null optional fields are accepted.
    • An out-of-range or fractional timeoutMs is rejected.
    • appduct_list_tools defaults to 50 tools.
    • The real daemon rejects a bad limit/offset coming through MCP.
    • The React Native warning fires for array, number and nullable-string roots, and stays silent for unions and intersections of objects.
  • Full suite: pnpm turbo run typecheck test --filter=appduct --filter=@appduct/shared --filter=@appduct/react-native passes: shared 259 (down from 272 with isObjectRootedSchema's tests), react-native 247, appduct 729 plus 1 skipped. Typecheck is clean and check:links passes.
  • Not tested: no run with a real agent (Claude Code or another MCP client) against the playground app.

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:

# Finding Resolution
1 A call could land on a different session than the one named: an old device's id resolved to its alias, and the alias could pass to a new device while the consent prompt was open Fixed: everything after the selector is resolved is routed by session id. Tested.
2 Unknown or misspelled appduct_call_tool params were dropped, and the tool ran with {} Fixed: rejected with invalid_request, listing the allowed keys. Tested.
3 Client permission now covers appduct_call_tool as a whole, and the default policy is "allow" Documented policy.destructive: "prompt" in the README, SECURITY.md and CHANGELOG. The default is unchanged.
4 The new React Native warning and docs wrongly said unions of objects can't be called Fixed: the warning fires only when the root type rules out an object. TOOLS.md, ARCHITECTURE.md and the skill are updated.
5 timeoutMs was clamped silently Fixed: an out-of-range value is rejected, and the range is in the schema and description. The second round then made it shorten-only; see below.
6 null was rejected for optional fields Fixed: null counts as absent
7 No default page size Fixed: limit defaults to 50
8 Leftover references (fake daemon header, useAppductTool, PROTOCOL.md, "proxied" wording, selector described as alias-only) Fixed
9 Missing tests for daemon param validation over MCP, id routing and unknown keys Added

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:

# Finding Resolution
1 timeoutMs claimed 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 again Fixed: timeoutMs can 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.
2 A cancel during an open consent prompt didn't stop the call: once the user accepted, the tool ran Fixed: the call is aborted before tools.call. The regression test fails without the fix.
3 appduct_list_tools summaries had no length cap, so 50 long descriptions made a 200 KB page Fixed: capped at 120 characters, the same as appduct tools. Tested.
4 Stale docs: TOOLS.md, ARCHITECTURE.md §11, the CHANGELOG selector wording, PROTOCOL.md, rpc.ts, and the create-cli help Fixed
5 The README's appduct daemon stop advice didn't say the restart drops every device and breaks a running appduct mcp. The policy.destructive scope was also stated too broadly. Fixed: README, SECURITY.md and CHANGELOG now say to set it before connecting, what a restart costs, and that it covers tools annotated destructiveHint
6 Progress correlation can mix up two concurrent calls of the same tool on the same session Older than this PR. The comment now says so instead of calling it unambiguous. Not changed.
7 Schemas didn't declare minLength or the filter cap Fixed

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
V3RON added this pull request to stack #76 September 21, 2026 12:04
@V3RON
V3RON merged commit 251170f into main Sep 21, 2026
8 checks passed
@V3RON
V3RON deleted the feat/mcp-tool-discovery branch September 21, 2026 12:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant