Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 33 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,36 @@ package versions for a release.

## Unreleased

- **Breaking (MCP): the app's tools are no longer listed as MCP tools.** An agent reaches them
through three built-ins that mirror the CLI: `appduct_list_tools` (one-line signatures and
each tool's policy, with `filter`/`limit`/`offset`, like `appduct tools`),
`appduct_describe_tool` (one tool's full schema, like `appduct tools <name>`) and
`appduct_call_tool` (`{ selector?, name, args?, timeoutMs? }`, like `appduct invoke`).
`tools/list` is now a fixed set of built-ins, so an app with hundreds of tools adds three
definitions to an agent's context, not hundreds. `appduct_list_tools` returns 50 tools at a time
unless given `limit`. `selector` takes a session alias or id; a call is routed by session id, so
it fails with `unknown_session` rather than reaching a new device that took over a departed
device's alias. Unknown parameters are rejected (`invalid_request`). `timeoutMs` can only
shorten the tool's own deadline, since the app stops a tool at its declared timeout; a longer
one, or one outside 1000–600000, is rejected rather than clamped. What goes away:
- Calling an app tool by its own name through `tools/call`. It now returns `tool_not_found`,
pointing at `appduct_list_tools` and `appduct_call_tool`.
- `<alias>__<name>` namespacing. With several devices connected, pass `selector` (the
session alias or id) instead.
- `notifications/tools/list_changed`, and the `listChanged` capability.
- MCP-level `outputSchema` enforcement and schema degradation: schemas reach the agent as
data through `appduct_describe_tool`, exactly as registered, whatever their root type. The
React Native SDK no longer warns about non-object output schemas.
- 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. To keep a
person approving destructive calls, set `policy.destructive` to `"prompt"` (it covers tools
annotated `destructiveHint: true`). `"prompt"`-policy
consent itself is unchanged: it is asked per call, via elicitation.
- The React Native SDK's input-schema warning now fires only for a root `type` that rules out
an object (`z.string()`, `z.array(...)`), not for unions or intersections of objects.
- `@appduct/shared` no longer exports `isObjectRootedSchema`.

- **Breaking (MCP): `"prompt"`-policy consent is elicitation-only.** The Claude Code-specific
fallback is gone: `tools/list` no longer emits `_meta["anthropic/requiresUserInteraction"]`, and
the MCP server no longer sends `consent: "client"`. A `"prompt"` tool called from an MCP client
Expand All @@ -18,8 +48,9 @@ package versions for a release.
the elicitation prompt instead; only a client that relied on the flag without supporting
elicitation loses access. To fix that, use a client that supports elicitation, or set the tool's
policy to `"allow"` in `config.json` — which removes the gate for every caller, including the
CLI — and restart the daemon (`appduct daemon stop`; the next command starts it again), since
`config.json` is read once at daemon start.
CLI — and restart the daemon (`appduct daemon stop`), since `config.json` is read once at daemon
start. The restart disconnects every device, which then has to link again, and a running
`appduct mcp` loses its daemon connection, so restart the MCP server in your client too.
- A daemon with this change that receives `consent: "client"` from an older MCP server treats it
as no consent, so the call is denied and audited as `no_consent_channel`.
- `@appduct/shared`: `ToolsCallParams.consent` and the audit record's `consent` narrow to
Expand Down
47 changes: 35 additions & 12 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -461,11 +461,31 @@ belong here:
`appduct mcp` starts a **stdio** MCP server (SDK: `@modelcontextprotocol/sdk`) that
proxies daemon RPC (auto-spawning the daemon like any client):

- `tools/list` mirrors the live registry. One session → tools under their own names;
several → namespaced `<alias>__<name>`. Registry and session changes emit
`notifications/tools/list_changed`, so an agent's tool list tracks the device.
- Tool calls, progress frames, errors (with their `type` preserved), and descriptor
annotations all map through verbatim. Two semantics the MCP surface does add:
- `tools/list` is a fixed set of built-in tools. The app's tools are never listed as MCP
tools of their own; an agent reaches them through three built-ins that mirror the CLI (§10):
`appduct_list_tools` (`appduct tools`: one-line signatures from `renderToolSignature`, each
tool's effective policy, with `filter`/`limit`/`offset` passed through to `tools.list` and
`limit` defaulting to 50), `appduct_describe_tool` (`appduct tools <name>`: the whole
descriptor), and `appduct_call_tool` (`appduct invoke`: `{ selector?, name, args?, timeoutMs? }`).
`timeoutMs` can only shorten the tool's own deadline, since the `tool_call` frame carries no
deadline and the app stops the handler at its declared one (`docs/PROTOCOL.md` §5); a longer value, or one outside
1000–600000 ms, is rejected rather than clamped. A client cancel that arrives while the consent
prompt is open stops the call before `tools.call`, even if the prompt is then accepted. Each
takes the same `selector` as the CLI (alias or session id) and resolves it with
`sessions.describe` first; every later daemon call for that request — `tools.list`,
`tools.call`, the progress subscription, a cancel — names the session by **id**. The daemon
gives a departed session's alias to the next device of the same model, so routing by alias
could run a call, one the user may already have approved, on a different device; by id it fails
with `unknown_session` instead. Results name the session by alias. Unknown parameters are
rejected with `invalid_request` rather than dropped, and `null` counts as absent.
A registry of hundreds of tools therefore costs a client three tool definitions, and nothing
about the registry or the session set changes `tools/list`: the server advertises no
`listChanged` capability and never sends `notifications/tools/list_changed`. Schemas travel as
data inside a tool result rather than as MCP `Tool.inputSchema`/`outputSchema`, so MCP's
object-rooted rule for those fields no longer applies to app schemas.
- Tool call results, progress frames and errors (with their `type` preserved) map through
verbatim: a JSON object result is returned as `structuredContent` as well as text, any other
value as text only. Two semantics the MCP surface does add:
`"prompt"`-policy consent (§12) — one channel, elicitation (issue #10), used whenever the
client declared the `elicitation` capability at `initialize`: a `"prompt"`-policy call sends one `elicitation/create` request naming the
tool, the session alias, and the call's arguments, and an `action: "accept"` reply becomes
Expand Down Expand Up @@ -768,7 +788,7 @@ deviations):
list of everything that changes the registry entry — `name`, `description`,
`timeoutMs` (app-side only, but part of the entry), stringified `annotations`, the
exported input/output JSON Schemas, and `enabled` — so a re-render never emits a
`tool_registry_delta` pair or an agent-side `notifications/tools/list_changed`. Schemas
`tool_registry_delta` pair. Schemas
are compared by identity first and re-exported only when the identity changed
(hoisted/memoized schemas never re-export; an inline `z.object({…})` re-exports once per
render and still matches by shape). A schema that exports no JSON Schema (zod 3, plain
Expand Down Expand Up @@ -821,9 +841,11 @@ deviations):
plain-object rule. The `jsonSchema` half of a pair and every converter result are held to
that same rule, so the forms cannot diverge in what they will publish.

Separately from all of this, an **input schema should be object-typed at its root** to be
usable over MCP — a root `enum`/`const`/`$ref`/`anyOf` is legal JSON Schema but leaves the
agent with no named arguments (issue #34). This is documented, not enforced.
Separately from all of this, an **input schema has to accept a JSON object**, because
`tools.call`'s `args` always are one: a root `type` that rules an object out can never be
satisfied (issue #34), and the React Native SDK dev-warns about it. A root `anyOf`/`oneOf`/
`allOf` of objects is callable, though its signature renders as `(...)`. This is warned about,
not enforced.

Every way a slot can end up with no shape — a missing exporter, an exporter that throws or
returns a non-object, a paired converter that does either — takes the same route: throw in
Expand Down Expand Up @@ -977,6 +999,7 @@ named-pipe path `\\.\pipe\appduct-<user>` behind the same client API.
pin sets; the anchor-CA design is a future option).
- Web/browser client (safe no-op stub only).
- Multiple endpoint candidates in the bootstrap payload.
- A tool whose `input_schema` is not object-rooted is listed but not usefully callable over MCP,
because MCP tool arguments are always an object (§9). Wrapping such arguments so the tool stays
callable is tracked in [issue #34](https://github.com/callstackincubator/appduct/issues/34).
- A tool whose `input_schema` root `type` rules out an object (`"string"`, `"array"`, ...) is
listed but not callable, because `tools.call`'s `args` are always a JSON object (§5). Wrapping
such arguments so the tool stays callable is tracked in
[issue #34](https://github.com/callstackincubator/appduct/issues/34).
8 changes: 5 additions & 3 deletions docs/PROTOCOL.md
Original file line number Diff line number Diff line change
Expand Up @@ -260,7 +260,8 @@ the tool, without `input_schema`/`output_schema`, so agents see a shapeless (`{}
the app-side SDK throws on that in development rather than letting it ship silently. The
daemon never inspects a schema's internals — only that it is a JSON object.

`annotations` map 1:1 to MCP tool annotations and drive the daemon's policy engine
`annotations` are shown to agents as-is (`appduct tools <name>`, `appduct_describe_tool` over
MCP) and drive the daemon's policy engine
(`docs/ARCHITECTURE.md` §12):
`destructiveHint: true` routes a call through `policy.destructive` instead of
`policy.default`.
Expand All @@ -277,7 +278,8 @@ is snake_case here like every other protocol-defined descriptor field, while the
layers. A camelCase key on this descriptor is an unknown extra, not a deadline. It is the app's *explicit* per-tool value only —
never an app-wide default such as `defaultToolTimeoutMs`. Older apps omit the field
entirely and keep the daemon's 10 s default, so it is safe to add in either direction. It
is a daemon-side scheduling hint and is never emitted on the MCP `Tool` JSON.
is a daemon-side scheduling hint; agents see it through `appduct tools <name>` and
`appduct_describe_tool`.

## 6. Session state machine

Expand Down Expand Up @@ -355,7 +357,7 @@ types also establish these details:
- `link.create` also accepts `addressOverride` (forces the bootstrap payload's advertised
address — used by the emulator/simulator fast path to force `127.0.0.1`).
- `tools.call`'s result carries a `callId` alongside `result`, so a caller juggling
several in-flight calls (the MCP server proxying concurrent `tools/call` requests) can
several in-flight calls (the MCP server running concurrent `appduct_call_tool` requests) can
match `tool_call_progress`/`tool_call_finished` events back to the call that produced
them without guessing from data shape.
- `tools.cancel({ selector?, callId, reason? })` sends `tool_cancel` (above) to the
Expand Down
11 changes: 8 additions & 3 deletions docs/SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -284,9 +284,10 @@ tool with no purpose outside a local dev loop, say. It just shouldn't be the exa
app copies for hardening.

**Consequence for agents and E2E flows:** because registration is the app-side allowlist,
`tools/list` legitimately differs per build artifact. A CI testing build may expose a
the tool set legitimately differs per build artifact. A CI testing build may expose a
different tool set than a local dev build or a hardened production build. Automated flows
should discover tools via `tools/list` rather than assume a fixed set is always present.
should discover tools (`appduct tools`, or `appduct_list_tools` over MCP) rather than assume
a fixed set is always present.

## Key handling rules

Expand Down Expand Up @@ -351,7 +352,11 @@ not as the mechanism that keeps a destructive tool out of reach of a hostile one
`policy.tools["<alias>/<name>"]` overrides) to `"deny"` for anything you don't want an
arbitrary caller invoking against a production build. Every `tools.call` — CLI, MCP, and
`appduct/client` alike — is evaluated against this before it ever reaches the app;
a denial returns `policy_denied` and never sends a `tool_call` frame. `"prompt"` requires a human gate
a denial returns `policy_denied` and never sends a `tool_call` frame. Over MCP, the client's own
permission prompt covers `appduct_call_tool` as a whole rather than each app tool, so an operator
who "always allows" it has approved every app tool; `policy.destructive: "prompt"` is how to keep
a human approving each call to a tool annotated `destructiveHint: true` (an unannotated tool
falls under `policy.default`). `"prompt"` requires a human gate
and fails closed everywhere one can't be guaranteed: today the only implemented gate
is an MCP client that declares the `elicitation` capability, which receives an
`elicitation/create` prompt for each call; the CLI and every other client are denied outright
Expand Down
28 changes: 6 additions & 22 deletions docs/TOOLS.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ An agent can only use a tool it can see the shape of, so every form below except

A Standard Schema does not have to be a plain object: arktype's `Type` is callable, and is detected the same way (anything carrying `~standard.validate`).

Whatever form you use, an **input schema must be object-typed at its root** to be callable over MCP — a root `enum`, `const`, `$ref`, or `anyOf` is legal JSON Schema but leaves the agent with no named arguments to pass.
Whatever form you use, the **input schema must accept a JSON object**, because a call's arguments always are one — see [Make the input schema accept an object](#make-the-input-schema-accept-an-object).

Appduct has no third-party runtime dependencies and does not bundle a JSON Schema validator, so a raw JSON Schema describes the tool for the agent but never enforces anything. Use a pair when you want both a real shape *and* real validation.

Expand Down Expand Up @@ -87,7 +87,7 @@ All of these throw a `TypeError` at registration naming what to fix.

## Registration is per mount, not per render

The hook registers once when the component mounts and re-registers only when something that changes the registration itself changed: `name`, `description`, the exported input/output JSON Schemas, `annotations`, `timeoutMs`, or `enabled`. Re-rendering the component — including on every keystroke of some unrelated state — sends nothing over the wire and does not make agents re-fetch `tools/list`.
The hook registers once when the component mounts and re-registers only when something that changes the registration itself changed: `name`, `description`, the exported input/output JSON Schemas, `annotations`, `timeoutMs`, or `enabled`. Re-rendering the component — including on every keystroke of some unrelated state — sends nothing over the wire.

**Your handler is always fresh.** The hook registers a stable wrapper that forwards to the handler from the latest render, so a handler that closes over component state sees the current value on the next call without being re-registered and without `useRef` workarounds:

Expand All @@ -113,29 +113,13 @@ Because exportable schemas are compared by their *exported* JSON Schema, the reg

**`deps` is an optional, advanced override.** Passing it replaces the derived key entirely with `useEffect`'s own semantics (`enabled` is still appended), which is occasionally useful — for example, forcing a re-registration on something the descriptor doesn't capture. Most call sites should simply omit it. Pass it consistently if you pass it at all: alternating between passing `deps` and omitting it changes the dependency-array length between renders, which React warns about, exactly as it does for a hand-written `useEffect`.

## Keep both schemas object-rooted
## Make the input schema accept an object

MCP's tool wire shape requires `inputSchema.type` and `outputSchema.type` to be the literal `"object"`, so `z.object({ ... })` (also `.passthrough()`/`z.looseObject(...)` and `z.record(...)`) is the only shape that survives to an agent intact. Anything else cannot be represented:
A tool call always passes its arguments as a JSON object. An `inputSchema` whose root type is something else — `z.string()`, `z.number()`, `z.array(...)` — can never be satisfied, and registering one logs a dev warning naming the tool. Wrap the value instead: `inputSchema: z.object({ sku: z.string() })` rather than `z.string()`.

| Construct | Exports as | Object-rooted? |
| --- | --- | --- |
| `z.object({ ... })`, `.passthrough()`, `z.record(...)` | `type: "object"` | yes |
| `z.array(...)` | `type: "array"` | no |
| `z.string()`, `z.number()`, `z.boolean()`, `z.null()` | `type: "string"` etc. | no |
| `z.union([...])`, `z.object(...).nullable()` | `anyOf` | no — no root `type` at all |
| `z.discriminatedUnion(...)` | `oneOf` | no, even when every branch is an object |
| `z.intersection(a, b)` | `allOf` | no, even when both sides are objects |
Unions and intersections of objects work: `z.union([...])`, `z.discriminatedUnion(...)` and `z.intersection(a, b)` export with no root `type`, and an object argument can still match one of their branches. The one-line signature in `appduct tools` shows their arguments as `(...)`, though, so an agent has to read the full schema (`appduct tools <name>`, or `appduct_describe_tool` over MCP) before it can call them. A single `z.object(...)` gives agents named arguments straight from the listing.

A client validates the *whole* `tools/list` result, so one such schema would otherwise leave the agent with zero tools from your app. Appduct degrades it instead:

| Schema | What Appduct does |
| --- | --- |
| `outputSchema` MCP cannot accept | Drops it from `tools/list`. The tool stays listed and callable; its result arrives as JSON text, with no schema describing it (agents still get `structuredContent` when the result happens to be a JSON object, they just have nothing to validate it against). |
| `inputSchema` MCP cannot accept | Replaces it with a permissive empty object schema, so agents cannot see the tool's real arguments. MCP arguments are always an object, so the tool is not usefully callable this way. |

Both log a dev warning naming the tool when it registers. That warning is a best-effort hint covering the root type only, which is everything zod itself can produce; MCP rejects a little more than that (a `properties` entry that is not an object subschema, such as the `{ a: true }` shorthand, or a `required` that is not an array), and those slip past it. **The authoritative signal is the `appduct mcp:` notice on the MCP server's stderr** — it names the tool and quotes the SDK's own reason for rejecting the schema.

Wrap the value instead — `outputSchema: z.object({ todos: z.array(z.string()) })` rather than `z.array(z.string())` — and agents get the full shape, described and validated. `appduct invoke`, `--json` output, and the JS client are unaffected either way: they carry the real schema and the raw result.
`outputSchema` has no such limit. A result can be any JSON value, and agents see the schema exactly as you wrote it.

## Long-running tools

Expand Down
Loading
Loading