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. Two gaps remain 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 solve both with one piece of app-declared metadata, and they fit the shape #67 leaves behind.
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.
Proposal
Phase 1: descriptor, daemon, CLI, SDKs
Descriptor.ToolDescriptor.group?: string, [a-zA-Z0-9_-]{1,64} (same pattern as name; reuse TOOL_NAME_PATTERN). Validated in isToolDescriptor, AppductToolDescriptor.swift, AppductClientTypes.kt; conformance fixtures in packages/native/fixtures/tool-descriptors.json gain group-valid, group-empty (invalid), group-bad-char (invalid), group-non-string (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. A convenience for the common case is worth considering but not required: 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 (exact, case-sensitive match) 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, so a caller can always see what groups exist and how big they are even when its page is empty. null is the ungrouped bucket. 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). In the human listing, when the registry has any grouped tool and no --group was given, print signatures under group 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>.--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".
Phase 2: lazy exposure over MCP
Where grouping pays off most, and separable so phase 1 can ship alone.
A new built-in tool appduct_find_tools({ group?, filter?, limit?, offset? }) returns signatures (via renderToolSignature) plus the groups summary, so an agent can discover what exists without the full descriptors ever entering its context.
Per connection, tools/list emits only tools in enabled groups. Ungrouped tools are always enabled (so an app with no groups behaves exactly as today, and phase 1 apps see no MCP change until they opt in). An appduct_enable_tools({ groups: string[] }) built-in flips groups on for this connection and fires notifications/tools/list_changed; appduct_disable_tools is the inverse.
Initial enabled set: everything (today's behaviour) unless config.jsonmcp.lazyGroups: true or appduct mcp --lazy-groups, in which case only ungrouped tools start enabled. Keep the opt-in explicit so nobody's agent silently loses tools on upgrade.
tools/call for a tool in a disabled group returns isError: true naming the group and appduct_enable_tools, never a protocol error, so the calling agent can recover in one step. Consent handling (resolveToolCallConsent) and the emittedRequiresUserInteraction bookkeeping are per listing and are unaffected, since a disabled tool was never listed.
Namespacing (<alias>__<name>) is orthogonal: groups are per session, and the groups summary in a multi-session listing is keyed by alias.
Acceptance criteria
Phase 1:
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; 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.
Phase 2:
With --lazy-groups, a client's tools/list contains only built-ins and ungrouped tools until appduct_enable_tools is called; list_changed fires on enable/disable; calling a disabled tool returns the recoverable error.
Without the flag, tools/list is byte-for-byte what it is today for an app with no groups.
Sequencing
Land after #67 merges: this touches commands/tools.ts, output.ts, rpc.ts, the daemon handler and the skill, all of which #67 rewrites. Phase 1 can be one PR; phase 2 a second.
Out of scope
Nested groups or multiple groups per tool. One flat string is enough for the problem at hand and keeps the wire format trivial.
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. Two gaps remain for an app that registers hundreds of tools:tools/listinpackages/appduct/src/mcp/server.tsemits every tool of every session on every request. MCP clients degrade with long tool lists (context cost per turn, worse tool selection), and feat(cli): agent-friendly output — signature tools listing with filter/paging, table-driven global flags, --pretty/--verbose, compact JSON #67 does not touch that surface at all.Groups solve both with one piece of app-declared metadata, and they fit the shape #67 leaves behind.
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.Proposal
Phase 1: descriptor, daemon, CLI, SDKs
Descriptor.
ToolDescriptor.group?: string,[a-zA-Z0-9_-]{1,64}(same pattern asname; reuseTOOL_NAME_PATTERN). Validated inisToolDescriptor,AppductToolDescriptor.swift,AppductClientTypes.kt; conformance fixtures inpackages/native/fixtures/tool-descriptors.jsongaingroup-valid,group-empty(invalid),group-bad-char(invalid),group-non-string(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. A convenience for the common case is worth considering but not required: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(exact, case-sensitive match) 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, so a caller can always see what groups exist and how big they are even when its page is empty.nullis the ungrouped bucket. 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). In the human listing, when the registry has any grouped tool and no--groupwas given, print signatures under group 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>.--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".Phase 2: lazy exposure over MCP
Where grouping pays off most, and separable so phase 1 can ship alone.
appduct_find_tools({ group?, filter?, limit?, offset? })returns signatures (viarenderToolSignature) plus thegroupssummary, so an agent can discover what exists without the full descriptors ever entering its context.tools/listemits only tools in enabled groups. Ungrouped tools are always enabled (so an app with no groups behaves exactly as today, and phase 1 apps see no MCP change until they opt in). Anappduct_enable_tools({ groups: string[] })built-in flips groups on for this connection and firesnotifications/tools/list_changed;appduct_disable_toolsis the inverse.config.jsonmcp.lazyGroups: trueorappduct mcp --lazy-groups, in which case only ungrouped tools start enabled. Keep the opt-in explicit so nobody's agent silently loses tools on upgrade.tools/callfor a tool in a disabled group returnsisError: truenaming the group andappduct_enable_tools, never a protocol error, so the calling agent can recover in one step. Consent handling (resolveToolCallConsent) and theemittedRequiresUserInteractionbookkeeping are per listing and are unaffected, since a disabled tool was never listed.<alias>__<name>) is orthogonal: groups are per session, and thegroupssummary in a multi-session listing is keyed by alias.Acceptance criteria
Phase 1:
grouppasses the same fixtures on JS, Swift and Kotlin (FixturesConformanceTest*); an invalid group invalidates the snapshot exactly like an invalidtimeout_ms.tools.listwithgroupfilters beforetotal;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 renders headings and the group-aware footer;--group,--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.Phase 2:
--lazy-groups, a client'stools/listcontains only built-ins and ungrouped tools untilappduct_enable_toolsis called;list_changedfires on enable/disable; calling a disabled tool returns the recoverable error.tools/listis byte-for-byte what it is today for an app with no groups.Sequencing
Land after #67 merges: this touches
commands/tools.ts,output.ts,rpc.ts, the daemon handler and the skill, all of which #67 rewrites. Phase 1 can be one PR; phase 2 a second.Out of scope
policy.groups["cart"] = "deny"). Natural follow-up once groups exist, but a separate issue.