From 42303c828fe9224b78cc1130d74175ce34bb9f40 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Mon, 21 Sep 2026 11:42:48 +0200 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20tool=20groups=20=E2=80=94=20optiona?= =?UTF-8?q?l=20group=20on=20the=20descriptor,=20--group/--groups=20on=20to?= =?UTF-8?q?ols.list?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 20 ++ docs/ARCHITECTURE.md | 18 +- docs/PROTOCOL.md | 20 +- docs/TOOLS.md | 31 ++- packages/appduct/README.md | 42 +++- .../src/__tests__/cli-v2.integration.test.ts | 150 +++++++++++++ .../appduct/src/__tests__/mcp-daemon-fake.ts | 5 +- .../__tests__/mcp-server.integration.test.ts | 49 +++++ .../session-engine.integration.test.ts | 26 +++ .../tool-invocation.integration.test.ts | 85 ++++++++ packages/appduct/src/cli/create-cli.ts | 2 + packages/appduct/src/cli/result-types.ts | 18 +- packages/appduct/src/cli/routes/tools.ts | 20 +- packages/appduct/src/commands/tools.ts | 77 +++++-- packages/appduct/src/daemon/daemon.ts | 30 ++- packages/appduct/src/output.ts | 169 ++++++++++++++- packages/native/android/README.md | 11 + .../java/com/callstack/appduct/Appduct.kt | 2 + .../callstack/appduct/AppductClientTypes.kt | 17 ++ .../java/com/callstack/appduct/Appduct.kt | 8 +- .../callstack/appduct/AppductClientTypes.kt | 17 ++ .../callstack/appduct/AppductToolRegistry.kt | 17 +- .../appduct/AppductToolRegistryTest.kt | 40 ++++ packages/native/fixtures/README.md | 7 +- .../native/fixtures/tool-descriptors.json | 198 ++++++++++++++++++ packages/native/ios/README.md | 10 + .../Sources/AppductCore/Real/AppductAPI.swift | 12 +- .../Real/AppductToolDescriptor.swift | 54 ++++- .../AppductCore/Stub/AppductAPIStub.swift | 2 + .../AppductCore/Stub/AppductClientStub.swift | 5 +- .../AppductToolDescriptorTests.swift | 33 +++ packages/react-native/README.md | 5 +- packages/react-native/src/Appduct.types.ts | 9 + .../react-native/src/__tests__/client.test.ts | 49 +++++ .../src/__tests__/noop-parity.test.ts | 29 +++ .../src/__tests__/use-appduct-tool.test.ts | 39 ++++ packages/react-native/src/client/index.ts | 1 + packages/react-native/src/index.ts | 22 +- packages/react-native/src/noop.ts | 11 +- packages/react-native/src/public-api.ts | 17 ++ packages/react-native/src/schema.ts | 5 +- packages/react-native/src/tool-group.ts | 31 +++ packages/react-native/src/useAppductTool.ts | 3 +- .../src/__tests__/tool-descriptor.test.ts | 86 ++++++++ packages/shared/src/domains/rpc.ts | 22 +- .../shared/src/domains/tool-descriptor.ts | 104 +++++++++ skills/appduct/SKILL.md | 17 +- 47 files changed, 1576 insertions(+), 69 deletions(-) create mode 100644 packages/react-native/src/tool-group.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 708b0596..23feaaea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,26 @@ 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 ` 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`. The MCP built-ins (`appduct_list_tools` and friends) don't take a + `group` yet. +- **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`), diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 8ba3fee7..1b2b32c1 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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 | @@ -528,7 +528,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 ` 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 ``, 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 @@ -773,20 +780,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 diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md index 303fafa3..c230d846 100644 --- a/docs/PROTOCOL.md +++ b/docs/PROTOCOL.md @@ -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" } ] } ``` @@ -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 @@ -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 ` 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 ``` diff --git a/docs/TOOLS.md b/docs/TOOLS.md index 0fe2b672..137fc8d7 100644 --- a/docs/TOOLS.md +++ b/docs/TOOLS.md @@ -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: @@ -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 `appduct tools` lists your tools. They don't change tool names, how tools are called, or what an MCP client sees. + ## 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()`. diff --git a/packages/appduct/README.md b/packages/appduct/README.md index e588ca12..b7f31236 100644 --- a/packages/appduct/README.md +++ b/packages/appduct/README.md @@ -39,7 +39,8 @@ That's the whole loop. There is no host process to start — `appduct` auto-spaw | `appduct keygen [--out ] [--force]` | generate a daemon private key, print its app pin | | `appduct link [--ttl ] [--qr] [--open android\|ios-sim\|ios-device] [--device ] [--app-id ] [--scheme ]` | mint a pending session and print its deep link | | `appduct ls` | list sessions: alias, state, device, tool count | -| `appduct tools [selector] [name] [--full] [--filter ] [--limit ] [--offset ]` | list a session's tools (one call signature + description per line), or show one tool's full schema | +| `appduct tools [selector] [name] [--full] [--group ] [--filter ] [--limit ] [--offset ]` | list a session's tools (one call signature + description per line), or show one tool's full schema | +| `appduct tools [selector] --groups` | list a session's tool groups with their tool counts | | `appduct invoke [selector] --input '' [--timeout ]` | call a tool | | `appduct events [selector] [--follow] [--since ]` | stream session/tool events (default), or one-shot pull everything retained since `` (`--since`); `--json` emits NDJSON | | `appduct revoke [selector]` | revoke a session | @@ -67,7 +68,44 @@ Run `appduct tools ` for a tool's full schema. A signature is derived straight from the tool's JSON Schema: required params are `name: type`, optional ones `name?: type` (with `= ` when the schema declares a short one), and `-> type` is the result when the tool declares an `output_schema`. `...` anywhere means the schema shape wasn't one this renderer could summarize — the tool's full schema (`appduct tools `) still has it. A `[prompt]`/`[deny]` tag follows a tool whose effective policy isn't `"allow"`. -Use `--filter ` to narrow the listing to tools whose name or description contains `` (case-insensitive), and `--limit `/`--offset ` to page through it; a truncated listing prints a trailing `Showing n of total tools (offset o). Narrow with --filter or page with --offset .` line so you know more were left out. `appduct tools --json` returns `{ tools, total }` — `total` is the count after `--filter` but before `--limit`/`--offset`. `appduct tools ` (a single tool) is unaffected by any of this and always returns the bare tool descriptor. +Use `--filter ` to narrow the listing to tools whose name or description contains `` (case-insensitive), and `--limit `/`--offset ` to page through it; a truncated listing prints a trailing `Showing n of total tools (offset o). Narrow with --filter or page with --offset .` line so you know more were left out. `appduct tools --json` returns `{ tools, total, groups }` — `total` is the count after `--group`/`--filter` but before `--limit`/`--offset`. `appduct tools ` (a single tool) is unaffected by any of this and always returns the bare tool descriptor; passing `--group`, `--groups`, `--filter`, `--limit` or `--offset` with a `` is a usage error. + +### Tool groups + +When the app puts its tools in groups ([`docs/TOOLS.md`](../../docs/TOOLS.md#group-tools-in-a-large-app)), `appduct tools` lists them under group headings, with subgroups indented under their parent and ungrouped tools last: + +``` +Tools + cart + add_item(sku: string, quantity: number) + Add a product to the cart + checkout + begin_checkout() + Start checkout with the current cart + checkout/payment + pay_with_card(card: string) + Pay for the order with a test card + (ungrouped) + reset_app() + Clear all local data +``` + +On a large app, start with `--groups` to see what there is, then list one group: + +``` +$ appduct tools --groups +Groups + cart 12 + checkout 8 + checkout/payment 3 + (ungrouped) 2 + +22 tools in total. Run `appduct tools --group ` to list one group's tools. + +$ appduct tools --group checkout +``` + +`--group checkout` lists `checkout` and all of its subgroups; `--group checkout/payment` lists only that subgroup. Matching is by whole name and case-sensitive, so `--group checkout` never matches a `checkoutx` group. `--group` combines with `--filter`, `--limit` and `--offset`. When a listing without `--group` is cut short, the footer names the top-level groups to narrow to: `Showing 5 of 22 tools (offset 0). Narrow with --group (groups: cart 12, checkout 8) or --filter , or page with --offset .` A group that is not one or two `/`-separated names of letters, digits, `_` and `-` (for example `checkout/` or `a/b/c`) is a usage error. With `--json`, each tool carries its `group`, and `groups` lists every group with its count, whatever `--group` or `--filter` you passed. ### The deep-link scheme diff --git a/packages/appduct/src/__tests__/cli-v2.integration.test.ts b/packages/appduct/src/__tests__/cli-v2.integration.test.ts index 02a4da23..c2550dae 100644 --- a/packages/appduct/src/__tests__/cli-v2.integration.test.ts +++ b/packages/appduct/src/__tests__/cli-v2.integration.test.ts @@ -105,6 +105,11 @@ const runCliHuman = async (args: string[], stateDir: string): Promise<{ stdout: return { stdout, stderr }; }; +/** Headings are colored even when piped (the CLI's palette is not TTY-gated), so line-level + * assertions compare the text without SGR sequences. */ +// eslint-disable-next-line no-control-regex +const stripAnsi = (value: string): string => value.replace(/\[[0-9;]*m/gu, ""); + const connectFakeApp = (port: number): Promise => { return new Promise((resolve, reject) => { const socket = new WebSocket(`wss://127.0.0.1:${port}`, { rejectUnauthorized: false }); @@ -429,4 +434,149 @@ describe("appduct CLI v2: end-to-end command table", () => { }, 20_000, ); + + test( + "tools on a grouped registry: headings, the group-aware footer, --group (parent and subgroup), --groups, and usage errors", + async () => { + const stateDir = await makeTempStateDir(); + + const status = await runCliJson(["daemon", "status"], stateDir); + expect(status.ok).toBe(true); + daemonPids.push((status.data as { daemon: { pid: number } }).daemon.pid); + const port = (status.data as { daemon: { wss_port: number } }).daemon.wss_port; + + const linkResult = await runCliJson(["link", "--ttl", "60"], stateDir); + const linkData = linkResult.data as { deepLink: string }; + const linkPayload = linkData.deepLink + .slice(linkData.deepLink.indexOf("appduct=") + "appduct=".length) + .split("&")[0]!; + const decoded = decodeBootstrap(linkPayload)!; + + const socket = await connectFakeApp(port); + socket.send( + JSON.stringify({ + type: "session_claim", + protocol_version: 2, + session_id: decoded.sessionId, + token: decoded.token, + device_model: "Pixel 8", + }), + ); + const ack = await nextMessage(socket); + const alias = ack.alias as string; + + // The #67 large-registry shape, split into three groups — `checkout` with a `payment` + // subgroup — plus ungrouped tools: 12 cart, 5 checkout + 3 checkout/payment, 6 flags, 4 none. + const tool = (name: string, group?: string) => ({ + name, + description: `Does something with ${name}.`, + ...(group !== undefined ? { group } : {}), + }); + const tools = [ + ...Array.from({ length: 12 }, (_, index) => tool(`cart_${String(index).padStart(2, "0")}`, "cart")), + ...Array.from({ length: 5 }, (_, index) => tool(`checkout_${index}`, "checkout")), + ...Array.from({ length: 3 }, (_, index) => tool(`payment_${index}`, "checkout/payment")), + ...Array.from({ length: 6 }, (_, index) => tool(`flag_${index}`, "flags")), + ...Array.from({ length: 4 }, (_, index) => tool(`misc_${index}`)), + ]; + socket.send(JSON.stringify({ type: "tool_registry_snapshot", session_id: decoded.sessionId, tools })); + await new Promise((resolve) => setTimeout(resolve, 100)); + + // --json: `group` on each entry, `groups` on the listing (whole registry, parent first). + const json = await runCliJson(["tools", alias], stateDir); + expect(json.ok).toBe(true); + const jsonData = json.data as { + tools: Array<{ name: string; group?: string }>; + total: number; + groups: Array<{ group: string | null; total: number }>; + }; + expect(jsonData.total).toBe(30); + expect(jsonData.tools.find((entry) => entry.name === "payment_0")?.group).toBe("checkout/payment"); + expect(jsonData.groups).toEqual([ + { group: "cart", total: 12 }, + { group: "checkout", total: 8 }, + { group: "checkout/payment", total: 3 }, + { group: "flags", total: 6 }, + { group: null, total: 4 }, + ]); + + // Human listing: group headings, a subgroup as an indented sub-heading, ungrouped last. + const human = await runCliHuman(["tools", alias], stateDir); + const lines = stripAnsi(human.stdout).split("\n"); + const indexOfLine = (line: string) => lines.indexOf(line); + expect(indexOfLine(" cart")).toBeGreaterThan(-1); + expect(indexOfLine(" cart_00()")).toBe(indexOfLine(" cart") + 1); + expect(indexOfLine(" checkout")).toBeGreaterThan(indexOfLine(" cart")); + expect(indexOfLine(" checkout/payment")).toBeGreaterThan(indexOfLine(" checkout")); + expect(indexOfLine(" payment_0()")).toBe(indexOfLine(" checkout/payment") + 1); + expect(indexOfLine(" flags")).toBeGreaterThan(indexOfLine(" checkout/payment")); + expect(indexOfLine(" (ungrouped)")).toBeGreaterThan(indexOfLine(" flags")); + expect(indexOfLine(" misc_0()")).toBe(indexOfLine(" (ungrouped)") + 1); + // Nothing was left out, so no footer. + expect(human.stdout).not.toContain("Showing"); + + // Truncated: the footer names the top-level groups (never subgroups) to narrow to. + const truncated = await runCliHuman(["tools", alias, "--limit", "5"], stateDir); + expect(truncated.stdout).toContain( + "Showing 5 of 30 tools (offset 0). Narrow with --group (groups: cart 12, checkout 8, flags 6) " + + "or --filter , or page with --offset .", + ); + + // --group : includes the subgroup; `total` is the group's size. + const parent = await runCliJson(["tools", alias, "--group", "checkout"], stateDir); + const parentData = parent.data as { tools: Array<{ name: string }>; total: number; group: string }; + expect(parentData.total).toBe(8); + expect(parentData.group).toBe("checkout"); + expect(parentData.tools.map((entry) => entry.name)).toEqual([ + "checkout_0", + "checkout_1", + "checkout_2", + "checkout_3", + "checkout_4", + "payment_0", + "payment_1", + "payment_2", + ]); + + // --group /: exactly the subgroup, flat (no headings), under its own title. + const sub = await runCliHuman(["tools", alias, "--group", "checkout/payment"], stateDir); + expect(stripAnsi(sub.stdout)).toContain("Tools in group checkout/payment"); + expect(sub.stdout).toContain(" payment_0()"); + expect(sub.stdout).not.toContain("checkout_0"); + expect(sub.stdout).not.toContain("(ungrouped)"); + + // --group combines with --filter and paging; the footer drops the group hint once narrowed. + const combined = await runCliHuman(["tools", alias, "--group", "cart", "--filter", "cart_1", "--limit", "1"], stateDir); + expect(combined.stdout).toContain("Showing 1 of 2 tools (offset 0). Narrow with --filter or page with --offset ."); + + // --groups: groups and counts only, subgroups indented under their parent. + const groupsHuman = await runCliHuman(["tools", alias, "--groups"], stateDir); + expect(stripAnsi(groupsHuman.stdout)).toContain("Groups\n cart 12\n checkout 8\n checkout/payment 3\n flags 6\n (ungrouped) 4\n"); + expect(groupsHuman.stdout).not.toContain("cart_00"); + + const groupsJson = await runCliJson(["tools", alias, "--groups"], stateDir); + expect(groupsJson.data).toEqual({ groups: jsonData.groups, total: 30 }); + + // Usage errors: a listing flag with a tool name, --groups with a narrowing flag, a bad group. + for (const args of [ + ["tools", alias, "cart_00", "--group", "cart"], + ["tools", "cart_00", "--group", "cart"], + ["tools", alias, "cart_00", "--groups"], + ["tools", alias, "--groups", "--group", "cart"], + ["tools", alias, "--groups", "--filter", "x"], + ["tools", alias, "--group", "a/b/c"], + ["tools", alias, "--group", "checkout/"], + ]) { + const result = await runCliJson(args, stateDir); + expect(result.ok, args.join(" ")).toBe(false); + expect(result.error?.type, args.join(" ")).toBe("usage_error"); + } + + socket.close(); + + const stopResult = await runCliJson(["daemon", "stop"], stateDir); + expect(stopResult.ok).toBe(true); + }, + 30_000, + ); }); diff --git a/packages/appduct/src/__tests__/mcp-daemon-fake.ts b/packages/appduct/src/__tests__/mcp-daemon-fake.ts index 375b078a..4110c78b 100644 --- a/packages/appduct/src/__tests__/mcp-daemon-fake.ts +++ b/packages/appduct/src/__tests__/mcp-daemon-fake.ts @@ -19,6 +19,7 @@ import { RPC_METHODS, + summarizeToolGroups, type ErrorType, type EventNotification, type SessionSummary, @@ -188,7 +189,7 @@ export const createFakeDaemon = (): FakeDaemon => { }; const entries = toolsByAlias.get(resolveSession(selector).alias)!; - // The daemon's `{ tools, total }` shape: sorted by name as the daemon sorts its registry, + // The daemon's `{ tools, total, groups }` shape: sorted by name as the daemon sorts its registry, // `filter`ed on name and description, `total` counted before paging. const lowerFilter = filter?.toLowerCase(); const matching = entries @@ -203,7 +204,7 @@ export const createFakeDaemon = (): FakeDaemon => { const start = offset ?? 0; const tools = matching.slice(start, limit === undefined ? undefined : start + limit); - return { tools, total: matching.length } as TResult; + return { tools, total: matching.length, groups: summarizeToolGroups(entries) } as TResult; } if (method === RPC_METHODS.toolsCall) { diff --git a/packages/appduct/src/__tests__/mcp-server.integration.test.ts b/packages/appduct/src/__tests__/mcp-server.integration.test.ts index 32f9b1a7..ce233737 100644 --- a/packages/appduct/src/__tests__/mcp-server.integration.test.ts +++ b/packages/appduct/src/__tests__/mcp-server.integration.test.ts @@ -345,6 +345,55 @@ describe("mcp: calling app tools", () => { app.socket.close(); }); + test("grouped tools are listed and called over MCP exactly like ungrouped ones, and the group never reaches MCP", async () => { + const { daemon, stateDir, port } = await startTestDaemon(); + const app = await claimApp(daemon, port); + await snapshotTools(daemon, app, [ + { name: "pay", group: "checkout/payment" }, + { name: "begin", group: "checkout" }, + { name: "ping" }, + ]); + + const handle = await createMcpHandle(stateDir); + const client = await connectInMemoryClient(handle); + + const listed = await client.request( + { method: "tools/call", params: { name: "appduct_list_tools", arguments: {} } }, + CallToolResultSchema, + ); + expect((listed.structuredContent as { tools: Array<{ name: string }> }).tools.map((tool) => tool.name)).toEqual([ + "begin", + "pay", + "ping", + ]); + expect(JSON.stringify(listed.structuredContent)).not.toContain("checkout"); + + const described = await client.request( + { method: "tools/call", params: { name: "appduct_describe_tool", arguments: { name: "pay" } } }, + CallToolResultSchema, + ); + expect(described.structuredContent).not.toHaveProperty("group"); + + app.socket.on("message", (data) => { + const msg = JSON.parse(data.toString("utf8")) as Record; + + if (msg.type === "tool_call") { + app.socket.send( + JSON.stringify({ type: "tool_result", session_id: app.sessionId, id: msg.id, result: { paid: true } }), + ); + } + }); + + const called = await client.request( + { method: "tools/call", params: { name: "appduct_call_tool", arguments: { name: "pay" } } }, + CallToolResultSchema, + ); + expect(called.isError).not.toBe(true); + expect(called.structuredContent).toEqual({ paid: true }); + + app.socket.close(); + }); + test("tool_call_progress frames map to MCP progress notifications when the client sends a progressToken", async () => { const { daemon, stateDir, port } = await startTestDaemon(); const app = await claimApp(daemon, port); diff --git a/packages/appduct/src/__tests__/session-engine.integration.test.ts b/packages/appduct/src/__tests__/session-engine.integration.test.ts index c079ba07..a55c66aa 100644 --- a/packages/appduct/src/__tests__/session-engine.integration.test.ts +++ b/packages/appduct/src/__tests__/session-engine.integration.test.ts @@ -450,6 +450,32 @@ describe("session engine: rejection matrix (daemon and other sessions survive ev expect(closeInfo.reason).toBe("invalid_registry"); }); + test("a tool_registry_snapshot with a too-deep group closes 1008 invalid_registry, like a bad timeout_ms", async () => { + const { daemon, port } = await startTestDaemon(); + const link = await createLinkAndDecode(daemon, port); + + const socket = await connectClient(port); + socket.send( + JSON.stringify({ type: "session_claim", protocol_version: 2, session_id: link.sessionId, token: link.token }), + ); + await nextMessage(socket); + + const closed = nextClose(socket); + socket.send( + JSON.stringify({ + type: "tool_registry_snapshot", + session_id: link.sessionId, + tools: [ + { name: "ok", description: "Fine.", group: "checkout/payment" }, + { name: "bad", description: "Too deep.", group: "checkout/payment/card" }, + ], + }), + ); + const closeInfo = await closed; + expect(closeInfo.code).toBe(1008); + expect(closeInfo.reason).toBe("invalid_registry"); + }); + test("unknown post-claim message type closes 1008 unknown_message_type", async () => { const { daemon, port } = await startTestDaemon(); const link = await createLinkAndDecode(daemon, port); diff --git a/packages/appduct/src/__tests__/tool-invocation.integration.test.ts b/packages/appduct/src/__tests__/tool-invocation.integration.test.ts index f5a7f9a9..a67d472d 100644 --- a/packages/appduct/src/__tests__/tool-invocation.integration.test.ts +++ b/packages/appduct/src/__tests__/tool-invocation.integration.test.ts @@ -428,6 +428,91 @@ describe("tools.list / tools.call: round trip", () => { rpcCall(daemon.paths.socketPath, "tools.list", { selector: app.alias, filter: "x".repeat(257) }), ).rejects.toMatchObject({ data: { type: "invalid_request" } }); + for (const group of [42, null, "", "checkout/", "/payment", "a//b", "a/b/c", "a b", "g".repeat(65)]) { + await expect( + rpcCall(daemon.paths.socketPath, "tools.list", { selector: app.alias, group }), + ).rejects.toMatchObject({ data: { type: "invalid_request" } }); + } + + app.socket.close(); + }); + + test("tools.list narrows by group segment before filter, total and paging; groups always reflects the whole registry", async () => { + const { daemon, port } = await startTestDaemon(); + const app = await claimApp(daemon, port); + + await snapshotTools(daemon, app, [ + { name: "add_item", description: "Adds to the cart.", group: "cart" }, + { name: "begin", description: "Starts checkout.", group: "checkout" }, + { name: "pay_card", description: "Pays by card.", group: "checkout/payment" }, + { name: "pay_cash", description: "Pays in cash.", group: "checkout/payment" }, + { name: "set_address", description: "Sets the address.", group: "checkout/address" }, + // A string-prefix match on "checkout" would wrongly include this one. + { name: "lookalike", description: "Not checkout.", group: "checkoutx" }, + { name: "ping", description: "Health check." }, + ]); + + const wholeRegistryGroups = [ + { group: "cart", total: 1 }, + { group: "checkout", total: 4 }, + { group: "checkout/address", total: 1 }, + { group: "checkout/payment", total: 2 }, + { group: "checkoutx", total: 1 }, + { group: null, total: 1 }, + ]; + + type Listing = { tools: Array<{ name: string; group?: string }>; total: number; groups: unknown }; + const list = async (params: Record) => + (await rpcCall(daemon.paths.socketPath, "tools.list", { selector: app.alias, ...params })) as Listing; + + const all = await list({}); + expect(all.total).toBe(7); + expect(all.groups).toEqual(wholeRegistryGroups); + // `group` rides along on each entry, straight off the descriptor. + expect(all.tools.find((tool) => tool.name === "pay_card")?.group).toBe("checkout/payment"); + expect(all.tools.find((tool) => tool.name === "ping")).not.toHaveProperty("group"); + + // A parent includes its subgroups, never a longer top-level name. + const checkout = await list({ group: "checkout" }); + expect(checkout.tools.map((tool) => tool.name)).toEqual(["begin", "pay_card", "pay_cash", "set_address"]); + expect(checkout.total).toBe(4); + expect(checkout.groups).toEqual(wholeRegistryGroups); + + // A subgroup is exactly that subgroup. + const payment = await list({ group: "checkout/payment" }); + expect(payment.tools.map((tool) => tool.name)).toEqual(["pay_card", "pay_cash"]); + expect(payment.total).toBe(2); + + // Case-sensitive, and an unknown group is an empty result, not an error. + const upper = await list({ group: "Checkout" }); + expect(upper.tools).toEqual([]); + expect(upper.total).toBe(0); + expect(upper.groups).toEqual(wholeRegistryGroups); + + // group + filter: `total` counts tools matching both, before paging. + const groupAndFilter = await list({ group: "checkout", filter: "PAYS" }); + expect(groupAndFilter.tools.map((tool) => tool.name)).toEqual(["pay_card", "pay_cash"]); + expect(groupAndFilter.total).toBe(2); + + // group + paging: the page is sliced from the group, `total` is the group's size. + const paged = await list({ group: "checkout", limit: 2, offset: 1 }); + expect(paged.tools.map((tool) => tool.name)).toEqual(["pay_card", "pay_cash"]); + expect(paged.total).toBe(4); + expect(paged.groups).toEqual(wholeRegistryGroups); + + app.socket.close(); + }); + + test("tools.list on a registry with no groups returns only the ungrouped bucket", async () => { + const { daemon, port } = await startTestDaemon(); + const app = await claimApp(daemon, port); + await snapshotTools(daemon, app, [{ name: "echo" }, { name: "ping" }]); + + const listing = (await rpcCall(daemon.paths.socketPath, "tools.list", { selector: app.alias })) as { + groups: unknown; + }; + expect(listing.groups).toEqual([{ group: null, total: 2 }]); + app.socket.close(); }); }); diff --git a/packages/appduct/src/cli/create-cli.ts b/packages/appduct/src/cli/create-cli.ts index 2d9fa5d9..9fbd5c4a 100644 --- a/packages/appduct/src/cli/create-cli.ts +++ b/packages/appduct/src/cli/create-cli.ts @@ -67,6 +67,8 @@ export const createCli = () => { cli .command("tools [selector] [name]", "List a session's tools, or show one tool's full schema.") .option("--full", "Render full schemas/annotations for every listed tool.") + .option("--group ", "Only tools in this group (\"checkout\" includes \"checkout/payment\").") + .option("--groups", "List the session's groups with tool counts instead of its tools.") .option("--filter ", "Only tools whose name or description contains this text (case-insensitive).") .option("--limit ", "Show at most n tools.") .option("--offset ", "Skip the first n tools of the sorted list."); diff --git a/packages/appduct/src/cli/result-types.ts b/packages/appduct/src/cli/result-types.ts index f38eb3b4..e8e84ccf 100644 --- a/packages/appduct/src/cli/result-types.ts +++ b/packages/appduct/src/cli/result-types.ts @@ -11,6 +11,7 @@ import type { EventNotification, SessionSummary, ToolDescriptor, + ToolGroupSummary, ToolsListResult, } from "@appduct/shared"; @@ -147,18 +148,27 @@ export type LinkCommandData = { /** `appduct ls`: `sessions.list` passthrough, verbatim (ARCHITECTURE.md §10: "--json passthrough"). */ export type LsCommandData = SessionSummary[]; -/** `appduct tools`'s listing form: the daemon's `tools.list` result, plus the `--filter`/`--limit`/ - * `--offset` inputs that produced it (only the ones actually given — echoed so `--json` and the +/** `appduct tools`'s listing form: the daemon's `tools.list` result, plus the `--group`/`--filter`/ + * `--limit`/`--offset` inputs that produced it (only the ones actually given — echoed so `--json` and the * human renderer's "Showing n of total" line can report what was asked for without threading the * CLI options through separately). */ export type ToolsListing = ToolsListResult & { offset?: number; limit?: number; + group?: string; filter?: string; }; -/** `appduct tools`: a listing, or a single descriptor when a tool name resolved to a detail lookup. */ -export type ToolsCommandData = ToolsListing | ToolDescriptor; +/** `appduct tools --groups`: the daemon's `groups` summary alone, plus the registry's size. */ +export type ToolGroupsListing = { + groups: ToolGroupSummary[]; + /** Every tool in the session, grouped or not. */ + total: number; +}; + +/** `appduct tools`: a listing, a groups summary (`--groups`), or a single descriptor when a tool + * name resolved to a detail lookup. */ +export type ToolsCommandData = ToolsListing | ToolGroupsListing | ToolDescriptor; /** `appduct invoke`: the tool's raw result payload, printed as-is. */ export type InvokeCommandData = unknown; diff --git a/packages/appduct/src/cli/routes/tools.ts b/packages/appduct/src/cli/routes/tools.ts index 9d964608..35e7feb8 100644 --- a/packages/appduct/src/cli/routes/tools.ts +++ b/packages/appduct/src/cli/routes/tools.ts @@ -1,8 +1,11 @@ /** Route for `appduct tools` — loaded by `cli/dispatch.ts`'s router only when it runs. */ +import { isValidToolGroup } from "@appduct/shared"; + import type { Route } from "../router.js"; import { handleToolsCommand } from "../../commands/tools.js"; +import { usageError } from "../../errors.js"; import { parseNonNegativeIntegerOption, parsePositiveIntegerOption, @@ -29,10 +32,25 @@ export const route: Route = async (context) => { const limit = parsePositiveIntegerOption(options.limit, "--limit"); const offset = parseNonNegativeIntegerOption(options.offset, "--offset"); const filter = readTextOption(context.argv, options.filter, "--filter"); + const group = readTextOption(context.argv, options.group, "--group"); + + // Checked here, not only by the daemon, so a malformed group is a usage error (exit 64) + // that never waits on the daemon — the same rule the daemon's `tools.list` applies. + if (group !== undefined && !isValidToolGroup(group)) { + throw usageError( + `"--group" must be a group name like "checkout" or "checkout/payment" (one or two "/"-separated segments of [a-zA-Z0-9_-], at most 64 characters each); got ${JSON.stringify(group)}.`, + ); + } + + const groups = options.groups === undefined ? undefined : Boolean(options.groups); + + if (groups && options.full) { + throw usageError('"--groups" lists groups only and cannot be combined with "--full".'); + } return guarded(context)(() => handleToolsCommand( - { selector: selector ?? selectorOrTarget, name: target, filter, limit, offset }, + { selector: selector ?? selectorOrTarget, name: target, group, groups, filter, limit, offset }, { stateDir }, ), )(); diff --git a/packages/appduct/src/commands/tools.ts b/packages/appduct/src/commands/tools.ts index da1133a8..3359e22d 100644 --- a/packages/appduct/src/commands/tools.ts +++ b/packages/appduct/src/commands/tools.ts @@ -1,7 +1,8 @@ /** - * `appduct tools` (ARCHITECTURE.md §10): `tools [selector] [--full] [--filter ] [--limit - * ] [--offset ]` lists tools for a session; `tools [selector] ` shows one tool's full - * schema/annotations. + * `appduct tools` (ARCHITECTURE.md §10): `tools [selector] [--full] [--group ] [--filter + * ] [--limit ] [--offset ]` lists tools for a session; `tools [selector] --groups` + * lists only the session's groups with their tool counts; `tools [selector] ` shows one + * tool's full schema/annotations. * * The command table gives both forms a leading optional `[selector]`, which makes a single * positional argument inherently ambiguous (is it the selector, or the tool name in `tools ` @@ -10,20 +11,25 @@ * exists there (or the implicit selector doesn't resolve, e.g. `ambiguous_session`), fall back to * treating it as a selector and list that session's tools instead. * - * `--filter`/`--limit`/`--offset` only ever reach the daemon on a *listing* request: the detail + * `--group`/`--filter`/`--limit`/`--offset` only ever reach the daemon on a *listing* request: the detail * path (an explicit ``, or the ambiguous single-arg probe above) always asks for the whole, * unpaged registry, so a name lookup can never miss a tool that paging would have left off a page. */ import { RPC_METHODS, type ToolDescriptor, type ToolsListResult } from "@appduct/shared"; -import type { CliResult, ToolsCommandData, ToolsListing } from "../cli/result-types.js"; +import type { CliResult, ToolGroupsListing, ToolsCommandData, ToolsListing } from "../cli/result-types.js"; import { usageError } from "../errors.js"; import { callDaemon, DaemonRpcError, type SpawnFn } from "../rpc/client.js"; export type ToolsCommandOptions = { selector?: string; name?: string; + /** Only tools in this group (`checkout` includes `checkout/*`). Listing only. */ + group?: string; + /** List the session's groups with tool counts instead of its tools. Listing only, and + * exclusive with every other listing flag. */ + groups?: boolean; /** Case-insensitive substring match against name and description. Listing only. */ filter?: string; /** Page size. Listing only. */ @@ -37,7 +43,7 @@ export type ToolsCommandContext = { spawn?: SpawnFn; }; -type ListParams = { filter?: string; limit?: number; offset?: number }; +type ListParams = { group?: string; filter?: string; limit?: number; offset?: number }; const listTools = ( selector: string | undefined, @@ -56,7 +62,21 @@ const findTool = (tools: ToolDescriptor[], name: string): ToolDescriptor | undef }; const hasPagingOptions = (options: ToolsCommandOptions): boolean => { - return options.filter !== undefined || options.limit !== undefined || options.offset !== undefined; + return ( + options.group !== undefined || + options.filter !== undefined || + options.limit !== undefined || + options.offset !== undefined + ); +}; + +const hasListingOnlyOptions = (options: ToolsCommandOptions): boolean => { + return hasPagingOptions(options) || options.groups === true; +}; + +/** Only the daemon-bound listing params — never `selector`/`name`/`groups`. */ +const toListParams = (options: ToolsCommandOptions): ListParams => { + return { group: options.group, filter: options.filter, limit: options.limit, offset: options.offset }; }; /** Echoes back only the paging/filter inputs actually given, alongside the daemon's result — the @@ -65,6 +85,7 @@ const hasPagingOptions = (options: ToolsCommandOptions): boolean => { const toListing = (result: ToolsListResult, params: ListParams): ToolsListing => { return { ...result, + ...(params.group !== undefined ? { group: params.group } : {}), ...(params.filter !== undefined ? { filter: params.filter } : {}), ...(params.limit !== undefined ? { limit: params.limit } : {}), ...(params.offset !== undefined ? { offset: params.offset } : {}), @@ -73,15 +94,45 @@ const toListing = (result: ToolsListResult, params: ListParams): ToolsListing => const listingOnlyError = () => { return usageError( - '"--filter", "--limit", and "--offset" only apply to a tools listing, not a single tool lookup.', + '"--group", "--groups", "--filter", "--limit", and "--offset" only apply to a tools listing, not a single tool lookup.', ); }; +/** `--groups` answers from the `groups` summary alone, which the daemon computes over the whole + * registry: `limit: 1` keeps the tool page itself (which is discarded) down to one entry, and + * `total` with no group/filter is the registry's size. */ +const listGroups = async ( + selector: string | undefined, + context: ToolsCommandContext, +): Promise => { + const result = await listTools(selector, context, { limit: 1 }); + return { groups: result.groups ?? [], total: result.total }; +}; + +const listOrGroups = async ( + selector: string | undefined, + options: ToolsCommandOptions, + context: ToolsCommandContext, +): Promise => { + if (options.groups === true) { + return listGroups(selector, context); + } + + const params = toListParams(options); + return toListing(await listTools(selector, context, params), params); +}; + export const handleToolsCommand = async ( options: ToolsCommandOptions, context: ToolsCommandContext, ): Promise> => { - if (options.name !== undefined && hasPagingOptions(options)) { + if (options.groups === true && hasPagingOptions(options)) { + throw usageError( + '"--groups" lists every group in the session and cannot be combined with "--group", "--filter", "--limit", or "--offset".', + ); + } + + if (options.name !== undefined && hasListingOnlyOptions(options)) { throw listingOnlyError(); } @@ -117,7 +168,7 @@ export const handleToolsCommand = async ( if (tool) { // The same rule as an explicit ` `: silently dropping the listing flags // here would make `tools --limit 5` behave differently from `tools `. - if (hasPagingOptions(options)) { + if (hasListingOnlyOptions(options)) { throw listingOnlyError(); } @@ -127,10 +178,8 @@ export const handleToolsCommand = async ( // Not a tool name on the implicit session (or there is no implicit session): treat the arg as // a selector and list that session's tools instead. - const result = await listTools(options.selector, context, options); - return { ok: true, data: toListing(result, options) }; + return { ok: true, data: await listOrGroups(options.selector, options, context) }; } - const result = await listTools(undefined, context, options); - return { ok: true, data: toListing(result, options) }; + return { ok: true, data: await listOrGroups(undefined, options, context) }; }; diff --git a/packages/appduct/src/daemon/daemon.ts b/packages/appduct/src/daemon/daemon.ts index 199e5238..d396a6b5 100644 --- a/packages/appduct/src/daemon/daemon.ts +++ b/packages/appduct/src/daemon/daemon.ts @@ -12,6 +12,9 @@ import { rm } from "node:fs/promises"; import { MAX_TOOLS_FILTER_LENGTH, RPC_METHODS, + isValidToolGroup, + summarizeToolGroups, + toolGroupMatches, EVENT_KINDS, type EventKind, type ErrorType, @@ -159,6 +162,15 @@ const asToolsListParams = (params: unknown): ToolsListParams => { const { selector } = asSelectorParams(params); const record = asRecordParams(params); + const group = record.group; + + if (group !== undefined && !isValidToolGroup(group)) { + throw new RpcApplicationError( + "invalid_request", + '"group" must be one or two "/"-separated segments, each matching [a-zA-Z0-9_-]{1,64}.', + ); + } + const filter = record.filter; if (filter !== undefined && (typeof filter !== "string" || filter.length > MAX_TOOLS_FILTER_LENGTH)) { @@ -182,6 +194,7 @@ const asToolsListParams = (params: unknown): ToolsListParams => { return { selector, + group: group as string | undefined, filter: filter as string | undefined, limit: limit as number | undefined, offset: offset as number | undefined, @@ -581,7 +594,7 @@ export const startDaemon = async (options: DaemonOptions): Promise { - const { selector, filter, limit, offset } = asToolsListParams(params); + const { selector, group, filter, limit, offset } = asToolsListParams(params); // ARCHITECTURE.md §5: tools.list works for ACTIVE and SUSPENDED sessions alike (the // retained registry survives suspend); only tools.call requires ACTIVE. const resolved = activeSessionManager.resolveForTools(selector); @@ -598,14 +611,23 @@ export const startDaemon = async (options: DaemonOptions): Promise (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)); + // Computed before any narrowing: the summary describes the whole registry, so an agent that + // narrowed to one group (or filtered to nothing) still sees every group it could pick. + const groups = summarizeToolGroups(entries); + + // Group first, then the substring filter, both before `total` — so `total` is "in this + // group and matching this filter", and paging slices that, never the whole registry. + const inGroup = + group === undefined ? entries : entries.filter((entry) => toolGroupMatches(entry.group, group)); + const lowerFilter = filter?.toLowerCase(); const matching = lowerFilter - ? entries.filter( + ? inGroup.filter( (entry) => entry.name.toLowerCase().includes(lowerFilter) || entry.description.toLowerCase().includes(lowerFilter), ) - : entries; + : inGroup; const total = matching.length; const page = @@ -613,7 +635,7 @@ export const startDaemon = async (options: DaemonOptions): Promise { return typeof data === "object" && data !== null && Array.isArray((data as ToolsListing).tools); }; +/** `--groups`' form: a `groups` array and no `tools` array (a listing carries both). */ +const isToolGroupsListing = (data: ToolsCommandData): data is ToolGroupsListing => { + return ( + typeof data === "object" && + data !== null && + Array.isArray((data as ToolGroupsListing).groups) && + !Array.isArray((data as ToolsListing).tools) + ); +}; + +/** The heading for the ungrouped bucket, in both the grouped listing and `--groups`. */ +const UNGROUPED_LABEL = "(ungrouped)"; + +/** How many top-level groups the truncation footer names before pointing at `--groups`. */ +const MAX_FOOTER_GROUPS = 10; + +/** Whether the registry (per the daemon's unfiltered `groups` summary) has any grouped tool. */ +const hasAnyGroup = (groups: readonly ToolGroupSummary[] | undefined): boolean => { + return (groups ?? []).some((entry) => entry.group !== null); +}; + +const topLevelGroups = (groups: readonly ToolGroupSummary[] | undefined): ToolGroupSummary[] => { + return (groups ?? []).filter((entry) => entry.group !== null && !entry.group.includes("/")); +}; + /** "No tools registered"/"No tools match" for an empty listing (compact or `--full` — both share * this line, only the header differs). */ const renderEmptyToolsLine = (data: ToolsListing): string => { @@ -168,32 +196,126 @@ const renderEmptyToolsLine = (data: ToolsListing): string => { return data.filter === undefined ? " No tools registered." : ` No tools match ${JSON.stringify(data.filter)}.`; }; +/** `--group (groups: cart 12, checkout 8)` — the footer's pointer at the registry's + * top-level groups, only for a listing that was not already narrowed to a group. Counts are the + * daemon's whole-registry totals (a parent's includes its subgroups). */ +const renderGroupHint = (data: ToolsListing): string | undefined => { + if (data.group !== undefined || !hasAnyGroup(data.groups)) { + return undefined; + } + + const top = topLevelGroups(data.groups); + const named = top.slice(0, MAX_FOOTER_GROUPS).map((entry) => `${entry.group} ${entry.total}`); + const more = top.length > MAX_FOOTER_GROUPS ? `, ... ${top.length - MAX_FOOTER_GROUPS} more; see --groups` : ""; + + return `--group (groups: ${named.join(", ")}${more})`; +}; + /** The `Showing n of total tools (offset o). Narrow with --filter or page with --offset * .` line — only when the page actually left tools out, so a listing that already shows - * everything (including a filtered one with no more matches) stays quiet. */ + * everything (including a filtered one with no more matches) stays quiet. On a registry with + * groups (and no `--group` given) it names the top-level groups to narrow to first. */ const renderTruncationLine = (data: ToolsListing): string[] => { if (data.tools.length >= data.total) { return []; } + const groupHint = renderGroupHint(data); + const narrow = + groupHint === undefined ? "--filter or" : `${groupHint} or --filter , or`; + return [ "", `Showing ${data.tools.length} of ${data.total} tools (offset ${data.offset ?? 0}). ` + - "Narrow with --filter or page with --offset .", + `Narrow with ${narrow} page with --offset .`, ]; }; +/** One tool's two summary lines (signature + first description line), indented by `indent`. */ +const renderToolSummaryLines = (tool: ToolsListEntry, indent: string): string[] => { + const tag = tool.policy === "allow" ? "" : ` [${tool.policy}]`; + return [`${indent}${renderToolSignature(tool)}${tag}`, `${indent} ${summarizeToolDescription(tool.description)}`]; +}; + +/** + * The page's tools under group headings: top-level groups as headings, subgroups as indented + * sub-headings under their parent, ungrouped tools last under `(ungrouped)`. Headings follow the + * daemon's `groups` order (a parent right before its subgroups); tools keep the daemon's name + * order within each heading. Only headings that have a tool on this page are printed, plus the + * parent heading of any subgroup that does. + */ +const renderGroupedToolLines = (colors: ColorPalette, tools: readonly ToolsListEntry[]): string[] => { + const byGroup = new Map(); + + for (const tool of tools) { + const key = tool.group ?? null; + const bucket = byGroup.get(key); + + if (bucket) { + bucket.push(tool); + } else { + byGroup.set(key, [tool]); + } + } + + // The same order the daemon's `groups` summary uses (parent before its subgroups, code-point + // order, never locale-dependent), derived from the page itself so it holds even for a page + // whose headings the summary would order the same way anyway. + const tops = new Map(); + + for (const key of byGroup.keys()) { + if (key === null) { + continue; + } + + const slash = key.indexOf("/"); + const top = slash === -1 ? key : key.slice(0, slash); + const subs = tops.get(top) ?? []; + + if (slash !== -1) { + subs.push(key); + } + + tops.set(top, subs); + } + + const byCodePoint = (a: string, b: string): number => (a < b ? -1 : a > b ? 1 : 0); + const lines: string[] = []; + + for (const top of [...tops.keys()].sort(byCodePoint)) { + lines.push(` ${colors.cyan(top)}`); + lines.push(...(byGroup.get(top) ?? []).flatMap((tool) => renderToolSummaryLines(tool, " "))); + + for (const sub of tops.get(top)!.sort(byCodePoint)) { + lines.push(` ${colors.cyan(sub)}`); + lines.push(...byGroup.get(sub)!.flatMap((tool) => renderToolSummaryLines(tool, " "))); + } + } + + const ungrouped = byGroup.get(null); + + if (ungrouped) { + lines.push(` ${colors.cyan(UNGROUPED_LABEL)}`); + lines.push(...ungrouped.flatMap((tool) => renderToolSummaryLines(tool, " "))); + } + + return lines; +}; + const renderToolSummaryTable = (colors: ColorPalette, data: ToolsListing): string[] => { if (data.tools.length === 0) { return [colors.green("Tools"), renderEmptyToolsLine(data)]; } + // Headings only when the registry has groups and the listing was not already narrowed to one — + // under `--group` every tool is in the requested group, so a heading would say nothing new. + const grouped = data.group === undefined && hasAnyGroup(data.groups); + return [ - colors.green("Tools"), - ...data.tools.flatMap((tool) => { - const tag = tool.policy === "allow" ? "" : ` [${tool.policy}]`; - return [` ${renderToolSignature(tool)}${tag}`, ` ${summarizeToolDescription(tool.description)}`]; - }), + colors.green(data.group === undefined ? "Tools" : `Tools in group ${data.group}`), + ...(grouped + ? renderGroupedToolLines(colors, data.tools) + : data.tools.flatMap((tool) => renderToolSummaryLines(tool, " "))), ...renderTruncationLine(data), "", "Run `appduct tools ` for a tool's full schema.", @@ -208,6 +330,7 @@ const renderToolDetail = (colors: ColorPalette, tool: ToolDescriptor, flags: Glo ["Description", tool.description], ["Input schema", tool.input_schema], ["Output schema", tool.output_schema], + ["Group", tool.group], ["Annotations", tool.annotations], // Only rendered for a tool that declares one; `renderFields` drops undefined rows, so a // tool on the daemon's default deadline shows no line at all rather than a misleading @@ -232,12 +355,44 @@ const renderToolsFullListing = (colors: ColorPalette, data: ToolsListing, flags: ]; }; +/** `appduct tools --groups`: every group with its tool count, subgroups indented under their + * parent, ungrouped last — in the daemon's `groups` order, which already puts a parent right + * before its subgroups. */ +const renderToolGroups = (colors: ColorPalette, data: ToolGroupsListing): string[] => { + if (data.groups.length === 0) { + return [colors.green("Groups"), " No tools registered."]; + } + + const label = (entry: ToolGroupSummary): string => { + if (entry.group === null) { + return UNGROUPED_LABEL; + } + + return entry.group.includes("/") ? ` ${entry.group}` : entry.group; + }; + + const width = Math.max(...data.groups.map((entry) => label(entry).length)); + const countWidth = Math.max(...data.groups.map((entry) => String(entry.total).length)); + + return [ + colors.green("Groups"), + ...data.groups.map((entry) => ` ${label(entry).padEnd(width)} ${String(entry.total).padStart(countWidth)}`), + "", + `${data.total} tool${data.total === 1 ? "" : "s"} in total. ` + + (hasAnyGroup(data.groups) ? "Run `appduct tools --group ` to list one group's tools." : "No tool declares a group."), + ]; +}; + const renderToolsData = ( colors: ColorPalette, data: ToolsCommandData, flags: GlobalFlags, full?: boolean, ): string[] => { + if (isToolGroupsListing(data)) { + return renderToolGroups(colors, data); + } + if (!isToolsListing(data)) { return renderToolDetail(colors, data, flags); } diff --git a/packages/native/android/README.md b/packages/native/android/README.md index c9c99d59..ce747b9b 100644 --- a/packages/native/android/README.md +++ b/packages/native/android/README.md @@ -89,6 +89,17 @@ schema library — the native SDK does no app-side input/output validation (the either). `annotations` takes a `ToolAnnotations(readOnlyHint?, destructiveHint?, idempotentHint?)` matching `PROTOCOL.md` §5. +On an app with many tools, pass `group` so agents can list them one area at a time +(`appduct tools --group cart`). A group is `"cart"` or one subgroup below it, like +`"checkout/payment"`; each part uses tool-name characters (letters, digits, `_`, `-`, at most 64). +A malformed group makes `register` throw `IllegalArgumentException`, like a malformed name: + +```kotlin +Appduct.register(name = "add_item", description = "Add a product to the cart.", group = "cart") { args -> + JSONObject().put("added", args.optString("sku")) +} +``` + A handler's return value is converted to JSON the same way the underlying client always has: `org.json` values pass through, and plain Kotlin/Java `Map`/`List`/`String`/`Number`/`Boolean`/ `null` convert automatically. Anything else fails that one call with `tool_serialization_error` diff --git a/packages/native/android/core-noop/src/main/java/com/callstack/appduct/Appduct.kt b/packages/native/android/core-noop/src/main/java/com/callstack/appduct/Appduct.kt index eced8960..0c2ca3af 100644 --- a/packages/native/android/core-noop/src/main/java/com/callstack/appduct/Appduct.kt +++ b/packages/native/android/core-noop/src/main/java/com/callstack/appduct/Appduct.kt @@ -22,6 +22,7 @@ object Appduct { outputSchema: JSONObject? = null, annotations: ToolAnnotations? = null, timeoutMs: Long? = null, + group: String? = null, handler: suspend (args: JSONObject, context: ToolCallContext) -> Any?, ): ToolRegistration = ToolRegistration(name) {} @@ -34,6 +35,7 @@ object Appduct { outputSchema: JSONObject? = null, annotations: ToolAnnotations? = null, timeoutMs: Long? = null, + group: String? = null, handler: suspend (args: JSONObject) -> Any?, ): ToolRegistration = ToolRegistration(name) {} diff --git a/packages/native/android/core-noop/src/main/java/com/callstack/appduct/AppductClientTypes.kt b/packages/native/android/core-noop/src/main/java/com/callstack/appduct/AppductClientTypes.kt index 77266f87..3b8bfe34 100644 --- a/packages/native/android/core-noop/src/main/java/com/callstack/appduct/AppductClientTypes.kt +++ b/packages/native/android/core-noop/src/main/java/com/callstack/appduct/AppductClientTypes.kt @@ -21,6 +21,9 @@ internal data class AppductToolDescriptor( val outputSchema: JSONObject? = null, val annotations: JSONObject? = null, val timeoutMs: Long? = null, + /** The tool's group (PROTOCOL.md §5): `"checkout"` or a subgroup like `"checkout/payment"`; + * `null` for an ungrouped tool. */ + val group: String? = null, ) { internal fun toWireJson(): JSONObject = JSONObject().apply { @@ -30,6 +33,7 @@ internal data class AppductToolDescriptor( if (outputSchema != null) put("output_schema", outputSchema) if (annotations != null) put("annotations", annotations) if (timeoutMs != null) put("timeout_ms", timeoutMs) + if (group != null) put("group", group) } companion object { @@ -80,6 +84,18 @@ internal data class AppductToolDescriptor( null } + // Unlike the fields above, an explicit JSON `null` is rejected rather than read as + // "absent": `@appduct/shared`'s `isToolDescriptor` only treats a *missing* `group` as + // ungrouped (packages/native/fixtures/tool-descriptors.json's "group-null" case). The + // segment rules themselves are checked by validateAppductToolDescriptor. + val group: String? = + if (obj.has("group")) { + obj.opt("group") as? String + ?: throw AppductInvalidToolDescriptorException("Tool \"$name\" group must be a string.") + } else { + null + } + return AppductToolDescriptor( name = name, description = requiredStringOrEmpty("description", "Tool \"$name\""), @@ -87,6 +103,7 @@ internal data class AppductToolDescriptor( outputSchema = optionalObject("output_schema"), annotations = optionalObject("annotations"), timeoutMs = timeoutMs, + group = group, ) } } diff --git a/packages/native/android/core/src/main/java/com/callstack/appduct/Appduct.kt b/packages/native/android/core/src/main/java/com/callstack/appduct/Appduct.kt index 6acb2f47..c5a4818b 100644 --- a/packages/native/android/core/src/main/java/com/callstack/appduct/Appduct.kt +++ b/packages/native/android/core/src/main/java/com/callstack/appduct/Appduct.kt @@ -76,7 +76,8 @@ object Appduct { /** * Registers (or replaces, by [name]) a tool. Throws `IllegalArgumentException` synchronously - * for an invalid [name]/[description]/[annotations]/[timeoutMs] (PROTOCOL.md §5). [handler] + * for an invalid [name]/[description]/[annotations]/[timeoutMs]/[group] (PROTOCOL.md §5). [group] + * is `"checkout"` or a subgroup like `"checkout/payment"`, or `null` for an ungrouped tool. [handler] * runs on this client's own background dispatcher, never the main thread -- hop to * `Dispatchers.Main` yourself for UI work. Its return value is converted to JSON the same way * this module's `AppductClient` always has (`org.json` values, and plain Kotlin/Java @@ -90,6 +91,7 @@ object Appduct { outputSchema: JSONObject? = null, annotations: ToolAnnotations? = null, timeoutMs: Long? = null, + group: String? = null, handler: suspend (args: JSONObject, context: ToolCallContext) -> Any?, ): ToolRegistration { val descriptor = @@ -100,6 +102,7 @@ object Appduct { outputSchema = outputSchema, annotations = annotations?.toWireJson(), timeoutMs = timeoutMs, + group = group, ) client().registerTool(descriptor) { args, context -> handler(args, ToolCallContext(context)) } return ToolRegistration(name) { client().unregisterTool(name) } @@ -114,9 +117,10 @@ object Appduct { outputSchema: JSONObject? = null, annotations: ToolAnnotations? = null, timeoutMs: Long? = null, + group: String? = null, handler: suspend (args: JSONObject) -> Any?, ): ToolRegistration = - register(name, description, inputSchema, outputSchema, annotations, timeoutMs) { args, _ -> handler(args) } + register(name, description, inputSchema, outputSchema, annotations, timeoutMs, group) { args, _ -> handler(args) } // --- deep links --- diff --git a/packages/native/android/core/src/main/java/com/callstack/appduct/AppductClientTypes.kt b/packages/native/android/core/src/main/java/com/callstack/appduct/AppductClientTypes.kt index 4dd89b62..6d52fd59 100644 --- a/packages/native/android/core/src/main/java/com/callstack/appduct/AppductClientTypes.kt +++ b/packages/native/android/core/src/main/java/com/callstack/appduct/AppductClientTypes.kt @@ -41,6 +41,9 @@ internal data class AppductToolDescriptor( val outputSchema: JSONObject? = null, val annotations: JSONObject? = null, val timeoutMs: Long? = null, + /** The tool's group (PROTOCOL.md §5): `"checkout"` or a subgroup like `"checkout/payment"`; + * `null` for an ungrouped tool. */ + val group: String? = null, ) { internal fun toWireJson(): JSONObject = JSONObject().apply { @@ -50,6 +53,7 @@ internal data class AppductToolDescriptor( if (outputSchema != null) put("output_schema", outputSchema) if (annotations != null) put("annotations", annotations) if (timeoutMs != null) put("timeout_ms", timeoutMs) + if (group != null) put("group", group) } companion object { @@ -109,6 +113,18 @@ internal data class AppductToolDescriptor( null } + // Unlike the fields above, an explicit JSON `null` is rejected rather than read as + // "absent": `@appduct/shared`'s `isToolDescriptor` only treats a *missing* `group` as + // ungrouped (packages/native/fixtures/tool-descriptors.json's "group-null" case). The + // segment rules themselves are checked by validateAppductToolDescriptor. + val group: String? = + if (obj.has("group")) { + obj.opt("group") as? String + ?: throw AppductInvalidToolDescriptorException("Tool \"$name\" group must be a string.") + } else { + null + } + return AppductToolDescriptor( name = name, description = requiredStringOrEmpty("description", "Tool \"$name\""), @@ -116,6 +132,7 @@ internal data class AppductToolDescriptor( outputSchema = optionalObject("output_schema"), annotations = optionalObject("annotations"), timeoutMs = timeoutMs, + group = group, ) } } diff --git a/packages/native/android/core/src/main/java/com/callstack/appduct/AppductToolRegistry.kt b/packages/native/android/core/src/main/java/com/callstack/appduct/AppductToolRegistry.kt index 072b4d1a..15da0cfd 100644 --- a/packages/native/android/core/src/main/java/com/callstack/appduct/AppductToolRegistry.kt +++ b/packages/native/android/core/src/main/java/com/callstack/appduct/AppductToolRegistry.kt @@ -3,6 +3,13 @@ package com.callstack.appduct import org.json.JSONObject private val TOOL_NAME_PATTERN = Regex("^[a-zA-Z0-9_-]{1,64}$") + +/** Mirrors `@appduct/shared`'s `TOOL_GROUP_PATTERN`: one or two `/`-separated tool-name segments. + * `Regex.matches` is a whole-string match, like JS's anchored `RegExp.test`. */ +private val TOOL_GROUP_PATTERN = Regex("^[a-zA-Z0-9_-]{1,64}(?:/[a-zA-Z0-9_-]{1,64})?$") + +/** Whether [group] is a valid tool group (PROTOCOL.md §5) -- `@appduct/shared`'s `isValidToolGroup`. */ +internal fun isValidAppductToolGroup(group: String): Boolean = TOOL_GROUP_PATTERN.matches(group) private const val MAX_TOOL_DESCRIPTION_LENGTH = 4096 private val TOOL_ANNOTATION_KEYS = setOf("readOnlyHint", "destructiveHint", "idempotentHint") @@ -10,7 +17,8 @@ private val TOOL_ANNOTATION_KEYS = setOf("readOnlyHint", "destructiveHint", "ide * Validates a [AppductToolDescriptor] against PROTOCOL.md §5, the same rules * `@appduct/shared`'s `isToolDescriptor` applies: `name` matches `^[a-zA-Z0-9_-]{1,64}$`, * `description` is 1-4096 chars, `annotations` (if present) is a JSON object of only the three - * known boolean keys, and `timeoutMs` (if present) is a positive integer. `inputSchema`/ + * known boolean keys, `timeoutMs` (if present) is a positive integer, and `group` (if present) is + * one or two `/`-separated segments each matching the name pattern. `inputSchema`/ * `outputSchema` are typed as `JSONObject?` already, so "JSON object if present" is guaranteed * structurally and needs no runtime check here. * @@ -55,6 +63,13 @@ internal fun validateAppductToolDescriptor(descriptor: AppductToolDescriptor) { "Tool \"${descriptor.name}\" timeoutMs must be a positive integer.", ) } + + val group = descriptor.group + if (group != null && !isValidAppductToolGroup(group)) { + throw AppductInvalidToolDescriptorException( + "Tool \"${descriptor.name}\" group \"$group\" must be one or two \"/\"-separated segments, each matching ^[a-zA-Z0-9_-]{1,64}$.", + ) + } } internal sealed class AppductRegistryDelta { diff --git a/packages/native/android/core/src/test/java/com/callstack/appduct/AppductToolRegistryTest.kt b/packages/native/android/core/src/test/java/com/callstack/appduct/AppductToolRegistryTest.kt index ad70c603..c0d571ba 100644 --- a/packages/native/android/core/src/test/java/com/callstack/appduct/AppductToolRegistryTest.kt +++ b/packages/native/android/core/src/test/java/com/callstack/appduct/AppductToolRegistryTest.kt @@ -129,6 +129,46 @@ class AppductToolRegistryTest { assertEquals(APPDUCT_MAX_TOOL_TIMEOUT_MS, snapshot[0].getLong("timeout_ms")) } + // --- group --- + + @Test + fun `one- or two-segment groups are accepted`() { + for (group in listOf("checkout", "checkout/payment", "A1_-2/b", "x".repeat(64) + "/" + "y".repeat(64))) { + validateAppductToolDescriptor(AppductToolDescriptor("sum", "Add.", group = group)) + } + } + + @Test + fun `malformed groups are rejected`() { + for (group in listOf("", "/", "checkout/", "/payment", "a//b", "a/b/c", "a b", "checkout\n", "x".repeat(65))) { + assertThrows(group, AppductInvalidToolDescriptorException::class.java) { + validateAppductToolDescriptor(AppductToolDescriptor("sum", "Add.", group = group)) + } + } + } + + @Test + fun `group round-trips through fromJson and the wire snapshot`() { + val parsed = AppductToolDescriptor.fromJson("""{"name":"pay","description":"Pay.","group":"checkout/payment"}""") + assertEquals("checkout/payment", parsed.group) + + val registry = AppductToolRegistry() + registry.upsert(parsed, noopHandler) + assertEquals("checkout/payment", registry.snapshotWireJson()[0].getString("group")) + + registry.upsert(descriptor(name = "ungrouped"), noopHandler) + assertTrue(!registry.snapshotWireJson()[1].has("group")) + } + + @Test + fun `a non-string or null group is rejected by fromJson`() { + for (raw in listOf("42", "null", "[\"a\"]")) { + assertThrows(raw, AppductInvalidToolDescriptorException::class.java) { + AppductToolDescriptor.fromJson("""{"name":"pay","description":"Pay.","group":$raw}""") + } + } + } + @Test fun `an in-range timeoutMs is stored unchanged`() { val registry = AppductToolRegistry() diff --git a/packages/native/fixtures/README.md b/packages/native/fixtures/README.md index 3df7112f..763501a8 100644 --- a/packages/native/fixtures/README.md +++ b/packages/native/fixtures/README.md @@ -71,7 +71,12 @@ invalid characters), the description length bounds (empty, 4096 chars, 4097 char non-string), schema fields that must be a JSON object if present (`input_schema`/`output_schema`), `annotations` that must be a JSON object of only the three known boolean keys, and `timeout_ms` that must be a positive integer if present (rejecting `0`, negative, fractional, and string -values) while a `timeoutMs` camelCase key is a harmless unknown extra, never a substitute. Also +values) while a `timeoutMs` camelCase key is a harmless unknown extra, never a substitute, and +`group` that must be one or two `/`-separated name-pattern segments if present (rejecting an empty +string, empty segments such as `checkout/`, `/payment` and `a//b`, three segments, a 65-character +segment, other characters, a trailing newline, and non-string values including `null`). A +trailing-newline tool name is covered too: ICU's `$` matches before a final line terminator, so the +Swift port must check that its match spans the whole string. Also covers a descriptor that is not a JSON object at all (a string, an array, `null`). Hand-written directly as JSON (no generator needed — these are just JSON Schema-shaped documents, diff --git a/packages/native/fixtures/tool-descriptors.json b/packages/native/fixtures/tool-descriptors.json index 50179074..b5ed8a74 100644 --- a/packages/native/fixtures/tool-descriptors.json +++ b/packages/native/fixtures/tool-descriptors.json @@ -326,6 +326,204 @@ }, "valid": true }, + { + "name": "name-trailing-newline", + "descriptor": { + "name": "tool\n", + "description": "x" + }, + "valid": false + }, + { + "name": "group-valid", + "descriptor": { + "name": "tool", + "description": "x", + "group": "checkout" + }, + "valid": true + }, + { + "name": "group-subgroup-valid", + "descriptor": { + "name": "tool", + "description": "x", + "group": "checkout/payment" + }, + "valid": true + }, + { + "name": "group-segments-64-chars-valid", + "descriptor": { + "name": "tool", + "description": "x", + "group": "gggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggg/gggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggg" + }, + "valid": true + }, + { + "name": "group-mixed-charset-valid", + "descriptor": { + "name": "tool", + "description": "x", + "group": "Feature_1/sub-area_2" + }, + "valid": true + }, + { + "name": "group-omitted-valid", + "descriptor": { + "name": "tool", + "description": "x" + }, + "valid": true + }, + { + "name": "group-empty", + "descriptor": { + "name": "tool", + "description": "x", + "group": "" + }, + "valid": false + }, + { + "name": "group-empty-segment", + "descriptor": { + "name": "tool", + "description": "x", + "group": "checkout/" + }, + "valid": false + }, + { + "name": "group-empty-leading-segment", + "descriptor": { + "name": "tool", + "description": "x", + "group": "/payment" + }, + "valid": false + }, + { + "name": "group-empty-middle-segment", + "descriptor": { + "name": "tool", + "description": "x", + "group": "a//b" + }, + "valid": false + }, + { + "name": "group-slash-only", + "descriptor": { + "name": "tool", + "description": "x", + "group": "/" + }, + "valid": false + }, + { + "name": "group-too-deep", + "descriptor": { + "name": "tool", + "description": "x", + "group": "a/b/c" + }, + "valid": false + }, + { + "name": "group-segment-65-chars", + "descriptor": { + "name": "tool", + "description": "x", + "group": "ggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggg" + }, + "valid": false + }, + { + "name": "group-subgroup-segment-65-chars", + "descriptor": { + "name": "tool", + "description": "x", + "group": "checkout/ggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggg" + }, + "valid": false + }, + { + "name": "group-bad-char", + "descriptor": { + "name": "tool", + "description": "x", + "group": "check out" + }, + "valid": false + }, + { + "name": "group-bad-char-dot", + "descriptor": { + "name": "tool", + "description": "x", + "group": "checkout.payment" + }, + "valid": false + }, + { + "name": "group-bad-char-backslash", + "descriptor": { + "name": "tool", + "description": "x", + "group": "checkout\\payment" + }, + "valid": false + }, + { + "name": "group-non-ascii", + "descriptor": { + "name": "tool", + "description": "x", + "group": "café" + }, + "valid": false + }, + { + "name": "group-trailing-newline", + "descriptor": { + "name": "tool", + "description": "x", + "group": "checkout\n" + }, + "valid": false + }, + { + "name": "group-non-string", + "descriptor": { + "name": "tool", + "description": "x", + "group": 42 + }, + "valid": false + }, + { + "name": "group-array", + "descriptor": { + "name": "tool", + "description": "x", + "group": [ + "checkout" + ] + }, + "valid": false + }, + { + "name": "group-null", + "descriptor": { + "name": "tool", + "description": "x", + "group": null + }, + "valid": false + }, { "name": "descriptor-not-an-object-string", "descriptor": "not-an-object", diff --git a/packages/native/ios/README.md b/packages/native/ios/README.md index 0b0153bd..75156bb2 100644 --- a/packages/native/ios/README.md +++ b/packages/native/ios/README.md @@ -173,6 +173,16 @@ try Appduct.shared.register( - `annotations` (`ToolAnnotations(readOnlyHint:destructiveHint:idempotentHint:)`) and `timeoutMs` are optional, exactly like the JS API's `registerTool`. +- `group` is optional too. On an app with many tools, set it so agents can list them one area at a + time (`appduct tools --group cart`). A group is `"cart"` or one subgroup below it, like + `"checkout/payment"`; each part uses tool-name characters (letters, digits, `_`, `-`, at most 64). + A malformed group makes `register` throw, like a malformed name: + + ```swift + try Appduct.shared.register(name: "add_item", description: "Add a product to the cart.", group: "cart") { args in + ["added": args["sku"] ?? NSNull()] + } + ``` ### Observing connection state, session, and errors diff --git a/packages/native/ios/Sources/AppductCore/Real/AppductAPI.swift b/packages/native/ios/Sources/AppductCore/Real/AppductAPI.swift index 0aa632ff..f0518c59 100644 --- a/packages/native/ios/Sources/AppductCore/Real/AppductAPI.swift +++ b/packages/native/ios/Sources/AppductCore/Real/AppductAPI.swift @@ -54,6 +54,7 @@ public final class Appduct: Sendable { outputSchema: [String: Any]? = nil, annotations: ToolAnnotations? = nil, timeoutMs: Int? = nil, + group: String? = nil, handler: @escaping @Sendable ([String: Any], ToolCallContext) async throws -> Any? ) throws -> ToolRegistration { let descriptor = try makeToolDescriptor( @@ -62,7 +63,8 @@ public final class Appduct: Sendable { inputSchema: inputSchema, outputSchema: outputSchema, annotations: annotations, - timeoutMs: timeoutMs + timeoutMs: timeoutMs, + group: group ) let coreHandler: ToolHandler = { args, context in @@ -87,6 +89,7 @@ public final class Appduct: Sendable { outputSchema: [String: Any]? = nil, annotations: ToolAnnotations? = nil, timeoutMs: Int? = nil, + group: String? = nil, handler: @escaping @Sendable ([String: Any]) async throws -> Any? ) throws -> ToolRegistration { try register( @@ -96,6 +99,7 @@ public final class Appduct: Sendable { outputSchema: outputSchema, annotations: annotations, timeoutMs: timeoutMs, + group: group, handler: { args, _ in try await handler(args) } ) } @@ -285,7 +289,8 @@ private func makeToolDescriptor( inputSchema: [String: Any]?, outputSchema: [String: Any]?, annotations: ToolAnnotations?, - timeoutMs: Int? + timeoutMs: Int?, + group: String? ) throws -> ToolDescriptor { func toJSONObject(_ dict: [String: Any]?, label: String) throws -> JSONObject? { guard let dict else { return nil } @@ -301,7 +306,8 @@ private func makeToolDescriptor( inputSchema: try toJSONObject(inputSchema, label: "inputSchema"), outputSchema: try toJSONObject(outputSchema, label: "outputSchema"), annotations: annotations, - timeoutMs: timeoutMs + timeoutMs: timeoutMs, + group: group ) try validateToolDescriptor(descriptor) return descriptor diff --git a/packages/native/ios/Sources/AppductCore/Real/AppductToolDescriptor.swift b/packages/native/ios/Sources/AppductCore/Real/AppductToolDescriptor.swift index 41a3053e..8a6fe042 100644 --- a/packages/native/ios/Sources/AppductCore/Real/AppductToolDescriptor.swift +++ b/packages/native/ios/Sources/AppductCore/Real/AppductToolDescriptor.swift @@ -36,6 +36,9 @@ public struct ToolDescriptor: Sendable, Equatable { public var annotations: ToolAnnotations? /// Positive integer milliseconds; the app-declared per-call deadline (§5). public var timeoutMs: Int? + /// The tool's group (§5): `"checkout"` or a subgroup like `"checkout/payment"` -- one or two + /// `/`-separated segments, each matching the tool-name pattern. `nil` for an ungrouped tool. + public var group: String? public init( name: String, @@ -43,7 +46,8 @@ public struct ToolDescriptor: Sendable, Equatable { inputSchema: JSONObject? = nil, outputSchema: JSONObject? = nil, annotations: ToolAnnotations? = nil, - timeoutMs: Int? = nil + timeoutMs: Int? = nil, + group: String? = nil ) { self.name = name self.description = description @@ -51,6 +55,7 @@ public struct ToolDescriptor: Sendable, Equatable { self.outputSchema = outputSchema self.annotations = annotations self.timeoutMs = timeoutMs + self.group = group } public var wireValue: JSONValue { @@ -59,6 +64,7 @@ public struct ToolDescriptor: Sendable, Equatable { if let outputSchema { out["output_schema"] = .object(outputSchema) } if let annotations { out["annotations"] = annotations.jsonValue } if let timeoutMs { out["timeout_ms"] = .number(Double(timeoutMs)) } + if let group { out["group"] = .string(group) } return .object(out) } } @@ -77,9 +83,28 @@ private let toolNamePattern: NSRegularExpression = { try! NSRegularExpression(pattern: "^[a-zA-Z0-9_-]{1,64}$") }() +private let toolGroupPattern: NSRegularExpression = { + // Mirrors `@appduct/shared`'s `TOOL_GROUP_PATTERN`: one or two `/`-separated tool-name segments. + // swiftlint:disable:next force_try + try! NSRegularExpression(pattern: "^[a-zA-Z0-9_-]{1,64}(?:/[a-zA-Z0-9_-]{1,64})?$") +}() + +/// A whole-string match. ICU's `$` also matches just before a *trailing* line terminator, so a +/// bare `firstMatch != nil` would accept `"tool\n"` where JS's `RegExp.test` and Kotlin's +/// `Regex.matches` reject it -- the match has to cover the entire string. +private func matchesWholeString(_ pattern: NSRegularExpression, _ value: String) -> Bool { + let range = NSRange(value.startIndex.. Bool { - let range = NSRange(name.startIndex.. Bool { + matchesWholeString(toolGroupPattern, group) } private let toolAnnotationKeys: Set = ["readOnlyHint", "destructiveHint", "idempotentHint"] @@ -88,7 +113,8 @@ private let maxToolDescriptionLength = 4096 /// Ports `@appduct/shared`'s `isToolDescriptor` exactly (PROTOCOL.md §5): name pattern, /// description length 1...4096, `input_schema`/`output_schema` must be JSON objects if present, /// `annotations` must be a JSON object of only the three known boolean keys, and `timeout_ms` must -/// be a positive integer if present. +/// be a positive integer if present, and `group` must be one or two `/`-separated segments each +/// matching the name pattern if present. public func validateToolDescriptor(_ descriptor: ToolDescriptor) throws { guard matchesToolNamePattern(descriptor.name) else { throw ToolDescriptorValidationError( @@ -115,6 +141,12 @@ public func validateToolDescriptor(_ descriptor: ToolDescriptor) throws { "Tool \"\(descriptor.name)\" timeout_ms must be a positive integer." ) } + + if let group = descriptor.group, !isValidToolGroup(group) { + throw ToolDescriptorValidationError( + "Tool \"\(descriptor.name)\" group \"\(group)\" must be one or two \"/\"-separated segments, each matching ^[a-zA-Z0-9_-]{1,64}$." + ) + } } /// Parses a raw wire `ToolDescriptor` JSON object (as `registerTool(descriptorJson:)` receives from @@ -180,13 +212,25 @@ public func parseToolDescriptor(_ value: JSONValue) throws -> ToolDescriptor { timeoutMs = Int(doubleValue) } + // Unlike the fields above, an explicit JSON `null` is rejected rather than read as "absent": + // `@appduct/shared`'s `isToolDescriptor` only treats a *missing* `group` as ungrouped, and + // packages/native/fixtures/tool-descriptors.json's "group-null" case pins all three to that. + var group: String? + if let raw = object["group"] { + guard let groupValue = raw.stringValue else { + throw ToolDescriptorValidationError("Tool \"\(name)\" group must be a string.") + } + group = groupValue + } + let descriptor = ToolDescriptor( name: name, description: description, inputSchema: inputSchema, outputSchema: outputSchema, annotations: annotations, - timeoutMs: timeoutMs + timeoutMs: timeoutMs, + group: group ) try validateToolDescriptor(descriptor) diff --git a/packages/native/ios/Sources/AppductCore/Stub/AppductAPIStub.swift b/packages/native/ios/Sources/AppductCore/Stub/AppductAPIStub.swift index c32236e3..40c3d9b2 100644 --- a/packages/native/ios/Sources/AppductCore/Stub/AppductAPIStub.swift +++ b/packages/native/ios/Sources/AppductCore/Stub/AppductAPIStub.swift @@ -28,6 +28,7 @@ public final class Appduct: Sendable { outputSchema: [String: Any]? = nil, annotations: ToolAnnotations? = nil, timeoutMs: Int? = nil, + group: String? = nil, handler: @escaping @Sendable ([String: Any], ToolCallContext) async throws -> Any? ) throws -> ToolRegistration { ToolRegistration {} @@ -41,6 +42,7 @@ public final class Appduct: Sendable { outputSchema: [String: Any]? = nil, annotations: ToolAnnotations? = nil, timeoutMs: Int? = nil, + group: String? = nil, handler: @escaping @Sendable ([String: Any]) async throws -> Any? ) throws -> ToolRegistration { ToolRegistration {} diff --git a/packages/native/ios/Sources/AppductCore/Stub/AppductClientStub.swift b/packages/native/ios/Sources/AppductCore/Stub/AppductClientStub.swift index 6e5ce72c..d6bcfc67 100644 --- a/packages/native/ios/Sources/AppductCore/Stub/AppductClientStub.swift +++ b/packages/native/ios/Sources/AppductCore/Stub/AppductClientStub.swift @@ -41,6 +41,7 @@ public struct ToolDescriptor: Sendable, Equatable { public var outputSchema: JSONObject? public var annotations: ToolAnnotations? public var timeoutMs: Int? + public var group: String? public init( name: String, @@ -48,7 +49,8 @@ public struct ToolDescriptor: Sendable, Equatable { inputSchema: JSONObject? = nil, outputSchema: JSONObject? = nil, annotations: ToolAnnotations? = nil, - timeoutMs: Int? = nil + timeoutMs: Int? = nil, + group: String? = nil ) { self.name = name self.description = description @@ -56,6 +58,7 @@ public struct ToolDescriptor: Sendable, Equatable { self.outputSchema = outputSchema self.annotations = annotations self.timeoutMs = timeoutMs + self.group = group } } diff --git a/packages/native/ios/Tests/AppductCoreTests/AppductToolDescriptorTests.swift b/packages/native/ios/Tests/AppductCoreTests/AppductToolDescriptorTests.swift index e59770b3..c3e10d18 100644 --- a/packages/native/ios/Tests/AppductCoreTests/AppductToolDescriptorTests.swift +++ b/packages/native/ios/Tests/AppductCoreTests/AppductToolDescriptorTests.swift @@ -85,4 +85,37 @@ final class AppductToolDescriptorTests: XCTestCase { ]) XCTAssertThrowsError(try parseToolDescriptor(wire)) } + + // MARK: group + + func testGroupMustBeOneOrTwoNameSegments() { + for group in ["checkout", "checkout/payment", String(repeating: "g", count: 64)] { + XCTAssertNoThrow(try validateToolDescriptor(ToolDescriptor(name: "tool", description: "x", group: group)), group) + } + for group in ["", "checkout/", "/payment", "a//b", "a/b/c", "a b", "checkout\n", String(repeating: "g", count: 65)] { + XCTAssertThrowsError(try validateToolDescriptor(ToolDescriptor(name: "tool", description: "x", group: group)), group) + } + } + + func testGroupRoundTripsThroughTheWireShape() throws { + let wire = JSONValue.object([ + "name": .string("pay"), + "description": .string("Pay."), + "group": .string("checkout/payment"), + ]) + let descriptor = try parseToolDescriptor(wire) + XCTAssertEqual(descriptor.group, "checkout/payment") + XCTAssertEqual(descriptor.wireValue.objectValue?["group"]?.stringValue, "checkout/payment") + XCTAssertNil(ToolDescriptor(name: "tool", description: "x").wireValue.objectValue?["group"]) + } + + func testRegistryStoresTheGroupItSnapshots() throws { + let registry = AppductToolRegistryStore() + try registry.upsert( + ToolDescriptor(name: "pay", description: "Pay.", group: "checkout/payment"), + handler: { _, _ in .null }, + defaultTimeoutMs: 10_000 + ) + XCTAssertEqual(registry.snapshot().first?.wireValue.objectValue?["group"]?.stringValue, "checkout/payment") + } } diff --git a/packages/react-native/README.md b/packages/react-native/README.md index 3b2a51f1..2180338f 100644 --- a/packages/react-native/README.md +++ b/packages/react-native/README.md @@ -119,7 +119,8 @@ Omit the session selector when only one session is active; pass an alias or sess | Export | Signature / notes | | --- | --- | -| `registerTool` | `({ name, description, inputSchema?, outputSchema?, annotations?, handler })` → `{ remove() }`. The disposer removes only its own registration. | +| `registerTool` | `({ name, description, inputSchema?, outputSchema?, annotations?, timeoutMs?, group?, handler })` → `{ remove() }`. The disposer removes only its own registration. `group` (`"cart"`, or a subgroup like `"checkout/payment"`) lets agents list your tools one area at a time — see [Group tools in a large app](https://github.com/callstackincubator/appduct/blob/main/docs/TOOLS.md#group-tools-in-a-large-app). | +| `createToolGroup` | `(group)` → a `registerTool` that puts every tool it registers in `group`. | | `useAppductTool` | `(definition, deps?, { enabled? })`. Registers once per mount, re-registering only when the descriptor changes; `deps` overrides that derivation. `enabled` defaults to `true`; `false` never registers, and removes any registration that hook owns. | | `handler` | `(args, context)`. `context.signal` is an `AbortSignal`, aborted when the caller cancels or the connection drops mid-call. Forward it (`fetch(url, { signal })`), check `signal.aborted`, or listen for `"abort"` — ignoring it is fine, the handler replies normally. | | `postEvent` | `(name, payload?)` — pushes an app event, read by `appduct events` and the MCP event tools. | @@ -142,7 +143,7 @@ Omit the session selector when only one session is active; pass an alias or sess ## Going further - [Trust modes](https://github.com/callstackincubator/appduct/blob/main/docs/SECURITY.md#trust-modes) and [Configuring trust](https://github.com/callstackincubator/appduct/blob/main/docs/SECURITY.md#configuring-trust) — pins, plugin options, bare-RN native keys. -- [Registering tools](https://github.com/callstackincubator/appduct/blob/main/docs/TOOLS.md) — schema forms, what re-registers, input schemas that accept an object, `timeoutMs`. +- [Registering tools](https://github.com/callstackincubator/appduct/blob/main/docs/TOOLS.md) — schema forms, what re-registers, tool groups, input schemas that accept an object, `timeoutMs`. - [Gating a tool by build variant](https://github.com/callstackincubator/appduct/blob/main/docs/SECURITY.md#gating-a-tool-by-build-variant) — `enabled`, and why `__DEV__` is wrong here. - [Build variants](https://github.com/callstackincubator/appduct/blob/main/docs/BUILD-VARIANTS.md) — `APPDUCT_ENABLED`, autolinking exclusion, compiling Appduct out of production builds. - [What a build without the native module does](https://github.com/callstackincubator/appduct/blob/main/docs/SECURITY.md#what-a-build-without-the-native-module-does). diff --git a/packages/react-native/src/Appduct.types.ts b/packages/react-native/src/Appduct.types.ts index a18886f8..e0531ec9 100644 --- a/packages/react-native/src/Appduct.types.ts +++ b/packages/react-native/src/Appduct.types.ts @@ -312,6 +312,15 @@ export type AppductToolDefinition< * client-wide `defaultToolTimeoutMs` is deliberately never sent. */ timeoutMs?: number; + /** + * The group this tool belongs to: a top-level group (`"checkout"`) or a subgroup + * (`"checkout/payment"`) — one or two `/`-separated segments, each `[a-zA-Z0-9_-]{1,64}`. + * Agents list a large app's tools one group at a time (`appduct tools --groups`, then + * `--group checkout`, which includes `checkout/*`). Optional; an ungrouped tool is listed under + * `(ungrouped)`. A malformed group makes registration throw, like a malformed `name`. + * `createToolGroup("checkout")` binds it for a whole feature module. + */ + group?: string; }; export type AppductToolRegistration< diff --git a/packages/react-native/src/__tests__/client.test.ts b/packages/react-native/src/__tests__/client.test.ts index 72dea635..9e7e0f8d 100644 --- a/packages/react-native/src/__tests__/client.test.ts +++ b/packages/react-native/src/__tests__/client.test.ts @@ -128,6 +128,55 @@ describe("createAppductClient (bridge contract)", () => { expect(fake.unregisterToolCalls).toEqual(["seed_cart"]); }); + test("registerTool puts group on the wire descriptor only when one is declared", () => { + const fake = createFakeNativeModule(); + const client = createAppductClient(fake.module); + + client.registerTool({ + name: "pay", + description: "Pay.", + group: "checkout/payment", + handler: () => undefined, + }); + client.registerTool({ + name: "ping", + description: "Ping.", + handler: () => undefined, + }); + + expect(JSON.parse(fake.registerToolCalls[0]!)).toMatchObject({ + name: "pay", + group: "checkout/payment", + }); + expect(JSON.parse(fake.registerToolCalls[1]!)).not.toHaveProperty("group"); + }); + + test("createToolGroup binds the group onto every registration it makes", async () => { + const fake = createFakeNativeModule(); + const client = createAppductClient(fake.module); + const { createToolGroupFactory } = await import("../tool-group"); + + const registerCartTool = createToolGroupFactory((registration) => + client.registerTool(registration), + )("cart"); + const registration = registerCartTool({ + name: "add_item", + description: "Add.", + handler: () => undefined, + }); + registerCartTool({ + name: "clear", + description: "Clear.", + handler: () => undefined, + }); + + const groups = fake.registerToolCalls.map((json) => JSON.parse(json).group); + expect(groups).toEqual(["cart", "cart"]); + + registration.remove(); + expect(fake.unregisterToolCalls).toEqual(["add_item"]); + }); + test("onToolCall dispatches to the registered handler and answers via respondToToolCall", async () => { const fake = createFakeNativeModule(); fake.setSessionId("session-1"); diff --git a/packages/react-native/src/__tests__/noop-parity.test.ts b/packages/react-native/src/__tests__/noop-parity.test.ts index 82d772cc..a7599ce8 100644 --- a/packages/react-native/src/__tests__/noop-parity.test.ts +++ b/packages/react-native/src/__tests__/noop-parity.test.ts @@ -39,6 +39,7 @@ describe("noop parity: type-level (see also public-api.ts's doc comment)", () => const names: (keyof CordierePublicApi)[] = [ "registerTool", + "createToolGroup", "useAppductTool", "jsonSchema", "postEvent", @@ -108,6 +109,34 @@ describe("noop parity: type-level (see also public-api.ts's doc comment)", () => }).remove(); } + // A group-bound registrar infers handler args exactly like `registerTool`, and refuses a + // registration that tries to set its own `group`. + const groupFactories: CordierePublicApi["createToolGroup"][] = [ + realModule.createToolGroup, + noopModule.createToolGroup, + ]; + + for (const createToolGroup of groupFactories) { + const registerCartTool = createToolGroup("cart"); + + registerCartTool({ + name: "grouped-paired", + description: "d", + inputSchema: pairedSchema, + handler: (args) => { + expectType<{ a: number }>(args); + }, + }).remove(); + + registerCartTool({ + name: "grouped-override", + description: "d", + // @ts-expect-error -- the bound group cannot be overridden per registration. + group: "other", + handler: () => undefined, + }).remove(); + } + // `jsonSchema` is the same identity helper on both entries. expect(realModule.jsonSchema(rawSchema)).toBe(rawSchema); expect(noopModule.jsonSchema(rawSchema)).toBe(rawSchema); diff --git a/packages/react-native/src/__tests__/use-appduct-tool.test.ts b/packages/react-native/src/__tests__/use-appduct-tool.test.ts index 812d816f..f94aef07 100644 --- a/packages/react-native/src/__tests__/use-appduct-tool.test.ts +++ b/packages/react-native/src/__tests__/use-appduct-tool.test.ts @@ -86,6 +86,7 @@ type Registration = { handler: AppductToolHandler; /** What tool-invocation's "must not return a result when outputSchema is omitted" rule keys on. */ hasOutputSchema: boolean; + group?: string; }; const makeRegisterTool = () => { @@ -105,6 +106,7 @@ const makeRegisterTool = () => { description: registration.description, handler: registration.handler as AppductToolHandler, hasOutputSchema: registration.outputSchema !== undefined, + group: registration.group, }; registrations.push(entry); return { @@ -316,6 +318,43 @@ describe("createUseAppductTool", () => { expect(registrations[2]?.removed).toBe(false); }); + test("changing group re-registers, an unchanged group does not", async () => { + const { registerTool, registrations } = makeRegisterTool(); + const { createUseAppductTool } = await import("../useAppductTool"); + const useAppductTool = createUseAppductTool( + registerTool, + realEntryOptions, + ); + + const render = (group?: string) => + host.render(() => useAppductTool({ ...toolDefinition(), group })); + + render("checkout"); + render("checkout"); + expect(registrations).toHaveLength(1); + + // Parent -> subgroup. + render("checkout/payment"); + expect(registrations).toHaveLength(2); + render("checkout/payment"); + expect(registrations).toHaveLength(2); + + // Grouped -> ungrouped. + host.render(() => useAppductTool(toolDefinition())); + expect(registrations).toHaveLength(3); + + expect(registrations.map((entry) => entry.removed)).toEqual([ + true, + true, + false, + ]); + expect(registrations.map((entry) => entry.group)).toEqual([ + "checkout", + "checkout/payment", + undefined, + ]); + }); + test("a handler closing over changed state does not re-register, and the next call sees the new value", async () => { const { registerTool, registrations } = makeRegisterTool(); const { createUseAppductTool } = await import("../useAppductTool"); diff --git a/packages/react-native/src/client/index.ts b/packages/react-native/src/client/index.ts index a831c4e5..e4c84bfa 100644 --- a/packages/react-native/src/client/index.ts +++ b/packages/react-native/src/client/index.ts @@ -138,6 +138,7 @@ export const createAppductClient = ( outputSchema, annotations: registration.annotations, timeoutMs: registration.timeoutMs, + group: registration.group, }); if (tools.has(registration.name)) { diff --git a/packages/react-native/src/index.ts b/packages/react-native/src/index.ts index 4ab67e84..d63de328 100644 --- a/packages/react-native/src/index.ts +++ b/packages/react-native/src/index.ts @@ -17,6 +17,7 @@ import { parseBootstrapPayload, parseBootstrapUrl } from "./bootstrap"; import { appductClient, noopIfNativeUnavailable } from "./default-client"; import * as noop from "./noop"; import { exportToolSchemaForKey } from "./schema"; +import { createToolGroupFactory } from "./tool-group"; import { createUseAppductTool } from "./useAppductTool"; export * from "./Appduct.types"; @@ -29,7 +30,11 @@ export { } from "./client"; export { appductNativeModule }; export { appductClient }; -export type { CordierePublicApi, AppductSubscription } from "./public-api"; +export type { + CordierePublicApi, + AppductSubscription, + AppductToolGroupRegistrar, +} from "./public-api"; export type { UseAppductToolOptions } from "./useAppductTool"; /** @@ -48,6 +53,19 @@ export function registerTool< ); } +/** + * `registerTool` bound to one group, for a feature module that registers several tools: + * + * ```ts + * const registerCartTool = createToolGroup("cart"); + * registerCartTool({ name: "add_item", description: "...", handler }); + * ``` + * + * `group` is a top-level group (`"cart"`) or a subgroup (`"checkout/payment"`). A malformed one + * makes each registration throw, exactly like passing it as `registerTool`'s own `group`. + */ +export const createToolGroup = createToolGroupFactory(registerTool); + /** Emits an `event` frame on the default client while active; drops (dev warning) otherwise. */ export function postEvent(name: string, payload?: unknown): Promise { return noopIfNativeUnavailable( @@ -143,7 +161,7 @@ export function getAppductBuildConfig(): AppductBuildConfig { /** * `useEffect` wrapper around `registerTool`: registers once per mount and re-registers only when * the registration itself changed (name, description, exported schemas, annotations, `timeoutMs`, - * `enabled`), disposing the previous registration first (identity-safe — see `registerTool`'s doc + * `group`, `enabled`), disposing the previous registration first (identity-safe — see `registerTool`'s doc * comment). Calls are routed through the latest render's handler, so `deps` is an optional * override rather than something every call site has to remember. `options.enabled` (default * `true`) gates registration without breaking the rules of hooks — see `docs/SECURITY.md`'s diff --git a/packages/react-native/src/noop.ts b/packages/react-native/src/noop.ts index 05de9da4..25be2f38 100644 --- a/packages/react-native/src/noop.ts +++ b/packages/react-native/src/noop.ts @@ -21,10 +21,15 @@ import type { } from "./Appduct.types"; import { AppductDisabledError } from "./Appduct.types"; import type { AppductSubscription } from "./public-api"; +import { createToolGroupFactory } from "./tool-group"; import { createUseAppductTool } from "./useAppductTool"; export * from "./Appduct.types"; -export type { CordierePublicApi, AppductSubscription } from "./public-api"; +export type { + CordierePublicApi, + AppductSubscription, + AppductToolGroupRegistrar, +} from "./public-api"; export type { UseAppductToolOptions } from "./useAppductTool"; const noopSubscription: AppductSubscription = { remove() {} }; @@ -46,6 +51,10 @@ export function registerTool< * whose whole purpose is to carry no Appduct work. */ export const useAppductTool = createUseAppductTool(registerTool); +/** Same signature as the real entry's; the bound registrar is the inert `registerTool` above, and + * no group is validated since nothing is ever registered. */ +export const createToolGroup = createToolGroupFactory(registerTool); + /** No-op: never sends anything. */ export async function postEvent( _name: string, diff --git a/packages/react-native/src/public-api.ts b/packages/react-native/src/public-api.ts index 16f7735d..04889b4a 100644 --- a/packages/react-native/src/public-api.ts +++ b/packages/react-native/src/public-api.ts @@ -15,6 +15,17 @@ import type { UseAppductToolOptions } from "./useAppductTool"; export type AppductSubscription = { remove(): void }; +/** `createToolGroup`'s result: `registerTool` with `group` bound (a registration passed to it + * cannot set its own `group`). */ +export type AppductToolGroupRegistrar = < + TInputSchema extends AppductRuntimeSchema | undefined, + TOutputSchema extends AppductRuntimeSchema | undefined, +>( + registration: AppductToolRegistration & { + group?: undefined; + }, +) => AppductSubscription; + /** * Public API surface shared by the `.` (real) and `./noop` (inert) entries (ARCHITECTURE.md §11). * Both entries are typed against this single interface so they cannot drift — see @@ -28,6 +39,12 @@ export type CordierePublicApi = { registration: AppductToolRegistration, ): AppductSubscription; + /** + * `registerTool` bound to one group (`"cart"`, or a subgroup like `"checkout/payment"`), so a + * feature module registers its tools without repeating the group name on each one. + */ + createToolGroup(group: string): AppductToolGroupRegistrar; + useAppductTool< TInputSchema extends AppductRuntimeSchema | undefined, TOutputSchema extends AppductRuntimeSchema | undefined, diff --git a/packages/react-native/src/schema.ts b/packages/react-native/src/schema.ts index 50d1dfc3..f4969c65 100644 --- a/packages/react-native/src/schema.ts +++ b/packages/react-native/src/schema.ts @@ -474,7 +474,7 @@ export const validateToolSchema = async ( /** A tool definition whose schemas have already been through `normalizeToolSchema`. */ export type AppductNormalizedToolDefinition = Pick< AppductToolDefinition, - "name" | "description" | "annotations" | "timeoutMs" + "name" | "description" | "annotations" | "timeoutMs" | "group" > & { inputSchema?: AppductNormalizedToolSchema; outputSchema?: AppductNormalizedToolSchema; @@ -573,5 +573,8 @@ export const toToolDescriptor = ( // `defaultToolTimeoutMs`, which stays a purely app-side fallback. Omitted entirely (no // `timeout_ms: undefined` key) when the tool declares none, so the daemon keeps its default. ...(wireTimeoutMs !== undefined ? { timeout_ms: wireTimeoutMs } : {}), + // Passed through unvalidated: native validates the descriptor (PROTOCOL.md §5) and throws + // synchronously on a malformed group, exactly as it does for a malformed name. + ...(definition.group !== undefined ? { group: definition.group } : {}), }; }; diff --git a/packages/react-native/src/tool-group.ts b/packages/react-native/src/tool-group.ts new file mode 100644 index 00000000..805fa67b --- /dev/null +++ b/packages/react-native/src/tool-group.ts @@ -0,0 +1,31 @@ +import type { + AppductRuntimeSchema, + AppductToolRegistration, +} from "./Appduct.types"; +import type { + AppductSubscription, + AppductToolGroupRegistrar, +} from "./public-api"; + +type ToolRegistrar = < + TInputSchema extends AppductRuntimeSchema | undefined, + TOutputSchema extends AppductRuntimeSchema | undefined, +>( + registration: AppductToolRegistration, +) => AppductSubscription; + +/** + * Builds `createToolGroup` on top of a `registerTool` implementation, the same way + * `createUseAppductTool` builds the hook: the real (`.`) and inert (`./noop`) entries each pass + * their own registrar, so the two cannot drift. + * + * The group is not validated here: the real registrar hands the descriptor to native, which + * validates it (PROTOCOL.md §5) and throws synchronously on a malformed group at registration — + * the same place a malformed `name` surfaces — while the inert registrar accepts anything. An + * eager check here would make the root entry throw where `./noop` does not. + */ +export const createToolGroupFactory = (registerTool: ToolRegistrar) => { + return (group: string): AppductToolGroupRegistrar => { + return (registration) => registerTool({ ...registration, group }); + }; +}; diff --git a/packages/react-native/src/useAppductTool.ts b/packages/react-native/src/useAppductTool.ts index 44f97df3..65495233 100644 --- a/packages/react-native/src/useAppductTool.ts +++ b/packages/react-native/src/useAppductTool.ts @@ -124,7 +124,7 @@ export function createUseAppductTool( /** * `useEffect` wrapper around `registerTool` that registers **once per mount** and re-registers * only when something that changes the registry entry changed — `name`, `description`, the - * exported input/output JSON Schemas, `annotations`, `timeoutMs`, or `options.enabled`. Omitting + * exported input/output JSON Schemas, `annotations`, `timeoutMs`, `group`, or `options.enabled`. Omitting * `deps` is therefore the correct, cheap default: re-rendering the hosting component does not * produce `tool_registry_delta` traffic. * (`timeoutMs` is app-side only — the daemon never sees it — but it is part of the entry, so a @@ -249,6 +249,7 @@ export function createUseAppductTool( definition.name, definition.description, definition.timeoutMs, + definition.group, annotationsKey, inputSchemaKey, outputSchemaKey, diff --git a/packages/shared/src/__tests__/tool-descriptor.test.ts b/packages/shared/src/__tests__/tool-descriptor.test.ts index b343f6f7..55977117 100644 --- a/packages/shared/src/__tests__/tool-descriptor.test.ts +++ b/packages/shared/src/__tests__/tool-descriptor.test.ts @@ -3,6 +3,9 @@ import { describe, expect, test } from "vitest"; import { clampToolTimeoutMs, isToolDescriptor, + isValidToolGroup, + summarizeToolGroups, + toolGroupMatches, MAX_TOOL_DESCRIPTION_LENGTH, MAX_TOOL_TIMEOUT_MS, MIN_TOOL_TIMEOUT_MS, @@ -157,3 +160,86 @@ describe("clampToolTimeoutMs", () => { } }); }); + +describe("tool groups", () => { + test("isValidToolGroup accepts one or two name-pattern segments and nothing else", () => { + expect(isValidToolGroup("checkout")).toBe(true); + expect(isValidToolGroup("checkout/payment")).toBe(true); + expect(isValidToolGroup(`${"a".repeat(64)}/${"b".repeat(64)}`)).toBe(true); + + for (const bad of [ + "", + "/", + "checkout/", + "/payment", + "a//b", + "a/b/c", + "a".repeat(65), + `a/${"b".repeat(65)}`, + "a b", + "a.b", + "a\n", + "café", + ]) { + expect(isValidToolGroup(bad), JSON.stringify(bad)).toBe(false); + } + + for (const nonString of [undefined, null, 42, ["a"], { group: "a" }]) { + expect(isValidToolGroup(nonString)).toBe(false); + } + }); + + test("isToolDescriptor rejects an invalid group and accepts a valid or omitted one", () => { + expect(isToolDescriptor({ ...valid(), group: "checkout/payment" })).toBe(true); + expect(isToolDescriptor(valid())).toBe(true); + expect(isToolDescriptor({ ...valid(), group: "a/b/c" })).toBe(false); + expect(isToolDescriptor({ ...valid(), group: null })).toBe(false); + }); + + test("toolGroupMatches matches by segment: a parent includes its subgroups, never a longer name", () => { + expect(toolGroupMatches("checkout", "checkout")).toBe(true); + expect(toolGroupMatches("checkout/payment", "checkout")).toBe(true); + expect(toolGroupMatches("checkout/payment", "checkout/payment")).toBe(true); + expect(toolGroupMatches("checkoutx", "checkout")).toBe(false); + expect(toolGroupMatches("checkoutx/payment", "checkout")).toBe(false); + expect(toolGroupMatches("checkout", "checkout/payment")).toBe(false); + expect(toolGroupMatches("checkout/paymentx", "checkout/payment")).toBe(false); + expect(toolGroupMatches("Checkout", "checkout")).toBe(false); + expect(toolGroupMatches(undefined, "checkout")).toBe(false); + }); + + test("summarizeToolGroups counts parents including subgroups, keeps a parent before its subgroups, null last", () => { + const summary = summarizeToolGroups([ + { group: "checkout/payment" }, + {}, + { group: "checkout-x" }, + { group: "cart" }, + { group: "checkout" }, + { group: "checkout/payment" }, + { group: "checkout/address" }, + { group: "Zeta" }, + {}, + ]); + + expect(summary).toEqual([ + { group: "Zeta", total: 1 }, + { group: "cart", total: 1 }, + // `checkout/*` sorts right after `checkout`, even though "-" < "/" would put "checkout-x" + // between them under a whole-path code-point comparison. + { group: "checkout", total: 4 }, + { group: "checkout/address", total: 1 }, + { group: "checkout/payment", total: 2 }, + { group: "checkout-x", total: 1 }, + { group: null, total: 2 }, + ]); + }); + + test("summarizeToolGroups lists a parent that only has subgroup tools, and omits null with no ungrouped tools", () => { + expect(summarizeToolGroups([{ group: "checkout/payment" }])).toEqual([ + { group: "checkout", total: 1 }, + { group: "checkout/payment", total: 1 }, + ]); + expect(summarizeToolGroups([])).toEqual([]); + expect(summarizeToolGroups([{}])).toEqual([{ group: null, total: 1 }]); + }); +}); diff --git a/packages/shared/src/domains/rpc.ts b/packages/shared/src/domains/rpc.ts index 44736765..314822b9 100644 --- a/packages/shared/src/domains/rpc.ts +++ b/packages/shared/src/domains/rpc.ts @@ -1,6 +1,6 @@ import type { ErrorType } from "./errors.js"; import type { AgentEndpoint } from "./transport.js"; -import type { ToolDescriptor } from "./tool-descriptor.js"; +import type { ToolDescriptor, ToolGroupSummary } from "./tool-descriptor.js"; /** Control-plane RPC method name constants (ARCHITECTURE.md §5). Types only — no transport here. */ export const RPC_METHODS = { @@ -144,6 +144,10 @@ export type SessionsRevokeResult = { ok: true }; export const MAX_TOOLS_FILTER_LENGTH = 256; export type ToolsListParams = SessionSelectorParams & { + /** Only tools in this group (PROTOCOL.md §5 group syntax), matched by segment: `checkout` + * includes every `checkout/*` subgroup, `checkout/payment` is exactly that subgroup, and + * `checkout` never matches `checkoutx`. Case-sensitive. */ + group?: string; /** Case-insensitive substring match against name and description. */ filter?: string; /** Page size; omitted means everything from `offset` on. */ @@ -162,14 +166,22 @@ export type ToolsListEntry = ToolDescriptor & { /** * `tools.list`'s result: the registry sorted by `name` (plain code-point order, so it is - * deterministic across locales), `filter`ed, then paged with `limit`/`offset` — `total` is the - * count *after* filtering but *before* paging, so a caller (the CLI) can say how many tools were - * left out of the page it got back. + * deterministic across locales), narrowed to `group`, `filter`ed, then paged with `limit`/`offset` + * — `total` is the count *after* the group and filter but *before* paging, so a caller (the CLI) + * can say how many tools were left out of the page it got back. */ export type ToolsListResult = { tools: ToolsListEntry[]; - /** Matching tools before `limit`/`offset` were applied. */ + /** Tools matching `group` and `filter`, before `limit`/`offset` were applied. */ total: number; + /** + * The session's groups with tool counts, over the whole registry — never narrowed by `group`, + * `filter`, `limit` or `offset`, so a caller can always see what there is to narrow to. One + * entry per top-level group (its `total` includes its subgroups), one per subgroup, and a + * `group: null` entry for ungrouped tools when there are any. Sorted by group path with a parent + * right before its subgroups, `null` last. Empty for an empty registry. + */ + groups: ToolGroupSummary[]; }; export type ToolsCallParams = SessionSelectorParams & { diff --git a/packages/shared/src/domains/tool-descriptor.ts b/packages/shared/src/domains/tool-descriptor.ts index 3b297f19..a2011969 100644 --- a/packages/shared/src/domains/tool-descriptor.ts +++ b/packages/shared/src/domains/tool-descriptor.ts @@ -6,6 +6,100 @@ export const TOOL_NAME_PATTERN = /^[a-zA-Z0-9_-]{1,64}$/; export const MAX_TOOL_DESCRIPTION_LENGTH = 4096; +/** + * A tool group (PROTOCOL.md §5): one or two `/`-separated segments, each matching + * {@link TOOL_NAME_PATTERN} — a top-level group (`checkout`) or a subgroup (`checkout/payment`), + * nothing deeper. Written as one anchored pattern so the Swift and Kotlin ports can use the exact + * same expression (and the conformance fixtures pin all three to it). + */ +export const TOOL_GROUP_PATTERN = /^[a-zA-Z0-9_-]{1,64}(?:\/[a-zA-Z0-9_-]{1,64})?$/; + +/** Whether `value` is a valid tool group string (see {@link TOOL_GROUP_PATTERN}). */ +export const isValidToolGroup = (value: unknown): value is string => { + return typeof value === "string" && TOOL_GROUP_PATTERN.test(value); +}; + +/** + * Segment match of a tool's `group` against a selected group (`tools.list`'s `group` param): + * `checkout` selects `checkout` itself and every `checkout/*` subgroup, `checkout/payment` selects + * exactly that subgroup, and `checkout` never selects `checkoutx`. Case-sensitive. An ungrouped + * tool (`toolGroup` undefined) matches nothing. + */ +export const toolGroupMatches = (toolGroup: string | undefined, selected: string): boolean => { + if (toolGroup === undefined) { + return false; + } + + return toolGroup === selected || toolGroup.startsWith(`${selected}/`); +}; + +/** One `tools.list` `groups` entry: a top-level group (its `total` includes its subgroups), a + * subgroup (`parent/child`), or `null` for the ungrouped bucket. */ +export type ToolGroupSummary = { group: string | null; total: number }; + +/** Orders group paths by segment — a parent sorts before its own subgroups, and a plain code-point + * comparison of the whole path (which would put `checkout-x` between `checkout` and + * `checkout/payment`, since `-` < `/`) never splits a parent from its subgroups. */ +const compareGroupPaths = (a: string, b: string): number => { + const [aTop = "", aSub] = a.split("/"); + const [bTop = "", bSub] = b.split("/"); + + if (aTop !== bTop) { + return aTop < bTop ? -1 : 1; + } + + if (aSub === bSub) { + return 0; + } + + if (aSub === undefined) { + return -1; + } + + if (bSub === undefined) { + return 1; + } + + return aSub < bSub ? -1 : 1; +}; + +/** + * `tools.list`'s `groups` summary over a registry: one entry per top-level group (counting its + * subgroups' tools too), one per subgroup, and one `null` entry for ungrouped tools (only when + * there are any). Sorted by group path (a parent right before its subgroups), `null` last. Group + * names compare by code point, never `localeCompare`, so the order does not depend on the + * daemon's locale. + */ +export const summarizeToolGroups = (tools: ReadonlyArray<{ group?: string }>): ToolGroupSummary[] => { + const totals = new Map(); + let ungrouped = 0; + + for (const tool of tools) { + if (tool.group === undefined) { + ungrouped += 1; + continue; + } + + const slash = tool.group.indexOf("/"); + const top = slash === -1 ? tool.group : tool.group.slice(0, slash); + totals.set(top, (totals.get(top) ?? 0) + 1); + + if (slash !== -1) { + totals.set(tool.group, (totals.get(tool.group) ?? 0) + 1); + } + } + + const summaries: ToolGroupSummary[] = [...totals.keys()] + .sort(compareGroupPaths) + .map((group) => ({ group, total: totals.get(group)! })); + + if (ungrouped > 0) { + summaries.push({ group: null, total: ungrouped }); + } + + return summaries; +}; + export type ToolAnnotations = { readOnlyHint?: boolean; destructiveHint?: boolean; @@ -53,6 +147,12 @@ export type ToolDescriptor = { * which are camelCase layers. A camelCase key arriving here is not a timeout and is ignored. */ timeout_ms?: number; + /** + * The group this tool belongs to (PROTOCOL.md §5): a top-level group (`checkout`) or a subgroup + * (`checkout/payment`), see {@link TOOL_GROUP_PATTERN}. Optional — an ungrouped tool omits it. + * Used by `tools.list`'s `group` filter and `groups` summary; never part of the MCP `Tool`. + */ + group?: string; }; const isJsonObject = (value: unknown): value is Record => { @@ -122,5 +222,9 @@ export const isToolDescriptor = (value: unknown): value is ToolDescriptor => { return false; } + if (value.group !== undefined && !isValidToolGroup(value.group)) { + return false; + } + return true; }; diff --git a/skills/appduct/SKILL.md b/skills/appduct/SKILL.md index 361b0e7e..08ddd14e 100644 --- a/skills/appduct/SKILL.md +++ b/skills/appduct/SKILL.md @@ -26,9 +26,14 @@ auto-spawn it on first use — there is no separate "start the host" step to man call signature (`name(params) -> result`) plus a one-line description, not a full schema — cheap to read even for an app with hundreds of tools. `...` anywhere in a signature means the CLI could not summarize that part of the schema; fetch the full - tool (step 4) to see it. On a large app, narrow first with `--filter ` (matches - name or description) and page with `--limit `/`--offset ` if the listing says - tools were left out. + tool (step 4) to see it. On a large app, run **`appduct tools --groups`** first: it + lists the app's tool groups with counts (subgroups like `checkout/payment` indented + under their parent). Then list one with **`--group `** — `--group checkout` + includes every `checkout/...` subgroup, `--group checkout/payment` only that one. When + the app declares no groups, or you know a word to look for, narrow with + `--filter ` (matches name or description) instead. Page with + `--limit `/`--offset ` if the listing says tools were left out; its footer names + the groups to narrow to. 4. **`appduct tools [selector] `** — the tool's full input/output schema (`--full` is implied for a single tool, no need to pass it). 5. **`appduct invoke [selector] --input '{"key":"value"}'`** — invoke the @@ -210,6 +215,12 @@ An input schema must **accept a JSON object**, since a call's args always are on of objects works, but its signature shows as `(...)`, so read the full schema (`appduct tools `) before calling it. +Put every tool in a `group` once an app has more than a screenful of them +(`group: "cart"`, or `createToolGroup("cart")` to bind it for a whole feature module); +use a subgroup (`group: "checkout/payment"`, at most one level below the group) only +when a group itself outgrows a screen. Each part of a group uses tool-name characters +(`[a-zA-Z0-9_-]`, at most 64); anything else makes registration throw. + ## Notes - Plain text is the CLI's default output and is meant to be read, not parsed — its exact From 6f0226bf7f732c92cf50f77fd11ec4c4ecd43f6a Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Mon, 21 Sep 2026 12:01:54 +0200 Subject: [PATCH 2/4] fix(cli): empty-group message, old-daemon guard and --groups hint for tool groups --- .../src/__tests__/cli-v2.integration.test.ts | 18 +++++++ packages/appduct/src/__tests__/output.test.ts | 25 ++++++++++ .../src/__tests__/tools-command.test.ts | 42 +++++++++++++++++ packages/appduct/src/commands/tools.ts | 47 +++++++++++++++++-- packages/appduct/src/output.ts | 18 +++++-- playground/app/(tabs)/index.tsx | 10 +++- 6 files changed, 152 insertions(+), 8 deletions(-) create mode 100644 packages/appduct/src/__tests__/tools-command.test.ts diff --git a/packages/appduct/src/__tests__/cli-v2.integration.test.ts b/packages/appduct/src/__tests__/cli-v2.integration.test.ts index c2550dae..1396e2ca 100644 --- a/packages/appduct/src/__tests__/cli-v2.integration.test.ts +++ b/packages/appduct/src/__tests__/cli-v2.integration.test.ts @@ -545,6 +545,12 @@ describe("appduct CLI v2: end-to-end command table", () => { expect(sub.stdout).not.toContain("checkout_0"); expect(sub.stdout).not.toContain("(ungrouped)"); + // An empty group (matching is case-sensitive) is not an empty registry. + const unknownGroup = await runCliHuman(["tools", alias, "--group", "Cart"], stateDir); + expect(stripAnsi(unknownGroup.stdout)).toContain( + 'Tools in group Cart\n No tools in group "Cart". Run `appduct tools --groups` to see the session\'s groups.', + ); + // --group combines with --filter and paging; the footer drops the group hint once narrowed. const combined = await runCliHuman(["tools", alias, "--group", "cart", "--filter", "cart_1", "--limit", "1"], stateDir); expect(combined.stdout).toContain("Showing 1 of 2 tools (offset 0). Narrow with --filter or page with --offset ."); @@ -564,6 +570,11 @@ describe("appduct CLI v2: end-to-end command table", () => { ["tools", alias, "cart_00", "--groups"], ["tools", alias, "--groups", "--group", "cart"], ["tools", alias, "--groups", "--filter", "x"], + ["tools", alias, "--groups", "--full"], + ["tools", alias, "--groups", "--limit", "2"], + ["tools", alias, "--groups", "--offset", "1"], + // The single-arg probe: `cart_00` resolves to a tool, so `--groups` is a listing flag on a lookup. + ["tools", "cart_00", "--groups"], ["tools", alias, "--group", "a/b/c"], ["tools", alias, "--group", "checkout/"], ]) { @@ -572,6 +583,13 @@ describe("appduct CLI v2: end-to-end command table", () => { expect(result.error?.type, args.join(" ")).toBe("usage_error"); } + // `--groups checkout` (a value on a boolean flag) points at `--group` instead of failing as + // an unknown session. + const groupsWithValue = await runCliJson(["tools", "--groups", "checkout"], stateDir); + expect(groupsWithValue.ok).toBe(false); + expect(groupsWithValue.error?.type).toBe("usage_error"); + expect(groupsWithValue.error?.message).toContain('use "--group checkout"'); + socket.close(); const stopResult = await runCliJson(["daemon", "stop"], stateDir); diff --git a/packages/appduct/src/__tests__/output.test.ts b/packages/appduct/src/__tests__/output.test.ts index 081ebde2..5c5733fc 100644 --- a/packages/appduct/src/__tests__/output.test.ts +++ b/packages/appduct/src/__tests__/output.test.ts @@ -120,6 +120,31 @@ describe("output rendering", () => { ); }); + test("the truncation footer names at most 10 top-level groups (no subgroups), then points at --groups", () => { + const topLevel = Array.from({ length: 12 }, (_, index) => ({ + group: `g${String(index).padStart(2, "0")}`, + total: 2, + })); + const rendered = renderResult( + { + ok: true, + data: { + tools: [{ name: "echo", description: "Echoes input.", policy: "allow", group: "g00" }], + total: 24, + limit: 1, + groups: [topLevel[0], { group: "g00/sub", total: 1 }, ...topLevel.slice(1)], + }, + }, + { command: "tools", flags: flags() }, + ).stdout ?? ""; + + expect(rendered).toContain( + "Showing 1 of 24 tools (offset 0). Narrow with --group (groups: g00 2, g01 2, g02 2, g03 2, " + + "g04 2, g05 2, g06 2, g07 2, g08 2, g09 2, ... 2 more; see --groups) or --filter , or page with --offset .", + ); + expect(rendered).not.toContain("g00/sub 1"); + }); + test("tools list output with a filter and no matches says so", () => { const rendered = renderResult( { ok: true, data: { tools: [], total: 0, filter: "nope" } }, diff --git a/packages/appduct/src/__tests__/tools-command.test.ts b/packages/appduct/src/__tests__/tools-command.test.ts new file mode 100644 index 00000000..14f64bf1 --- /dev/null +++ b/packages/appduct/src/__tests__/tools-command.test.ts @@ -0,0 +1,42 @@ +/** + * `commands/tools.ts` against a daemon that predates tool groups: its `tools.list` result has no + * `groups` and it ignores the `group` param. The version guard normally restarts such a daemon; + * when it could not, `--group`/`--groups` must fail loudly rather than render the whole registry + * as one group, or "No tools registered." for a registry that has tools. + */ + +import { describe, expect, test, vi } from "vitest"; + +const callDaemon = vi.fn(); + +vi.mock("../rpc/client.js", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, callDaemon: (...args: unknown[]) => callDaemon(...args) }; +}); + +const { handleToolsCommand } = await import("../commands/tools.js"); + +const oldDaemonResult = { + tools: [{ name: "add_item", description: "Adds.", group: "cart", policy: "allow" }], + total: 1, +}; + +describe("tools command against a daemon without group support", () => { + test("--groups and --group report the unsupported daemon instead of a wrong listing", async () => { + callDaemon.mockResolvedValue(oldDaemonResult); + + await expect(handleToolsCommand({ groups: true }, { stateDir: "/unused" })).rejects.toMatchObject({ + type: "connection_error", + }); + await expect(handleToolsCommand({ group: "cart" }, { stateDir: "/unused" })).rejects.toMatchObject({ + type: "connection_error", + }); + }); + + test("a plain listing still works", async () => { + callDaemon.mockResolvedValue(oldDaemonResult); + + const result = await handleToolsCommand({ filter: "add" }, { stateDir: "/unused" }); + expect(result.ok).toBe(true); + }); +}); diff --git a/packages/appduct/src/commands/tools.ts b/packages/appduct/src/commands/tools.ts index 3359e22d..71545fca 100644 --- a/packages/appduct/src/commands/tools.ts +++ b/packages/appduct/src/commands/tools.ts @@ -19,7 +19,7 @@ import { RPC_METHODS, type ToolDescriptor, type ToolsListResult } from "@appduct/shared"; import type { CliResult, ToolGroupsListing, ToolsCommandData, ToolsListing } from "../cli/result-types.js"; -import { usageError } from "../errors.js"; +import { connectionError, usageError } from "../errors.js"; import { callDaemon, DaemonRpcError, type SpawnFn } from "../rpc/client.js"; export type ToolsCommandOptions = { @@ -106,7 +106,24 @@ const listGroups = async ( context: ToolsCommandContext, ): Promise => { const result = await listTools(selector, context, { limit: 1 }); - return { groups: result.groups ?? [], total: result.total }; + return { groups: requireGroupSupport(result), total: result.total }; +}; + +/** + * A daemon that predates tool groups answers `tools.list` with no `groups` and ignores the + * `group` param outright, so `--group cart` would print the whole registry as if it were the + * group, and `--groups` would print nothing. The version guard normally restarts such a daemon + * before it is asked; when it could not (a daemon with live sessions, run without + * `--daemon-restart`), say so rather than render a wrong answer. + */ +const requireGroupSupport = (result: ToolsListResult) => { + if (!Array.isArray(result.groups)) { + throw connectionError( + 'The running Appduct daemon does not support tool groups ("--group"/"--groups"). Restart it with a newer version: `appduct daemon stop`, or pass `--daemon-restart`.', + ); + } + + return result.groups; }; const listOrGroups = async ( @@ -115,11 +132,33 @@ const listOrGroups = async ( context: ToolsCommandContext, ): Promise => { if (options.groups === true) { - return listGroups(selector, context); + try { + return await listGroups(selector, context); + } catch (error) { + // `--groups` takes no value, so `tools --groups checkout` reads `checkout` as a session + // selector. When no such session exists, the likelier intent is `--group checkout`. + if ( + selector !== undefined && + error instanceof DaemonRpcError && + error.data?.type === "unknown_session" + ) { + throw usageError( + `No session matches "${selector}". "--groups" takes no value; to list one group's tools, use "--group ${selector}".`, + ); + } + + throw error; + } } const params = toListParams(options); - return toListing(await listTools(selector, context, params), params); + const result = await listTools(selector, context, params); + + if (params.group !== undefined) { + requireGroupSupport(result); + } + + return toListing(result, params); }; export const handleToolsCommand = async ( diff --git a/packages/appduct/src/output.ts b/packages/appduct/src/output.ts index f7d7b3af..062c41cd 100644 --- a/packages/appduct/src/output.ts +++ b/packages/appduct/src/output.ts @@ -193,6 +193,13 @@ const renderEmptyToolsLine = (data: ToolsListing): string => { return ` No tools at offset ${data.offset ?? 0}; ${data.total} matching tool${data.total === 1 ? "" : "s"} in total.`; } + if (data.group !== undefined) { + // Never "No tools registered" for an empty group: the registry may well have tools, just not + // in this group (a typo, or the wrong case — matching is case-sensitive). + const match = data.filter === undefined ? "" : ` match ${JSON.stringify(data.filter)}`; + return ` No tools in group ${JSON.stringify(data.group)}${match}. Run \`appduct tools --groups\` to see the session's groups.`; + } + return data.filter === undefined ? " No tools registered." : ` No tools match ${JSON.stringify(data.filter)}.`; }; @@ -302,9 +309,14 @@ const renderGroupedToolLines = (colors: ColorPalette, tools: readonly ToolsListE return lines; }; +/** The listing's title line: `Tools`, or `Tools in group ` under `--group`. */ +const renderToolsTitle = (colors: ColorPalette, data: ToolsListing): string => { + return colors.green(data.group === undefined ? "Tools" : `Tools in group ${data.group}`); +}; + const renderToolSummaryTable = (colors: ColorPalette, data: ToolsListing): string[] => { if (data.tools.length === 0) { - return [colors.green("Tools"), renderEmptyToolsLine(data)]; + return [renderToolsTitle(colors, data), renderEmptyToolsLine(data)]; } // Headings only when the registry has groups and the listing was not already narrowed to one — @@ -312,7 +324,7 @@ const renderToolSummaryTable = (colors: ColorPalette, data: ToolsListing): strin const grouped = data.group === undefined && hasAnyGroup(data.groups); return [ - colors.green(data.group === undefined ? "Tools" : `Tools in group ${data.group}`), + renderToolsTitle(colors, data), ...(grouped ? renderGroupedToolLines(colors, data.tools) : data.tools.flatMap((tool) => renderToolSummaryLines(tool, " "))), @@ -343,7 +355,7 @@ const renderToolDetail = (colors: ColorPalette, tool: ToolDescriptor, flags: Glo const renderToolsFullListing = (colors: ColorPalette, data: ToolsListing, flags: GlobalFlags): string[] => { if (data.tools.length === 0) { - return [colors.green("Tools"), renderEmptyToolsLine(data)]; + return [renderToolsTitle(colors, data), renderEmptyToolsLine(data)]; } return [ diff --git a/playground/app/(tabs)/index.tsx b/playground/app/(tabs)/index.tsx index 9fd3e4ff..baeb4566 100644 --- a/playground/app/(tabs)/index.tsx +++ b/playground/app/(tabs)/index.tsx @@ -33,7 +33,8 @@ function formatToolLine(tool: RegisteredTool): string { tool.annotations?.idempotentHint && "idempotent", ].filter(Boolean); const suffix = flags.length > 0 ? ` [${flags.join(", ")}]` : ""; - return `${tool.name}${suffix}\n ${tool.description}`; + const group = tool.group ? `${tool.group} / ` : ""; + return `${group}${tool.name}${suffix}\n ${tool.description}`; } export default function ToolsScreen() { @@ -52,6 +53,9 @@ export default function ToolsScreen() { setCallCount((count) => count + 1); }; + // Groups: `counter` and `diagnostics` (with a `diagnostics/progress` subgroup), plus `sum` + // left ungrouped -- so `appduct tools` shows headings, `--groups` has something to list, and + // `--group diagnostics` vs `--group diagnostics/progress` differ. useAppductTool({ name: "sum", description: "Adds two numbers.", @@ -71,6 +75,7 @@ export default function ToolsScreen() { useAppductTool({ name: "call_count", description: "Reports how many times the playground's counted tools have run.", + group: "counter", annotations: { readOnlyHint: true }, outputSchema: z.object({ count: z.number(), @@ -83,6 +88,7 @@ export default function ToolsScreen() { useAppductTool({ name: "reset_counter", description: "Resets the playground's call counter to zero.", + group: "counter", annotations: { destructiveHint: true }, outputSchema: z.object({ count: z.number(), @@ -96,6 +102,7 @@ export default function ToolsScreen() { useAppductTool({ name: "slow_task", description: "Takes ~1.5s and reports progress along the way.", + group: "diagnostics/progress", outputSchema: z.object({ done: z.boolean(), }), @@ -117,6 +124,7 @@ export default function ToolsScreen() { useAppductTool({ name: "throwing_tool", description: "Always throws, to exercise tool_execution_error.", + group: "diagnostics", handler: () => { throw new Error("throwing_tool always fails on purpose."); }, From 22ec02eba1d858e8d84aa5fcf4df0facb4f4543c Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Mon, 21 Sep 2026 14:14:40 +0200 Subject: [PATCH 3/4] feat(mcp): expose tool groups through appduct_list_tools appduct_list_tools takes a group (the daemon's segment matching, validated by the daemon), shows each tool's group, and returns the whole-registry groups summary on every result, so an agent can see an app's areas and list one. appduct_describe_tool includes the group. Asking a daemon that predates groups for one fails with connection_error instead of listing the whole registry, as in the CLI. --- CHANGELOG.md | 6 +- docs/ARCHITECTURE.md | 7 +- docs/TOOLS.md | 2 +- packages/appduct/README.md | 2 +- .../__snapshots__/mcp-server.test.ts.snap | 4 + .../appduct/src/__tests__/e2e/mcp.e2e.test.ts | 1 + .../appduct/src/__tests__/mcp-daemon-fake.ts | 8 +- .../__tests__/mcp-server.integration.test.ts | 31 +++++--- .../appduct/src/__tests__/mcp-server.test.ts | 75 ++++++++++++++++++- packages/appduct/src/mcp/app-tools.ts | 30 ++++++-- packages/appduct/src/mcp/connect-tool.ts | 8 +- skills/appduct/SKILL.md | 6 +- 12 files changed, 152 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 23feaaea..c5fb64a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,8 +24,10 @@ package versions for a release. 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`. The MCP built-ins (`appduct_list_tools` and friends) don't take a - `group` yet. + 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. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 1b2b32c1..843823cb 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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 `: 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 `: 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 diff --git a/docs/TOOLS.md b/docs/TOOLS.md index 137fc8d7..9cb69788 100644 --- a/docs/TOOLS.md +++ b/docs/TOOLS.md @@ -140,7 +140,7 @@ registerCartTool({ name: "add_item", description: "Add a product to the cart", h 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 `appduct tools` lists your tools. They don't change tool names, how tools are called, or what an MCP client sees. +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 diff --git a/packages/appduct/README.md b/packages/appduct/README.md index b7f31236..ef2d801d 100644 --- a/packages/appduct/README.md +++ b/packages/appduct/README.md @@ -208,7 +208,7 @@ Once configured, an agent reaches the connected app's tools through three built- | Tool | Does what | CLI equivalent | | --- | --- | --- | -| `appduct_list_tools` | Lists the app's tools as one-line signatures, with each tool's policy. Returns 50 at a time unless given `limit`; takes `filter` and `offset`. | `appduct tools` | +| `appduct_list_tools` | Lists the app's tools as one-line signatures, with each tool's group and policy, plus the app's groups with counts. Returns 50 at a time unless given `limit`; takes `group`, `filter` and `offset`. | `appduct tools` | | `appduct_describe_tool` | Shows one tool's full input and output schema. | `appduct tools ` | | `appduct_call_tool` | Calls a tool by `name` with `args`, with progress and errors preserved. | `appduct invoke` | diff --git a/packages/appduct/src/__tests__/__snapshots__/mcp-server.test.ts.snap b/packages/appduct/src/__tests__/__snapshots__/mcp-server.test.ts.snap index 560efe2a..88e65443 100644 --- a/packages/appduct/src/__tests__/__snapshots__/mcp-server.test.ts.snap +++ b/packages/appduct/src/__tests__/__snapshots__/mcp-server.test.ts.snap @@ -132,6 +132,10 @@ exports[`mcp: tools/list > the built-in tool shapes match the locked snapshot 1` "maxLength": 256, "type": "string", }, + "group": { + "pattern": "^[a-zA-Z0-9_-]{1,64}(?:\\/[a-zA-Z0-9_-]{1,64})?$", + "type": "string", + }, "limit": { "exclusiveMinimum": 0, "type": "integer", diff --git a/packages/appduct/src/__tests__/e2e/mcp.e2e.test.ts b/packages/appduct/src/__tests__/e2e/mcp.e2e.test.ts index 0f720072..bfa5c736 100644 --- a/packages/appduct/src/__tests__/e2e/mcp.e2e.test.ts +++ b/packages/appduct/src/__tests__/e2e/mcp.e2e.test.ts @@ -82,6 +82,7 @@ describe("e2e: mcp (real stdio subprocess)", () => { total: 1, limit: 50, tools: [{ name: "echo", signature: "echo(text?: string)", summary: "Echoes its input.", policy: "allow" }], + groups: [{ group: null, total: 1 }], }); const described = await client.request( diff --git a/packages/appduct/src/__tests__/mcp-daemon-fake.ts b/packages/appduct/src/__tests__/mcp-daemon-fake.ts index 4110c78b..0aae6a1d 100644 --- a/packages/appduct/src/__tests__/mcp-daemon-fake.ts +++ b/packages/appduct/src/__tests__/mcp-daemon-fake.ts @@ -20,6 +20,7 @@ import { RPC_METHODS, summarizeToolGroups, + toolGroupMatches, type ErrorType, type EventNotification, type SessionSummary, @@ -181,8 +182,9 @@ export const createFakeDaemon = (): FakeDaemon => { } if (method === RPC_METHODS.toolsList) { - const { selector, filter, limit, offset } = (params ?? {}) as { + const { selector, group, filter, limit, offset } = (params ?? {}) as { selector?: string; + group?: string; filter?: string; limit?: number; offset?: number; @@ -190,11 +192,13 @@ export const createFakeDaemon = (): FakeDaemon => { const entries = toolsByAlias.get(resolveSession(selector).alias)!; // The daemon's `{ tools, total, groups }` shape: sorted by name as the daemon sorts its registry, - // `filter`ed on name and description, `total` counted before paging. + // narrowed to `group`, `filter`ed on name and description, `total` counted before paging, + // and `groups` summarizing the whole registry. const lowerFilter = filter?.toLowerCase(); const matching = entries .map((entry) => ({ ...entry })) .sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)) + .filter((entry) => group === undefined || toolGroupMatches(entry.group, group)) .filter( (entry) => lowerFilter === undefined || diff --git a/packages/appduct/src/__tests__/mcp-server.integration.test.ts b/packages/appduct/src/__tests__/mcp-server.integration.test.ts index ce233737..2d5253e5 100644 --- a/packages/appduct/src/__tests__/mcp-server.integration.test.ts +++ b/packages/appduct/src/__tests__/mcp-server.integration.test.ts @@ -345,7 +345,7 @@ describe("mcp: calling app tools", () => { app.socket.close(); }); - test("grouped tools are listed and called over MCP exactly like ungrouped ones, and the group never reaches MCP", async () => { + test("appduct_list_tools narrows to a group on the real daemon, and grouped tools describe and call like any other", async () => { const { daemon, stateDir, port } = await startTestDaemon(); const app = await claimApp(daemon, port); await snapshotTools(daemon, app, [ @@ -358,21 +358,34 @@ describe("mcp: calling app tools", () => { const client = await connectInMemoryClient(handle); const listed = await client.request( - { method: "tools/call", params: { name: "appduct_list_tools", arguments: {} } }, + { method: "tools/call", params: { name: "appduct_list_tools", arguments: { group: "checkout" } } }, CallToolResultSchema, ); - expect((listed.structuredContent as { tools: Array<{ name: string }> }).tools.map((tool) => tool.name)).toEqual([ - "begin", - "pay", - "ping", - ]); - expect(JSON.stringify(listed.structuredContent)).not.toContain("checkout"); + expect(listed.structuredContent).toMatchObject({ + total: 2, + tools: [ + { name: "begin", group: "checkout" }, + { name: "pay", group: "checkout/payment" }, + ], + groups: [ + { group: "checkout", total: 2 }, + { group: "checkout/payment", total: 1 }, + { group: null, total: 1 }, + ], + }); + + const badGroup = await client.request( + { method: "tools/call", params: { name: "appduct_list_tools", arguments: { group: "a/b/c" } } }, + CallToolResultSchema, + ); + expect(badGroup.isError).toBe(true); + expect((badGroup.content[0] as { text: string }).text).toContain("invalid_request"); const described = await client.request( { method: "tools/call", params: { name: "appduct_describe_tool", arguments: { name: "pay" } } }, CallToolResultSchema, ); - expect(described.structuredContent).not.toHaveProperty("group"); + expect(described.structuredContent).toMatchObject({ name: "pay", group: "checkout/payment" }); app.socket.on("message", (data) => { const msg = JSON.parse(data.toString("utf8")) as Record; diff --git a/packages/appduct/src/__tests__/mcp-server.test.ts b/packages/appduct/src/__tests__/mcp-server.test.ts index df64cb6d..5d2c39d8 100644 --- a/packages/appduct/src/__tests__/mcp-server.test.ts +++ b/packages/appduct/src/__tests__/mcp-server.test.ts @@ -172,9 +172,80 @@ describe("mcp: appduct_list_tools", () => { annotations: { destructiveHint: true }, }, ], + groups: [{ group: null, total: 2 }], }); }); + test("narrows to a group, subgroups included, while groups still summarizes every tool", async () => { + const daemon = createFakeDaemon(); + daemon.addSession({ alias: "pixel-8" }).setTools([ + { name: "begin", group: "checkout" }, + { name: "pay", group: "checkout/payment" }, + { name: "add_item", group: "cart" }, + { name: "ping" }, + ]); + + const client = await startServerWithClient(daemon); + const result = await callBuiltin(client, "appduct_list_tools", { group: "checkout" }); + + expect(result.structuredContent).toMatchObject({ + session: "pixel-8", + group: "checkout", + total: 2, + tools: [ + { name: "begin", group: "checkout" }, + { name: "pay", group: "checkout/payment" }, + ], + groups: [ + { group: "cart", total: 1 }, + { group: "checkout", total: 2 }, + { group: "checkout/payment", total: 1 }, + { group: null, total: 1 }, + ], + }); + expect(daemon.calls().filter((call) => call.method === RPC_METHODS.toolsList).at(-1)?.params).toMatchObject({ + group: "checkout", + }); + }); + + test("against a daemon that predates groups, asking for a group fails instead of listing everything", async () => { + const daemon = createFakeDaemon(); + daemon.addSession({ alias: "pixel-8" }).setTools([{ name: "ping" }]); + // An older daemon answers tools.list with no `groups` and ignores `group`. + const openOldStream = async () => { + const stream = await daemon.openStream(); + const call = stream.call; + return { + ...stream, + call: async (method: string, params?: unknown): Promise => { + const result = await call(method, params); + + if (method === RPC_METHODS.toolsList) { + const { groups: _groups, ...rest } = result as Record; + return rest as TResult; + } + + return result; + }, + }; + }; + + const handle = await createMcpServer({ stateDir: "/nonexistent-state-dir", openStream: openOldStream, env: {} }); + mcpHandles.push(handle); + const [serverTransport, clientTransport] = InMemoryTransport.createLinkedPair(); + await handle.connect(serverTransport); + const client = new Client({ name: "test-client", version: "0.0.0" }); + await client.connect(clientTransport); + + const plain = await callBuiltin(client, "appduct_list_tools", {}); + expect(plain.structuredContent).toMatchObject({ tools: [{ name: "ping" }] }); + expect(plain.structuredContent).not.toHaveProperty("groups"); + + expect(errorText(await callBuiltin(client, "appduct_list_tools", { group: "cart" }))).toContain( + "does not support tool groups", + ); + }); + test("forwards filter/limit/offset to the daemon, echoes them, and reports total before paging", async () => { const daemon = createFakeDaemon(); daemon @@ -269,7 +340,7 @@ describe("mcp: appduct_list_tools", () => { }); describe("mcp: appduct_describe_tool", () => { - test("returns the whole descriptor, its signature and policy, with every schema exactly as registered", async () => { + test("returns the whole descriptor, group included, with its signature and policy, and every schema exactly as registered", async () => { const daemon = createFakeDaemon(); daemon.addSession({ alias: "pixel-8" }).setTools([ { @@ -280,6 +351,7 @@ describe("mcp: appduct_describe_tool", () => { output_schema: { type: "array", items: { type: "string" } }, annotations: { readOnlyHint: true }, timeout_ms: 30_000, + group: "todos", }, ]); @@ -296,6 +368,7 @@ describe("mcp: appduct_describe_tool", () => { output_schema: { type: "array", items: { type: "string" } }, annotations: { readOnlyHint: true }, timeout_ms: 30_000, + group: "todos", }); }); diff --git a/packages/appduct/src/mcp/app-tools.ts b/packages/appduct/src/mcp/app-tools.ts index 7cef4d78..962064bf 100644 --- a/packages/appduct/src/mcp/app-tools.ts +++ b/packages/appduct/src/mcp/app-tools.ts @@ -17,6 +17,7 @@ import { RPC_METHODS, renderToolSignature, summarizeToolDescription, + TOOL_GROUP_PATTERN, type EffectivePolicyDecision, type SessionsDescribeResult, type ToolDescriptor, @@ -48,8 +49,10 @@ export const LIST_TOOLS_TOOL_DESCRIPTOR = { name: LIST_TOOLS_TOOL_NAME, description: "List the tools the connected app registered, as one-line signatures " + - "(`name(param: type, optional?: type) -> result`) with the first line of each description " + - "and the tool's effective policy. Start here: the app's tools are not MCP tools of their own. " + + "(`name(param: type, optional?: type) -> result`) with the first line of each description, " + + "the tool's group and its effective policy. Start here: the app's tools are not MCP tools of " + + "their own. Every result also carries groups: the app's tool groups with counts, over all its " + + "tools. On a large app, list one area with group (\"checkout\" includes \"checkout/payment\"). " + "filter is a case-insensitive substring match on name and description; limit (default " + `${DEFAULT_LIST_TOOLS_LIMIT}) and offset page the name-sorted list, and total counts every ` + "match before paging, so page on with offset when total is larger. Use appduct_describe_tool for " + @@ -59,6 +62,7 @@ export const LIST_TOOLS_TOOL_DESCRIPTOR = { type: "object", properties: { selector: SELECTOR_PROPERTY, + group: { type: "string", pattern: TOOL_GROUP_PATTERN.source }, filter: { type: "string", maxLength: MAX_TOOLS_FILTER_LENGTH }, limit: { type: "integer", exclusiveMinimum: 0 }, offset: { type: "integer", minimum: 0 }, @@ -70,8 +74,8 @@ export const LIST_TOOLS_TOOL_DESCRIPTOR = { export const DESCRIBE_TOOL_TOOL_DESCRIPTOR = { name: DESCRIBE_TOOL_TOOL_NAME, description: - "Show one of the connected app's tools in full: description, input_schema (JSON Schema for " + - "appduct_call_tool's args), output_schema, annotations, timeout and effective policy. Find " + + "Show one of the connected app's tools in full: description, group, input_schema (JSON Schema " + + "for appduct_call_tool's args), output_schema, annotations, timeout and effective policy. Find " + "names with appduct_list_tools.", inputSchema: { type: "object", @@ -182,6 +186,7 @@ const toDescriptor = (entry: ToolsListEntry): ToolDescriptor => { output_schema: entry.output_schema, annotations: entry.annotations, timeout_ms: entry.timeout_ms, + group: entry.group, }; }; @@ -215,19 +220,30 @@ const findTool = async (call: DaemonCall, selector: string | undefined, name: st export const handleListToolsTool = async (rawArgs: unknown, call: DaemonCall) => { const args = asRecord(rawArgs); - rejectUnknownKeys(args, LIST_TOOLS_TOOL_NAME, ["selector", "filter", "limit", "offset"]); + rejectUnknownKeys(args, LIST_TOOLS_TOOL_NAME, ["selector", "group", "filter", "limit", "offset"]); const selector = asOptionalString(args.selector, "selector"); const session = await resolveSession(call, selector); - // `filter`/`limit`/`offset` are validated by the daemon, which rejects a bad value with + // `group`/`filter`/`limit`/`offset` are validated by the daemon, which rejects a bad value with // `invalid_request` exactly as it does for the CLI. const params = { + ...(args.group !== undefined && args.group !== null ? { group: args.group } : {}), ...(args.filter !== undefined && args.filter !== null ? { filter: args.filter } : {}), limit: args.limit ?? DEFAULT_LIST_TOOLS_LIMIT, ...(args.offset !== undefined && args.offset !== null ? { offset: args.offset } : {}), }; const result = await call(RPC_METHODS.toolsList, { selector: session.sessionId, ...params }); + // A daemon that predates tool groups returns no `groups` and ignores `group`, so the page it sent + // back is the whole registry, not the group. The version check normally restarts such a daemon + // first; when it could not, say so rather than hand the agent a wrong listing. + if (!Array.isArray(result.groups) && "group" in params) { + throw new McpBuiltinToolError( + "connection_error", + "The running Appduct daemon does not support tool groups. Restart it with a newer version (`appduct daemon stop`), then restart this MCP server.", + ); + } + return { session: session.alias, total: result.total, @@ -236,9 +252,11 @@ export const handleListToolsTool = async (rawArgs: unknown, call: DaemonCall) => name: entry.name, signature: renderToolSignature(entry), summary: summarizeToolDescription(entry.description), + ...(entry.group !== undefined ? { group: entry.group } : {}), policy: entry.policy, ...(entry.annotations ? { annotations: entry.annotations } : {}), })), + ...(Array.isArray(result.groups) ? { groups: result.groups } : {}), }; }; diff --git a/packages/appduct/src/mcp/connect-tool.ts b/packages/appduct/src/mcp/connect-tool.ts index 99da0b80..d599549f 100644 --- a/packages/appduct/src/mcp/connect-tool.ts +++ b/packages/appduct/src/mcp/connect-tool.ts @@ -132,7 +132,13 @@ export const WAIT_FOR_SESSION_TOOL_DESCRIPTOR = { * mapping (see `server.ts`) handles them the same way it handles an app tool's error. */ export class McpBuiltinToolError extends Error { constructor( - readonly type: "invalid_request" | "tool_not_found" | "tool_timeout" | "tool_cancelled" | "tool_execution_error", + readonly type: + | "invalid_request" + | "tool_not_found" + | "tool_timeout" + | "tool_cancelled" + | "tool_execution_error" + | "connection_error", message: string, ) { super(message); diff --git a/skills/appduct/SKILL.md b/skills/appduct/SKILL.md index 08ddd14e..d328589f 100644 --- a/skills/appduct/SKILL.md +++ b/skills/appduct/SKILL.md @@ -134,8 +134,10 @@ which blocks until the device connects (or returns immediately if it already has The app's own tools are not MCP tools of their own. Reach them through three built-ins that mirror the CLI: -1. `appduct_list_tools` lists them as one-line signatures with each tool's policy (like - `appduct tools`). On a large app, narrow with `filter`, or page with `limit`/`offset`. +1. `appduct_list_tools` lists them as one-line signatures with each tool's group and policy + (like `appduct tools`), and every result carries the app's `groups` with counts. On a large + app, pick a group from that summary and list it with `group` (`"checkout"` includes + `"checkout/payment"`), or narrow with `filter`, or page with `limit`/`offset`. 2. `appduct_describe_tool({ name })` shows one tool's full input and output schema (like `appduct tools `). 3. `appduct_call_tool({ name, args })` runs it (like `appduct invoke`). From adc3142043f7363f5371bf31f111fc12631fa057 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Mon, 21 Sep 2026 14:50:20 +0200 Subject: [PATCH 4/4] docs(mcp): tell agents that filter skips groups and group names need their parent path Found in an end-to-end run with a real agent: it tried filter before group, and a subgroup's bare name before its full path, costing an extra call each time. appduct_list_tools' description now says both. --- packages/appduct/src/mcp/app-tools.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/appduct/src/mcp/app-tools.ts b/packages/appduct/src/mcp/app-tools.ts index 962064bf..8fe2f03e 100644 --- a/packages/appduct/src/mcp/app-tools.ts +++ b/packages/appduct/src/mcp/app-tools.ts @@ -52,8 +52,10 @@ export const LIST_TOOLS_TOOL_DESCRIPTOR = { "(`name(param: type, optional?: type) -> result`) with the first line of each description, " + "the tool's group and its effective policy. Start here: the app's tools are not MCP tools of " + "their own. Every result also carries groups: the app's tool groups with counts, over all its " + - "tools. On a large app, list one area with group (\"checkout\" includes \"checkout/payment\"). " + - "filter is a case-insensitive substring match on name and description; limit (default " + + "tools. On a large app, list one area with group, copying the exact name from groups, parent " + + "path included (\"diagnostics/progress\", not \"progress\"); \"checkout\" includes " + + "\"checkout/payment\". filter is a case-insensitive substring match on name and description " + + "only, never on group names; limit (default " + `${DEFAULT_LIST_TOOLS_LIMIT}) and offset page the name-sorted list, and total counts every ` + "match before paging, so page on with offset when total is larger. Use appduct_describe_tool for " + "one tool's full input/output schema, then appduct_call_tool to run it. A tool with policy " +