feat: tool groups — optional group on the descriptor and --group/--groups on tools.list - #74
Merged
Merged
Conversation
V3RON
force-pushed
the
feat/tool-groups
branch
from
September 21, 2026 12:08
1746bda to
6f0226b
Compare
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.
…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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds tool groups (#70). A tool can declare an optional
group: a top-level group (checkout) or one subgroup (checkout/payment). Each segment matchesTOOL_NAME_PATTERN.tools.listandappduct toolscan then narrow by group.ToolDescriptor.group?: stringis validated by one shared pattern,^[a-zA-Z0-9_-]{1,64}(?:/[a-zA-Z0-9_-]{1,64})?$, inisToolDescriptor(JS),validateToolDescriptor/parseToolDescriptor(Swift) andvalidateAppductToolDescriptor/fromJson(Kotlin core and core-noop). An invalid group invalidates the snapshot with1008 invalid_registry, the same as a badtimeout_ms. An explicitnullis rejected on all three platforms.tools.list. Takes agroupparam (a malformed one isinvalid_request). The handler sorts, then applies the group (segment match:checkoutincludescheckout/*and never matchescheckoutx; case-sensitive), thenfilter, then computestotal, then pages. Every result carriesgroups: {group: string|null, total}[], computed over the whole registry before any narrowing.appduct tools --group <name>combines with--filter/--limit/--offset.appduct tools --groupsprints groups and counts, with subgroups indented under their parent.--groupis given, the listing prints group headings, subgroups as sub-headings, and(ungrouped)last. The truncation footer names the top-level groups.--jsoncarriesgroupon each entry andgroupson the listing.--group/--groupstogether with a<name>, including the single-arg probe;--groupswith any narrowing flag or--full; a malformed--group.--groups, not "No tools registered."registerTool/useAppductToolacceptgroup, and it is part ofuseAppductTool's derived re-registration key.createToolGroup("cart")returnsregisterToolwith the group bound. Root and./noopshare one factory, and a registration passed to it can't override the group, which is checked at the type level.Appduct.shared.register(…, group:)and KotlinAppduct.register(…, group =), stubs included.appduct_list_tools,appduct_describe_toolandappduct_call_tool. Groups are exposed there too:appduct_list_toolstakesgroup, with the same segment matching as--group. The daemon validates it and rejects a malformed one asinvalid_request.groupand carries the whole-registrygroupssummary on every call, so an agent can see an app's areas and list one.appduct_describe_toolincludesgroup.connection_errorinstead of listing the whole registry, the same guard the CLI has.tools/listitself is unchanged: groups are data in these results, not MCP tools.counter(2 tools),diagnostics(1) plus thediagnostics/progresssubgroup (1), andsumleft ungrouped. The Tools tab shows each tool's group.Closes #70
Backwards compatibility
origin/main'sisToolDescriptor: it accepts descriptors withgroupof any value ("checkout","a/b/c",42), so unknown keys are ignored. The daemon stores the descriptor as-is.group.--daemon-restart), an old daemon returns nogroupsand ignores thegroupparam. In that case--group/--groupsnow fail with aconnection_errorthat says the daemon doesn't support groups, instead of printing the whole registry as the group. Plain listings still work.Also fixed
NSRegularExpressiontreats$as matching before a final line terminator, so"tool\n"passed on iOS while JS and Kotlin rejected it; the daemon would then reject the snapshot. The Swift name and group matchers now require the match to span the whole string. New fixture vectors:name-trailing-newlineandgroup-trailing-newline.Testing
pnpm build,pnpm typecheck,pnpm lint(0 errors; the existing prettier warnings are unchanged),pnpm check:links.pnpm test: shared 299 passed, react-native 247 passed, appduct 791 passed + 1 skipped. New or extended tests:tool-descriptor.test.ts: validation, segment matching, summary ordering.fixtures-conformance: 22 newtool-descriptors.jsonvectors, includinggroup-valid,group-subgroup-valid,group-empty,group-empty-segment,/payment,a//b,/,group-too-deep, 64- and 65-char segments,group-bad-charand variants, non-ASCII, trailing newline,group-non-string, array,null.tool-invocation.integration.test.ts: group filters before total and paging,groupsreflects the unfiltered registry, bad group params are rejected.session-engine.integration.test.ts: a too-deep group gives1008 invalid_registry.cli-v2.integration.test.ts: the feat(cli): agent-friendly output — signature tools listing with filter/paging, table-driven global flags, --pretty/--verbose, compact JSON #67 fake app with three groups, one with a subgroup, plus ungrouped tools. Covers headings, the group-aware footer,--groupfor parent and subgroup,--groups, JSON shape, an empty group, and the usage errors.output.test.ts: the footer with more than 10 groups.tools-command.test.ts: old-daemon handling.use-appduct-tool.test.ts: re-registers only whengroupchanges.client.test.ts:groupon the wire;createToolGroup.noop-parity.test.ts:createToolGrouptyping on both entries.mcp-server.test.ts:appduct_list_toolsnarrows to a group (subgroups included) whilegroupssummarizes every tool;groupappears in list and describe output; the old-daemon guard.mcp-server.integration.test.ts: against the real daemon, listing by group, a malformed group rejected asinvalid_request, and describing and calling a grouped tool.swift build -c release(Stub branch) andswift test: 135 tests, 0 failures. That includesFixturesConformanceTests.testToolDescriptorsFixtureand newAppductToolDescriptorTestsgroup cases../gradlew :core:testDebugUnitTest :core-noop:assembleRelease: 146 tests, 0 failures, 0 errors. That includesFixturesConformanceTestand newAppductToolRegistryTestgroup cases.appductCLI (this branch's build) against the playground on an iPhone 17 Pro simulator (iOS 26.4). Debug build viaexpo prebuild+xcodebuild, Metro on 8081. The session was established withappduct link --open ios-simagainst a copy of the playground state dir on port 8453, because 8443 was taken by an unrelated local daemon. Excerpts:Adversarial review
A separate reviewer agent read #70 and reviewed the full diff. Findings and resolutions:
--group(for example the wrong case) printed "No tools registered.", and with--filterit dropped the group. Fixed: the listing now saysNo tools in group "X"…under aTools in group Xtitle, with a regression test in cli-v2.--groupsagainst a pre-groups daemon printed "No tools registered.", and--grouplisted the whole registry. Fixed: both now fail with aconnection_errornaming the unsupported daemon (tools-command.test.ts).groupssorts by segment, not by the plain path string. A parent always sits right before its subgroups (checkout,checkout/payment,checkout-x), where a plain code-point sort would putcheckout-xbetween them because-sorts before/. This is documented inrpc.tsand ARCHITECTURE.md §5.(offset o) … or page with --offset <n>wording around the issue'sNarrow with --group <name> (groups: …) or --filter <text>part. It also names at most 10 top-level groups, then... N more; see --groups, so the line stays bounded on a large app.--groupswith--full,--limitand--offset; the single-arg probetools cart_00 --groups; the footer overflow past 10 groups.tools --groups checkouttreatedcheckoutas a session. Fixed: when that selector is an unknown session, the error suggests--group checkout(tested).The reviewer found JS/Swift/Kotlin parity, daemon ordering, MCP isolation, the re-registration key and the docs correct. I didn't run a second review round, because the fixes were confined to CLI messaging and tests.
Not verified
mcp-server.integration.test.ts) against a real daemon and fake app, not by a live MCP client on the simulator.Rebased onto #73 and #75
Rebased onto
mainafter #73 (elicitation-only consent) and #75 (MCP list/describe/call built-ins) merged. Conflicts were resolved as follows:tools.listrow takes this PR'sgroup/groups, and thetools.callrow keeps refactor(mcp)!: make elicitation the only "prompt" consent channel #73'sconsent?: "elicitation".output.ts: the grouped listing uses feat(mcp)!: reach app tools through list/describe/call built-ins #75's sharedsummarizeToolDescriptioninstead of its own copy.tool-mapping.test.tsis deleted, as onmain.groupssummary.A follow-up commit exposes groups over MCP (above). After it,
pnpm turbo run build typecheck test --filter=appduct --filter=@appduct/shared --filter=@appduct/react-nativepasses: shared 286, react-native 250, appduct 739 plus 1 skipped.check:linkspasses. No native files conflicted, so CI's iOS and Android jobs will cover those.