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
22 changes: 22 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,28 @@ package versions for a release.

## Unreleased

- **New: tool groups.** A tool can declare an optional `group` — a top-level group (`"cart"`)
or one subgroup below it (`"checkout/payment"`); each part matches the tool-name pattern
`[a-zA-Z0-9_-]{1,64}`. Set it with `registerTool`/`useAppductTool`'s `group` option (a change
to it re-registers the tool), with `createToolGroup("cart")` to bind one group for a whole
feature module, or with the `group` parameter of the Swift and Kotlin `register` calls. An
invalid group invalidates the registry snapshot exactly like an invalid `timeout_ms`, so all
three SDKs reject it at registration. A daemon that predates groups ignores the field.
- **New: `appduct tools --group <name>` and `appduct tools --groups`.** `--group checkout` lists
the `checkout` group and all its subgroups (`checkout/payment` lists just that subgroup) and
combines with `--filter`/`--limit`/`--offset`; `--groups` lists only the groups and their tool
counts. Without `--group`, a registry with groups is listed under group headings, and a
truncated listing's footer names the top-level groups to narrow to. `tools.list` gains a
`group` param (applied before `total` and paging) and a `groups` summary of the whole registry
on every result, so `appduct tools --json` now returns `{ tools, total, groups }` and each
entry carries its `group`.
- **New: groups over MCP.** `appduct_list_tools` takes a `group` (same matching as `--group`),
shows each tool's `group`, and returns the `groups` summary on every result, so an agent can
see an app's areas and list one of them. `appduct_describe_tool` includes the tool's `group`.
- **Fixed (iOS): a tool name with a trailing newline (`"tool\n"`) is now rejected**, matching
`@appduct/shared` and Android. The Swift core's name check accepted it because ICU's `$` also
matches before a final line terminator; the daemon would then have rejected the snapshot.

- **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`),
Expand Down
25 changes: 18 additions & 7 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -261,7 +261,7 @@ Methods:
| `sessions.list` | — | `SessionSummary[]` |
| `sessions.describe` | `{ selector? }` | full session detail incl. device metadata, state timestamps, tool count |
| `sessions.revoke` | `{ selector? }` | `{ ok: true }` — closes socket (code 1000), frees alias |
| `tools.list` | `{ selector?, filter?, limit?, offset? }` | `{ tools: ToolsListEntry[], total }` — `tools` is the registry sorted by `name` (code-point order), `filter`ed (case-insensitive substring match against name/description) and paged with `limit`/`offset`; each entry is a `ToolDescriptor` (full schema + annotations) plus the tool's effective `policy: "allow" \| "deny" \| "prompt"` (§12), resolved daemon-side. `total` is the filtered count *before* paging, so a caller can tell how much a page left out |
| `tools.list` | `{ selector?, group?, filter?, limit?, offset? }` | `{ tools: ToolsListEntry[], total, groups }` — `tools` is the registry sorted by `name` (code-point order), narrowed to `group` (PROTOCOL.md §5 syntax, matched by segment: `checkout` includes `checkout/*` and never `checkoutx`; case-sensitive), `filter`ed (case-insensitive substring match against name/description) and paged with `limit`/`offset`; each entry is a `ToolDescriptor` (full schema + annotations + `group`) plus the tool's effective `policy: "allow" \| "deny" \| "prompt"` (§12), resolved daemon-side. `total` is the count matching `group` and `filter` *before* paging, so a caller can tell how much a page left out. `groups: { group: string \| null, total }[]` summarizes the **whole** registry — never narrowed by `group`, `filter` or paging: one entry per top-level group (its `total` includes its subgroups), one per subgroup, and `group: null` for ungrouped tools when there are any; sorted by group path with a parent right before its subgroups, `null` last. A malformed `group` is `invalid_request`, like a bad `limit` |
| `tools.call` | `{ selector?, name, args, timeoutMs?, caller?: "cli" \| "mcp", consent?: "elicitation" }` | `{ result, callId }` on success — `callId` lets a caller with several in-flight calls match `tool_call_progress`/`tool_call_finished` events back to this call; JSON-RPC error with `data.type` preserving the wire error type on failure. `caller` attributes the audit record (§12); `consent` is the MCP server's evidence of a `"prompt"`-policy human gate (§12) — `"elicitation"` after the client accepted an elicitation prompt, absent otherwise (including for the CLI). |
| `tools.cancel` | `{ selector?, callId, reason? }` | `{ cancelled: boolean }` — sends `tool_cancel` (§7) to the app for a still-pending call; `false` for an unknown/already-finished `callId` or no active socket (a no-op, not an error) |
| `events.subscribe` | `{ sessionSelector?, kinds? }` | `{ ok: true }`, then `event` notifications on this connection |
Expand Down Expand Up @@ -464,9 +464,10 @@ proxies daemon RPC (auto-spawning the daemon like any client):
- `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? }`).
tool's `group` and effective policy, with `group`/`filter`/`limit`/`offset` passed through to
`tools.list`, `limit` defaulting to 50, and the daemon's whole-registry `groups` summary on
every result), `appduct_describe_tool` (`appduct tools <name>`: the whole descriptor, `group`
included), 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
Expand Down Expand Up @@ -528,7 +529,14 @@ The per-command reference lives in the [`appduct` package README](../packages/ap
which is where it stays current. `appduct tools`'s human listing renders each tool through
`@appduct/shared`'s `renderToolSignature` (a one-line call signature derived from the tool's JSON
Schema) rather than printing the raw schema, so it stays cheap to read against an app that
registers hundreds of tools. Global flags (`cli/global-flags.ts`'s declarative table): `--json`
registers hundreds of tools. Once any tool declares a `group` (PROTOCOL.md §5), that listing prints
the page's signatures under group headings (subgroups as indented sub-headings, ungrouped tools last
under `(ungrouped)`), and its "Showing n of total" footer names the top-level groups with their
counts so an agent narrows with `--group <name>` rather than guessing a `--filter`. `--group` is
`tools.list`'s `group` param, filtered daemon-side like `--filter`; `--groups` prints only the
`groups` summary (subgroups indented under their parent). Both are listing-only flags, a usage
error next to a tool `<name>`, and a malformed `--group` is a usage error before the daemon is
asked. Global flags (`cli/global-flags.ts`'s declarative table): `--json`
(machine output, NDJSON for streams; compact by default), `--pretty` (indent `--json` output and
embedded JSON values, never NDJSON lines), `--verbose` (include the `meta` block — omitted by
default in both human and `--json` output), `--no-color`, `--state-dir`, `--daemon-restart` (force
Expand Down Expand Up @@ -773,20 +781,23 @@ deviations):
install the listener must call the exported `restoreSession()` (equivalently
`appductClient.restoreSession()`) at startup — it is the only other reader of the
lease, so skipping it drops a resumable session on every JS runtime replacement.
- `registerTool({ name, description, inputSchema?, outputSchema?, annotations?, handler })`
- `registerTool({ name, description, inputSchema?, outputSchema?, annotations?, timeoutMs?, group?, handler })`
→ `{ remove() }`. JS converts/validates the schema and keeps the handler in a local map;
the wire descriptor is validated again natively (per PROTOCOL.md §5) and throws
synchronously on an invalid one. The disposer removes only its own registration (compare
by registration identity, not name). Duplicate name registration logs a dev warning and
overwrites. Native owns the registry itself and its `tool_registry_snapshot`/
`tool_registry_delta` sends; `getRegisteredTools()` reads straight from it.
`createToolGroup(group)` returns this same `registerTool` with `group` bound, for a feature
module that registers several tools in one group; it validates nothing itself (native does,
at registration), so the root and `./noop` entries behave identically.
- `useAppductTool(definition, deps?, { enabled? })` — `useEffect` wrapper around
`registerTool`/`remove`. It registers **once per mount**: the registered handler is a
stable wrapper forwarding to the latest render's `definition.handler`, so a handler
closing over component state is fresh on every call without re-registering. With `deps`
omitted (the documented default) the effect keys off a derived, fixed-length dependency
list of everything that changes the registry entry — `name`, `description`,
`timeoutMs` (app-side only, but part of the entry), stringified `annotations`, the
`timeoutMs` (app-side only, but part of the entry), `group`, stringified `annotations`, the
exported input/output JSON Schemas, and `enabled` — so a re-render never emits a
`tool_registry_delta` pair. Schemas
are compared by identity first and re-exported only when the identity changed
Expand Down
20 changes: 18 additions & 2 deletions docs/PROTOCOL.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,8 @@ must always use the token from its most recent `session_ack`, never a cached old
"input_schema": { "type": "object", "properties": { "a": { "type": "number" }, "b": { "type": "number" } }, "required": ["a", "b"] },
"output_schema": { "type": "object", "properties": { "total": { "type": "number" } } },
"annotations": { "readOnlyHint": true },
"timeout_ms": 60000 }
"timeout_ms": 60000,
"group": "math" }
] }
```

Expand Down Expand Up @@ -249,7 +250,8 @@ can ask "what happened?" after the fact instead of only listening live.
"input_schema": { /* draft 2020-12 JSON Schema */ }, // optional
"output_schema": { /* draft 2020-12 JSON Schema */ }, // optional
"annotations": { "readOnlyHint": true, "destructiveHint": false, "idempotentHint": true },
"timeout_ms": 60000 } // optional, positive integer
"timeout_ms": 60000, // optional, positive integer
"group": "math/arithmetic" } // optional, one or two "/"-separated name segments
```

Schemas come from whatever the app registered the tool with: a Standard Schema JSON Schema
Expand Down Expand Up @@ -281,6 +283,20 @@ entirely and keep the daemon's 10 s default, so it is safe to add in either dire
is a daemon-side scheduling hint; agents see it through `appduct tools <name>` and
`appduct_describe_tool`.

`group` puts the tool in an app-declared group so an agent can list a large registry one
area at a time (`tools.list`'s `group` param, `appduct tools --group`). It is a single string
of one or two `/`-separated segments, each matching the name pattern
`^[a-zA-Z0-9_-]{1,64}$`: a top-level group (`checkout`) or a subgroup (`checkout/payment`),
nothing deeper. As one pattern: `^[a-zA-Z0-9_-]{1,64}(/[a-zA-Z0-9_-]{1,64})?$`, matched against
the whole string. Anything else — an empty string, an empty segment (`checkout/`, `/payment`,
`a//b`), three or more segments, any other character, a non-string, or an explicit `null` —
fails `isToolDescriptor` and invalidates the whole snapshot, exactly like a bad `timeout_ms`.
Omit the field for an ungrouped tool. Groups are matched by segment and case-sensitively:
selecting `checkout` includes `checkout/*`, and never `checkoutx`. `group` is a descriptor
field rather than an annotation because `annotations` is exactly the three MCP hints above; it
is never emitted on the MCP `Tool` JSON. A daemon that predates groups ignores the field (like
any unknown descriptor key), so an app can send it to any daemon.

## 6. Session state machine

```
Expand Down
31 changes: 30 additions & 1 deletion docs/TOOLS.md
Original file line number Diff line number Diff line change
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.
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`, `group`, 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`.

**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,6 +113,35 @@ 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`.

## Group tools in a large app

Once your app registers more tools than fit on a screen, give each one a `group`. An agent then runs `appduct tools --groups` to see your app's areas, and `appduct tools --group cart` to list one of them, instead of guessing words to `--filter` on.

```ts
useAppductTool({
name: "add_item",
description: "Add a product to the cart",
group: "cart",
inputSchema: z.object({ sku: z.string(), quantity: z.number() }),
handler: async ({ sku, quantity }) => cart.add(sku, quantity),
});
```

A group is a top-level name (`cart`) or one subgroup below it (`checkout/payment`). Each part uses the same characters as a tool name (letters, digits, `_` and `-`, at most 64). Nothing deeper than one subgroup is allowed. Add a subgroup only when a group itself outgrows a screen: `appduct tools --group checkout` lists `checkout` together with every `checkout/...` subgroup, and `--group checkout/payment` lists only that subgroup.

To register several tools in one group without repeating its name, bind it once with `createToolGroup`:

```ts
import { createToolGroup } from "@appduct/react-native";

const registerCartTool = createToolGroup("cart");

registerCartTool({ name: "add_item", description: "Add a product to the cart", handler: addItem });
registerCartTool({ name: "clear_cart", description: "Remove every item from the cart", handler: clearCart });
```

A malformed group (`"checkout/"`, `"a/b/c"`, `"check out"`) makes the registration throw, like a malformed tool name. Groups only change how tools are listed, by `appduct tools` and by `appduct_list_tools` over MCP. They don't change tool names or how tools are called.

## Make the input schema accept an object

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()`.
Expand Down
Loading
Loading