You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
#67 makes a large registry cheap to read from the CLI: a signature per tool, daemon-side --filter (substring on name and description) and --limit/--offset paging. One gap remains for an app that registers hundreds of tools: substring filtering is guesswork. An agent looking for "the checkout tools" has to know that the author wrote "checkout" somewhere in each name or description. The app author knows the structure (auth, navigation, cart, checkout, flags, debug) and has no way to declare it.
Groups fix that with one piece of app-declared metadata, and they fit the shape #67 leaves behind.
MCP exposure (lazy listing, discovery over MCP) is out of scope here and tracked in a separate issue. This issue does not change tools/list or tools/call in packages/appduct/src/mcp/server.ts.
Design constraints, from the current code
group is a descriptor field, not an annotation.annotations is validated as exactly the three MCP boolean hints on all three platforms (isValidAnnotations in packages/shared/src/domains/tool-descriptor.ts, AppductToolDescriptor.swift, AppductToolRegistry.kt) and maps 1:1 to MCP annotations. A fourth key would be rejected everywhere and would leak into MCP.
Older daemons must tolerate it.isToolDescriptor checks known fields and ignores unknown ones, so an app sending group to a pre-feature daemon keeps working; verify the Swift and Kotlin decoders do the same before relying on it.
Filtering belongs daemon-side, in the tools.list handler (packages/appduct/src/daemon/daemon.ts), applied before total is computed and before paging, exactly like filter is today. The CLI stays a renderer.
The CLI plumbing is already there: ToolsCommandOptions → listTools params → toListing echo → ToolsListing → the "Showing n of total" footer in output.ts. group is one more option through the same path.
Group shape: at most two levels
A group is either a top-level group (checkout) or a subgroup (checkout/payment). Nothing deeper.
Wire format stays a single string: ToolDescriptor.group?: string.
Valid values: one or two segments joined by /, each segment matching TOOL_NAME_PATTERN ([a-zA-Z0-9_-]{1,64}). Invalid: empty string, empty segment (checkout/, /payment, a//b), three or more segments, any other character, non-string.
Matching is by segment, case-sensitive: group: "checkout" selects tools in checkoutand in any checkout/* subgroup; group: "checkout/payment" selects exactly that subgroup. checkout does not match checkoutx.
Free-form nesting was considered and rejected: two levels cover real app structure (feature → sub-area) without a tree format on the wire or recursive rendering.
Proposal
Descriptor. Validated in isToolDescriptor, AppductToolDescriptor.swift, AppductClientTypes.kt with the rules above. Conformance fixtures in packages/native/fixtures/tool-descriptors.json gain at least group-valid, group-subgroup-valid, group-empty, group-empty-segment, group-too-deep, group-bad-char, group-non-string (all but the first two invalid). Documented in docs/PROTOCOL.md §5.
SDKs.registerTool/useAppductTool gain group?: string (part of the derived re-registration key, next to annotations and timeoutMs; docs/TOOLS.md "Registration is per mount"). Swift and Kotlin descriptor initialisers gain group. Optional convenience: createToolGroup("cart") returning a registerTool bound to that group, so a feature module registers its tools in one place without repeating the name.
tools.list.ToolsListParams.group?: string (validated with the same rules; segment match as above) applied in the handler between the sort and the substring filter, so total is "matching this group and this filter, before paging". The result gains groups: Array<{ group: string | null; total: number }> computed over the unfiltered, unpaged registry: one entry per top-level group (its total includes its subgroups), one entry per subgroup, and null for the ungrouped bucket. Sorted by group path, null last. Types in packages/shared/src/domains/rpc.ts; docs/ARCHITECTURE.md §5 row updated.
CLI.appduct tools [selector] --group <name> and appduct tools [selector] --groups (list groups with counts, nothing else; subgroups indented under their parent). In the human listing, when the registry has any grouped tool and no --group was given, print signatures under group headings (subgroups as sub-headings, ungrouped last as (ungrouped)), and make the footer say Showing n of total tools. Narrow with --group <name> (groups: cart 12, checkout 8, ...) or --filter <text>. (top-level groups only in the footer). --group combines with --filter/--limit/--offset; with <name> it is a usage error like the other listing flags. --json carries group on each entry (it is on the descriptor already) and groups on the listing.
Skill. One paragraph in skills/appduct/SKILL.md step 3: on a large app, run appduct tools --groups first, then --group <name>; and in the tool-authoring section, "put every tool in a group once an app has more than a screenful of them; use a subgroup only when a group itself outgrows a screen".
Acceptance criteria
Descriptor validation for group passes the same fixtures on JS, Swift and Kotlin (FixturesConformanceTest*); an invalid group invalidates the snapshot exactly like an invalid timeout_ms.
tools.list with group filters before total, with segment matching (parent includes subgroups, no string-prefix false positives); groups is present on every result and reflects the unfiltered registry; bad params rejected like limit/offset are in tool-invocation.integration.test.ts.
useAppductTool re-registers when group changes and not otherwise (use-appduct-tool.test.ts).
docs/PROTOCOL.md §5, docs/ARCHITECTURE.md §5/§10, docs/TOOLS.md, the SDK READMEs, the skill and CHANGELOG.md updated. An app on an older daemon still lists and calls its tools.
MCP tools/list output is unchanged.
Out of scope
More than two levels, or multiple groups per tool.
Any change to MCP exposure (separate issue).
Policy keyed on group (policy.groups["cart"] = "deny"). Natural follow-up once groups exist, but a separate issue.
Why
#67 makes a large registry cheap to read from the CLI: a signature per tool, daemon-side
--filter(substring on name and description) and--limit/--offsetpaging. One gap remains for an app that registers hundreds of tools: substring filtering is guesswork. An agent looking for "the checkout tools" has to know that the author wrote "checkout" somewhere in each name or description. The app author knows the structure (auth, navigation, cart, checkout, flags, debug) and has no way to declare it.Groups fix that with one piece of app-declared metadata, and they fit the shape #67 leaves behind.
MCP exposure (lazy listing, discovery over MCP) is out of scope here and tracked in a separate issue. This issue does not change
tools/listortools/callinpackages/appduct/src/mcp/server.ts.Design constraints, from the current code
groupis a descriptor field, not an annotation.annotationsis validated as exactly the three MCP boolean hints on all three platforms (isValidAnnotationsinpackages/shared/src/domains/tool-descriptor.ts,AppductToolDescriptor.swift,AppductToolRegistry.kt) and maps 1:1 to MCPannotations. A fourth key would be rejected everywhere and would leak into MCP.isToolDescriptorchecks known fields and ignores unknown ones, so an app sendinggroupto a pre-feature daemon keeps working; verify the Swift and Kotlin decoders do the same before relying on it.tools.listhandler (packages/appduct/src/daemon/daemon.ts), applied beforetotalis computed and before paging, exactly likefilteris today. The CLI stays a renderer.ToolsCommandOptions→listToolsparams →toListingecho →ToolsListing→ the "Showing n of total" footer inoutput.ts.groupis one more option through the same path.Group shape: at most two levels
A group is either a top-level group (
checkout) or a subgroup (checkout/payment). Nothing deeper.ToolDescriptor.group?: string./, each segment matchingTOOL_NAME_PATTERN([a-zA-Z0-9_-]{1,64}). Invalid: empty string, empty segment (checkout/,/payment,a//b), three or more segments, any other character, non-string.group: "checkout"selects tools incheckoutand in anycheckout/*subgroup;group: "checkout/payment"selects exactly that subgroup.checkoutdoes not matchcheckoutx.Free-form nesting was considered and rejected: two levels cover real app structure (feature → sub-area) without a tree format on the wire or recursive rendering.
Proposal
Descriptor. Validated in
isToolDescriptor,AppductToolDescriptor.swift,AppductClientTypes.ktwith the rules above. Conformance fixtures inpackages/native/fixtures/tool-descriptors.jsongain at leastgroup-valid,group-subgroup-valid,group-empty,group-empty-segment,group-too-deep,group-bad-char,group-non-string(all but the first two invalid). Documented indocs/PROTOCOL.md§5.SDKs.
registerTool/useAppductToolgaingroup?: string(part of the derived re-registration key, next toannotationsandtimeoutMs;docs/TOOLS.md"Registration is per mount"). Swift and Kotlin descriptor initialisers gaingroup. Optional convenience:createToolGroup("cart")returning aregisterToolbound to that group, so a feature module registers its tools in one place without repeating the name.tools.list.ToolsListParams.group?: string(validated with the same rules; segment match as above) applied in the handler between the sort and the substringfilter, sototalis "matching this group and this filter, before paging". The result gainsgroups: Array<{ group: string | null; total: number }>computed over the unfiltered, unpaged registry: one entry per top-level group (itstotalincludes its subgroups), one entry per subgroup, andnullfor the ungrouped bucket. Sorted by group path,nulllast. Types inpackages/shared/src/domains/rpc.ts;docs/ARCHITECTURE.md§5 row updated.CLI.
appduct tools [selector] --group <name>andappduct tools [selector] --groups(list groups with counts, nothing else; subgroups indented under their parent). In the human listing, when the registry has any grouped tool and no--groupwas given, print signatures under group headings (subgroups as sub-headings, ungrouped last as(ungrouped)), and make the footer sayShowing n of total tools. Narrow with --group <name> (groups: cart 12, checkout 8, ...) or --filter <text>.(top-level groups only in the footer).--groupcombines with--filter/--limit/--offset; with<name>it is a usage error like the other listing flags.--jsoncarriesgroupon each entry (it is on the descriptor already) andgroupson the listing.Skill. One paragraph in
skills/appduct/SKILL.mdstep 3: on a large app, runappduct tools --groupsfirst, then--group <name>; and in the tool-authoring section, "put every tool in a group once an app has more than a screenful of them; use a subgroup only when a group itself outgrows a screen".Acceptance criteria
grouppasses the same fixtures on JS, Swift and Kotlin (FixturesConformanceTest*); an invalid group invalidates the snapshot exactly like an invalidtimeout_ms.tools.listwithgroupfilters beforetotal, with segment matching (parent includes subgroups, no string-prefix false positives);groupsis present on every result and reflects the unfiltered registry; bad params rejected likelimit/offsetare intool-invocation.integration.test.ts.appduct toolson the feat(cli): agent-friendly output — signature tools listing with filter/paging, table-driven global flags, --pretty/--verbose, compact JSON #67 fake app extended with three groups (one with a subgroup) renders headings and the group-aware footer;--group(parent and subgroup),--groups, and the usage error with<name>are covered incli-v2.integration.test.ts.useAppductToolre-registers whengroupchanges and not otherwise (use-appduct-tool.test.ts).docs/PROTOCOL.md§5,docs/ARCHITECTURE.md§5/§10,docs/TOOLS.md, the SDK READMEs, the skill andCHANGELOG.mdupdated. An app on an older daemon still lists and calls its tools.tools/listoutput is unchanged.Out of scope
policy.groups["cart"] = "deny"). Natural follow-up once groups exist, but a separate issue.