Skip to content

Tool groups: optional group (max two levels) on the descriptor and --group on tools.list #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. 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: ToolsCommandOptionslistTools 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 checkout and 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.
  • 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 (one with a subgroup) renders headings and the group-aware footer; --group (parent and subgroup), --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.
  • 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.

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