RFD 055: Tool Groups
- Status: Discussion
- Category: Design
- Authors: Jean Mertz git@jeanmertz.com
- Date: 2025-07-20
- Extended by: RFD 056
Summary
This RFD introduces tool groups: named sets of tools that can be enabled or disabled as a unit via --tools GROUP / --no-tools GROUP. Groups support exhaustive validation, ensuring every tool is classified relative to a group. Group membership is declared on the tool side via a groups field.
Motivation
Tools are configured individually in conversation.tools.NAME. Named config fragments (loaded via --cfg or the extends field) bundle tool configurations into reusable files, but there is no way to enable or disable a set of tools from the CLI.
Today, disabling all filesystem write tools requires:
jp query -T fs_create_file,fs_modify_file,fs_delete_file,fs_move_file "explain this code"And ensuring that every tool in the system is classified as "write" or "not write" is impossible — there is no validation mechanism. A new MCP tool could silently retain write access when the user expected --no-tools write to disable all write-capable tools.
A --cfg disable_write_tools.toml workaround exists, but it's fragile — you maintain a separate file that mirrors tool names, and nothing validates that the file stays in sync when tools are added or removed.
Tool groups solve two problems:
- CLI shorthand:
--no-tools writedisables all write tools in one flag. - Exhaustive validation: a group marked
exhaustive = trueguarantees that every loaded tool has been explicitly classified relative to that group. No tool escapes unreviewed.
Design
Group Definition
Groups are defined at conversation.tools.groups.NAME:
[conversation.tools.groups]
write = { exhaustive = true }
read = {}
git = {}
cargo = {}A group definition contains:
exhaustive(optional, defaultfalse): whether every tool must be classified relative to this group. See Exhaustive Validation.
Groups do not list their members. Membership is declared on the tool side (see below).
Group names must not collide with tool names. If a group and a tool share a name, JP exits with a config error at startup.
Group names must not start with ! (reserved for the exclusion shorthand).
Tool-Side Group Membership
Tools declare their group memberships via a groups field:
[conversation.tools.fs_create_file]
groups = ["write"]
[conversation.tools.fs_read_file]
groups = ["read", "!write"]Each entry in the array is either:
- A string — shorthand for included membership.
- A
!-prefixed string — shorthand for excluded membership. - A structured entry —
{ group = "NAME", membership = "include" | "exclude" }for when the long form is preferred.
The three forms are equivalent:
# These all mean "included in write":
groups = ["write"]
groups = [{ group = "write" }]
groups = [{ group = "write", membership = "include" }]
# These all mean "excluded from write":
groups = ["!write"]
groups = [{ group = "write", membership = "exclude" }]Referencing a group name that does not exist in conversation.tools.groups is a config error.
Naming both !write and write is valid, last-defined wins.
Membership States
For any (tool, group) pair, there are exactly three states:
| State | Meaning | How expressed |
|---|---|---|
| Included | Tool is a member of the group | "write" or { group = "write" } |
| Excluded | Tool is explicitly not a member | "!write" or `{ group = "write", |
| membership = "exclude" }` | ||
| Undefined | Tool has not been classified | Group not mentioned in groups |
The distinction between excluded and undefined is what makes exhaustive validation meaningful.
Interaction with * Defaults
The * defaults section can set a default groups field:
[conversation.tools.'*']
groups = ["write"]
[conversation.tools.fs_read_file]
groups = ["!write", "read"]This establishes a fail-closed baseline: all tools default to the write group. A tool that is not a write tool must explicitly exclude itself. This means --no-tools write is safe by default — if a new tool is added and the author forgets to classify it, it lands in the write group and gets disabled when write tools are disabled. No tool silently escapes with write access.
The groups field uses merge-by-group-name semantics at every merge boundary — between * defaults and tool-level config, and between config file layers (workspace config, --cfg overrides, etc.).
The merge rule is the same at every boundary:
- Start with the lower-priority groups array.
- Remove any entries whose group name appears in the higher-priority array (the higher-priority side overrides those).
- Append all higher-priority entries.
Retained lower-priority entries come first. Higher-priority entries follow in their declared order.
This means a --cfg override that sets groups = ["!write"] on a tool will override the write entry from the workspace config's * defaults, while retaining any other group entries that were already present.
Example:
# Defaults
[conversation.tools.'*']
groups = ["write"]
# fs_read_file overrides write, adds read
[conversation.tools.fs_read_file]
groups = ["!write", "read"]
# github_issues inherits "write" from *, adds github
[conversation.tools.github_issues]
groups = ["github"]
# cargo_check: inherits "write" from *, no tool-level groups override
# (if it has no groups field at all, it inherits ["write"] from *)
# Effective groups:
# fs_read_file: ["!write", "read"] (tool replaced *'s write with !write, added read)
# github_issues: ["write", "github"] (*'s write retained, tool's github appended)
# cargo_check: ["write"] (inherited from *)This merge behavior avoids the verbosity problem with exhaustive groups. Without it, every tool that sets its own groups would need to re-declare all exhaustive group memberships — exactly the kind of error-prone repetition that exhaustive validation is meant to prevent.
Exhaustive Validation
A group with exhaustive = true requires that every enabled tool has been classified relative to that group — either included or excluded. If any tool has the group in undefined state (not mentioned in the tool's effective groups array after * merge), JP exits with a startup error.
[conversation.tools.groups.write]
exhaustive = true
[conversation.tools.'*']
groups = ["write"] # baseline: all tools in write
[conversation.tools.fs_read_file]
groups = ["!write", "read"] # override: excluded from write
[conversation.tools.some_new_mcp_tool]
source = "mcp.my_server"
# inherits "write" from * → classified → OKWithout the * baseline, every tool must explicitly mention the exhaustive group in its groups array. Both approaches are valid — the * baseline is a convenience for the common pattern where most tools share a default classification.
Exhaustive validation runs after all config layers are merged (config files, --cfg, CLI flags) but before the query starts.
CLI Interaction
--tools (-t) and --no-tools (-T) accept group names in addition to tool names:
# Enable all tools in the "read" and "cargo" groups
jp query -t read,cargo "fix the parser"
# Enable all tools, then disable the "write" group
jp query -t -T write "explain this code"
# Disable the "git" group
jp query -T git "review this design"Since group names and tool names cannot collide, name resolution is unambiguous.
--tools GROUP enables all tools with included membership in that group. --no-tools GROUP disables all tools with included membership in that group.
Interaction with Tool enable Field
The existing enable field on tool config controls tool activation behavior. Groups interact with it as follows:
enable value | --tools GROUP | --no-tools GROUP |
|---|---|---|
None (default) | Enabled | Disabled |
on / true | Enabled | Disabled |
off / false | Enabled | Disabled |
explicit | Not enabled | Disabled |
explicit_or_group | Enabled | Disabled |
always | (already enabled) | Not disabled |
None(default): the tool has no explicit enable setting. Treated as enabled (consistent withis_none_or(Enable::is_on)in the current implementation).explicit: not enabled by--tools GROUP, consistent with not being enabled by bare--tools. Requires being named directly:--tools TOOL.explicit_or_group(new variant): likeexplicit, the tool is not enabled by bare--tools. But unlikeexplicit, it is enabled when a group it belongs to is activated via--tools GROUP. This allows tools that should not be swept up by blanket enable-all but should respond to targeted group activation.always: cannot be disabled by any mechanism, including--no-tools GROUP. Consistent with existing behavior for system-critical tools likedescribe_tools.
Drawbacks
Merge-by-name is a special case. The groups field uses merge-by-name semantics with * defaults, while scalar fields use replace semantics and other array fields use MergeableVec's append/replace strategies. This must be documented clearly. The justification (avoiding re-declaration verbosity with exhaustive groups) is sound, but it adds a concept users must learn.
New Enable variant. Adding explicit_or_group increases the surface area of an already nuanced enum. Users who don't use groups will never encounter it, but it is another value to document and maintain.
Alternatives
Include/exclude lists on group definitions
Instead of tool-side membership, groups could declare their members:
[conversation.tools.groups.write]
include = ["fs_create_file", "fs_modify_file"]
exclude = ["fs_read_file", "cargo_check"]This was rejected because it creates dual membership paths — membership expressed both on the group and on the tool. Conflicts between the two require resolution rules. Tool-side declaration is the single source of truth.
Tags instead of groups
Tools could declare tags (tags = ["read", "write"]) and the CLI could filter by tag (--tools @read). This is similar to the chosen design but does not support exhaustive validation or the include/exclude distinction.
exhaustive = "include" | "exclude" variants
Instead of just true | false, exhaustive could default unclassified tools to included or excluded. These were dropped because "include" is functionally equivalent to false (the check never fires — every tool auto-classifies) and "exclude" is already the natural behavior of the * defaults pattern ('*'.groups = ["write"] with per-tool "!write" overrides). The boolean is sufficient.
Non-Goals
- Group-level configuration. Groups in this RFD are purely membership containers for CLI shorthand and exhaustive validation. Allowing groups to carry config defaults or overrides (e.g., group-wide
runorstylesettings) is a natural extension but is deferred to a future RFD. - Recursive groups. Groups cannot contain other groups. The real-world taxonomy is shallow (read, write, git, cargo, github). If deeper nesting is needed, it can be added in a future RFD.
Risks and Open Questions
* merge semantics may surprise users. The merge-by-name behavior for groups differs from other fields. If this causes confusion in practice, we could fall back to replace semantics and accept the verbosity cost for exhaustive groups.
Enable enum growth. The enum now has five variants (on, off, explicit, explicit_or_group, always). If future features add more activation modes, the enum may need restructuring. For now, five variants is manageable.
Implementation Notes
GroupMemberships type
The merge-by-group-name behavior is implemented as a dedicated GroupMemberships type rather than extending MergeableVec with a third strategy. The key extraction logic (parsing group names from strings, !-prefixed strings, and structured entries) is specific to the groups type and doesn't generalize to other vec fields.
GroupMemberships wraps a Vec<GroupEntry> and provides:
merge_from(&mut self, other: &GroupMemberships)— the merge-by-name operation.included(&self) -> impl Iterator<Item = &str>— iterate included group names in declaration order.is_classified(&self, group: &str) -> bool— check if a group is mentioned (included or excluded), for exhaustive validation.
GroupEntry is an enum supporting string shorthand ("write", "!write") and structured form ({ group, membership }), with group_name() and is_excluded() accessors.
The type uses #[serde(transparent)] to serialize as a plain JSON/TOML array. A custom schematic merge function (merge_group_memberships) integrates it with the config system.
Implementation Plan
Phase 1: Types and membership
- Implement
GroupMemberships,GroupEntry, andMembershiptypes with merge-by-group-name semantics and themerge_group_membershipsmerge function. - Add
ToolGroupConfigstruct withexhaustivefield (no config sections). - Add
groups: IndexMap<String, ToolGroupConfig>toToolsConfig(as a named field before the flattenedtoolsfield, following the same pattern asdefaults). - Add
groupsfield to bothToolConfigandToolsDefaultsConfig, using theGroupMembershipstype. - Update
AssignKeyValue,PartialConfigDelta, andToPartialimplementations forPartialToolsConfigto handle the newgroupsfield. - Validate at config load time: no group/tool name collisions, no references to undefined groups.
Phase 2: Exhaustive validation
- After full config merge, iterate all enabled tools and compute their effective groups (tool-level merged with
*defaults). - For each group with
exhaustive = true, verify every enabled tool is classified (included or excluded). Exit with an error listing unclassified tools if any are found.
Phase 3: CLI integration
- Extend
--tools/--no-toolsname resolution to check groups (unambiguous since group/tool name collisions are a config error). - When a group name is matched, expand to enable/disable all tools with included membership in that group.
- Respect
enablefield variants:explicittools are not activated by group enable;alwaystools are not disabled by group disable. - Add
Enable::ExplicitOrGroupvariant. - Update existing tests in
query_tests.rsto cover group interactions.
References
- Configuration architecture — progressive complexity design principles
MergeableVec(crates/jp_config/src/types/vec.rs) — existing array merge strategies in the config systemcrates/jp_config/src/conversation/tool.rs—ToolConfig,ToolsConfig,ToolsDefaultsConfig,Enablecrates/jp_cli/src/cmd/query.rs—apply_enable_tools,--tools/--no-toolshandling- RFD 042 — tool options (per-tool runtime configuration, complementary feature)