Skip to content

Tool groups: optional group on the descriptor, --group on tools.list, and lazy group exposure over MCP #70

Description

@V3RON

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/--offset paging. Two gaps remain for an app that registers hundreds of tools:

  1. 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.
  2. MCP still gets everything. tools/list in packages/appduct/src/mcp/server.ts emits 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

  • 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: ToolsCommandOptionslistTools 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.json mcp.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.
  • appduct tools on 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 in cli-v2.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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions