Skip to content

feat(routing): per-channel MCP and built-in tool filtering#1378

Open
nick-stebbings wants to merge 4 commits intonearai:stagingfrom
nick-stebbings:pr/channel-routing
Open

feat(routing): per-channel MCP and built-in tool filtering#1378
nick-stebbings wants to merge 4 commits intonearai:stagingfrom
nick-stebbings:pr/channel-routing

Conversation

@nick-stebbings
Copy link
Copy Markdown
Contributor

Summary

Add a JSON-configurable channel routing system that filters which MCP servers and built-in tools are offered to the LLM based on the incoming message channel.

Motivation

Multi-channel deployments (Slack + Telegram + web) need per-channel tool scoping. A research channel should only see research MCP tools, not production APIs. DMs should have broader access than shared channels.

How it works

Configuration at ~/.ironclaw/channel-routing.json:

{
  "groups": {
    "minimal": ["Archon"],
    "content": ["Archon", "Notion", "Kit"],
    "dev":     ["Archon", "Kiro", "Notion"]
  },
  "builtin_whitelist": {
    "content": ["memory_search", "create_job", "http_request"]
  },
  "channels": {
    "agentiffai-content": "content",
    "agentiffai-dev":     "dev"
  },
  "default_group": "minimal"
}
  • groups: named sets of allowed MCP server prefixes
  • builtin_whitelist: per-group allowlist of built-in tools (absent = all allowed)
  • channels: channel name/ID → group mapping
  • default_group: fallback for unmapped channels
  • DM channels (slack-dm, telegram-dm, cli, web) bypass filtering entirely

MCP tools identified by server prefix (Notion_post_search → server Notion). Filtering applied per-iteration in the dispatcher so newly registered tools are always correctly scoped.

Test plan

  • cargo test --lib passes (3162 tests, includes 10 routing-specific tests)
  • cargo clippy --all --all-features clean
  • cargo fmt --check clean
  • Manual: message in mapped channel → tools filtered to group
  • Manual: DM → all tools available (bypass)
  • Manual: unmapped channel → default_group applied

🤖 Generated with Claude Code

@github-actions github-actions bot added scope: agent Agent core (agent loop, router, scheduler) size: L 200-499 changed lines risk: medium Business logic, config, or moderate-risk modules contributor: experienced 6-19 merged PRs labels Mar 18, 2026
@gemini-code-assist
Copy link
Copy Markdown
Contributor

Summary of Changes

Hello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request introduces a robust and configurable system for managing tool access within multi-channel deployments. By allowing administrators to define which MCP servers and built-in tools are available to the LLM agent based on the originating channel, it significantly enhances the agent's contextual awareness and security. This prevents unintended tool usage across different communication contexts, such as restricting production APIs from research channels, and provides a flexible mechanism for tailoring the agent's capabilities to specific channel requirements.

Highlights

  • JSON-configurable Channel Routing: Introduced a new system that allows configuring tool access based on the incoming message channel via a channel-routing.json file.
  • Per-Channel Tool Filtering: Implemented filtering for both MCP (Multi-Channel Platform) server tools and built-in tools, allowing specific tools to be available only in designated channels or groups.
  • Flexible Group Definitions: Enables defining named groups of allowed MCP server prefixes and whitelists for built-in tools, which channels can then be mapped to.
  • Direct Message (DM) Bypass: Direct message channels (e.g., Slack DMs, Telegram DMs, CLI, web) are explicitly configured to bypass all tool filtering, granting full access to all available tools.
  • Dynamic Filtering in Agent Loop: The tool filtering logic is applied dynamically at each iteration within the agent dispatcher, ensuring that newly registered tools are always correctly scoped according to the channel's configuration.
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a channel routing system that filters MCP servers and built-in tools based on the incoming message channel, configurable via a JSON file. The changes include adding a new channel_routing.rs file, modifying agent_loop.rs and dispatcher.rs to incorporate the routing logic, and updating tests to account for the new functionality. The review focuses on correctness, maintainability, security, and performance, with suggestions for improved restrictiveness, efficiency, and consistent application of routing logic.

@github-actions github-actions bot added scope: docs Documentation scope: dependencies Dependency updates labels Mar 19, 2026
@nick-stebbings nick-stebbings force-pushed the pr/channel-routing branch 2 times, most recently from 82a2d3f to 7114583 Compare March 19, 2026 21:21
@nick-stebbings
Copy link
Copy Markdown
Contributor Author

Addressed Gemini review feedback in 7114583:

  • Restrictive default when group not found: Changed from allowing all tools to blocking all MCP tools (only built-in tools pass through). Misconfigured groups now fail safe.
  • No panics in production code: Verified — all unwrap() calls are inside #[cfg(test)] test module.
  • Test files updated: Added channel_routing: None to all test AgentDeps initializers that conflicted during rebase.

All checks pass (clippy, fmt, compilation).

Copy link
Copy Markdown
Member

@ilblackdragon ilblackdragon left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: per-channel tool routing

The feature concept is sound — per-channel tool scoping is valuable for multi-channel deployments. However, the implementation has architectural and correctness issues that should be addressed before merge.

Architecture: config should live in the database, not a JSON file

The routing config is loaded from ~/.ironclaw/channel-routing.json once at startup. This means:

  • No hot-reload: Changes require a full restart. Operators iterating on channel routing (the common case when setting this up) have a painful feedback loop.
  • No web UI: Can't be configured through the settings page like other IronClaw settings. Every other user-facing configuration uses the database-backed settings system.
  • No per-user scoping: The JSON file is global. The database settings system supports per-user settings naturally.

The right approach is to store this in the existing SettingsStore (like other config in src/settings.rs) and expose it through the web settings UI. This gets hot-reload for free — the settings system already handles that. The ChannelRoutingConfig struct and filtering logic are fine; it's just the persistence/loading layer that needs to change.

Correctness issues

  1. MCP server name prefix matching is fragile: extract_mcp_server checks tool_name.starts_with("{server}_") by iterating all known servers. If one server name is a prefix of another (e.g., Kit and KitchenAI), Kit_ matches KitchenAI_recipe_search if checked first. Fix: sort by length descending, or pre-compute a lookup map keyed by longest-prefix match.

  2. is_dm false-positives on channel names starting with "web": DM_PREFIXES includes "web", so a channel named web-team-standup would bypass all filtering via starts_with. Use exact matches or require a delimiter.

  3. default_group is not validated at load time: If default_group: "typo" doesn't match any key in groups, every unmapped channel hits the restrictive fallback with a warning on every LLM iteration. Validate at load time.

Other

  • The version bump (0.19.0 → 0.20.0) and CHANGELOG entries should be in a separate release PR — they reference unrelated PRs and make this harder to cherry-pick or revert.
  • No integration test proving the dispatcher actually uses the filtered tools (unit tests for the config logic are good, but the wiring in dispatcher.rs is untested).

@github-actions github-actions bot added size: XL 500+ changed lines and removed size: L 200-499 changed lines labels Mar 21, 2026
@nick-stebbings
Copy link
Copy Markdown
Contributor Author

Addressed all review feedback in two commits (caab172, 6cb5d12):

Architecture: SettingsStore migration (6cb5d12)

  • ChannelRoutingConfig now loads from SettingsStore database first, falls back to channel-routing.json file
  • AgentDeps.channel_routing changed to Arc<RwLock<Option<...>>> — supports hot-reload via SIGHUP without restart
  • apply_channel_routing is now async (acquires read lock)
  • save_to_store() method added for web UI integration

Bug fixes (caab172)

  1. MCP prefix matching — server names now sorted by length descending. KitchenAI_recipe_search correctly matches KitchenAI, not Kit. Test added.
  2. is_dm false-positivesweb is now exact-match only. web-team-standup no longer bypasses routing. slack-dm-* uses prefix-with-delimiter. Negative test cases added.
  3. default_group validationvalidate() checks at load time that default_group and all channel mappings reference existing groups. Invalid configs return None with error log. Two tests added.

Cleanup

  • Added Serialize derive for DB persistence
  • Added metadata_key field (optional, for compat with existing configs)
  • Version bump / CHANGELOG not included in this PR (as requested)

Not yet addressed

  • Integration test for dispatcher wiring — happy to add if the architecture changes are approved
  • The extract_mcp_server now uses a static helper (extract_mcp_server_from) taking a sorted list, avoiding repeated group iteration

@ilblackdragon ready for re-review.

@nick-stebbings
Copy link
Copy Markdown
Contributor Author

nick-stebbings commented Mar 28, 2026

Addressed review feedback in latest push:

Architecture (ilblackdragon's main concern):

  • Migrated config from channel-routing.json file to SettingsStore (database-backed)
  • Added load_from_store() / save_to_store() on ChannelRoutingConfig
  • No file fallback — SettingsStore is the single source of truth
  • Changed AgentDeps.channel_routing to Arc<RwLock<Option<...>>> for hot-reload support
  • apply_channel_routing is now async to support RwLock reads

Correctness fixes (from previous push, still included):

  1. Restrictive default when group not found — blocks all MCP tools (fail-safe)
  2. Load-time validationdefault_group must exist in groups map
  3. No panics in production code — all unwrap() calls inside #[cfg(test)]

Rebased onto latest staging (8f8cb7f7).

Copy link
Copy Markdown
Collaborator

@zmanian zmanian left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review -- REQUEST_CHANGES

The filtering logic and test coverage for the config module are solid. However, three blockers:

HIGH

1. is_dm() bypass uses wrong channel names -- Checks for "web", "slack-dm", "telegram-dm" but none match runtime names. Web chat uses "gateway", Slack uses "slack-relay" (DMs distinguished by metadata, not channel name), and no "telegram-dm" channel exists. The DM bypass never fires, so routing restrictions are applied to DMs (opposite of documented intent).

Fix: Check "gateway", "cli", "repl" as exact matches. For relay DMs, check metadata.event_type == "direct_message" (may need to pass metadata into filter_tool_defs).

2. Startup loads from "default" scope instead of config.owner_id -- ChannelRoutingConfig::load_from_store(db.as_ref(), "default") but SettingsStore is user-scoped. Any deployment where owner_id != "default" silently fails to load routing config, leaving all tools exposed.

Fix: Use config.owner_id.

3. No actual hot-reload -- AgentDeps.channel_routing is Arc<RwLock<...>> claiming hot-reload support, but the RwLock is only written at startup. SIGHUP handler doesn't refresh it. save_to_store() exists but is never called.

Fix: Either wire up SIGHUP refresh, or remove the RwLock / hot-reload claim until implemented.

Medium

4. Filtering only covers tool definitions, not execution -- Tools are hidden from the LLM but execute_tool doesn't re-verify channel permission. A hallucinated or injected tool name would still execute. Consider adding a secondary check in execute_tool_calls.

5. MCP server detection relies on tool_name.starts_with("{server}_") -- Fragile naming convention. A built-in tool named Archon_something would be misclassified. ToolDefinition should ideally carry provenance metadata.

nick-stebbings and others added 4 commits March 31, 2026 23:11
Add a JSON-configurable channel routing system that filters which MCP
servers and built-in tools are offered to the LLM based on the incoming
message channel.

Configuration at ~/.ironclaw/channel-routing.json:
- groups: named sets of allowed MCP server prefixes
- builtin_whitelist: per-group explicit allowlist of built-in tools
  (absent = all built-ins allowed for that group)
- channels: channel name/ID → group mapping
- default_group: fallback for unmapped channels

MCP tools are identified by server prefix (Notion_post_search → Notion).
Built-ins without a prefix are filtered via the optional whitelist.
DM channels (slack-dm, telegram-dm, cli, web) bypass filtering entirely.

Applied per-iteration in dispatcher.rs so newly registered tools are
always correctly scoped. ChannelRoutingConfig loaded at startup via
AgentDeps.channel_routing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Fix MCP server prefix matching: sort by length desc to avoid Kit/KitchenAI collision
- Fix is_dm false-positives: exact match for 'web'/'cli'/'repl', prefix-with-delimiter for DMs
- Add config validation: default_group and channel mappings checked at load time
- Add Serialize derive for future SettingsStore migration
- Add metadata_key field for compat with existing configs
- 7 new tests for prefix matching, DM edge cases, and validation
- Add load_from_store/save_to_store for database-backed config
- Change AgentDeps.channel_routing to Arc<RwLock<Option<...>>> for hot-reload
- Make apply_channel_routing async to support RwLock reads
- Load from DB only (no file fallback) — SettingsStore is the single source
- Update all test files for new RwLock type

Architecture change requested by ilblackdragon: config now uses the
SettingsStore system, enabling web UI configuration and hot-reload
without restarts. File-based channel-routing.json removed from the
loading path; use the settings API to configure routing.
1. DM/web bypass aligned with runtime channel names:
   - "web" → "gateway" (actual web chat channel name)
   - Removed "slack-dm-*" prefix — Slack DMs arrive on "slack" channel
     with metadata.channel starting with 'D'
   - Added Telegram DM detection via metadata.chat_type == "private"
   - is_dm() and filter_tool_defs() now accept metadata parameter

2. Owner scope: load channel_routing from config.owner_id instead of
   hardcoded "default" — non-default deployments now get routing.

3. Hot-reload: SIGHUP handler now reloads channel_routing from
   SettingsStore. Arc<RwLock> shared between AgentDeps and handler.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@nick-stebbings
Copy link
Copy Markdown
Contributor Author

Addressed all three points from @serrrfirat in 487cfc2, rebased onto latest staging (b6b3ffa1):

1. DM/web bypass aligned with runtime channel names (Medium)

  • "web""gateway" (actual web chat WASM channel name)
  • Removed "slack-dm" / "slack-dm-*" — Slack DMs arrive on the "slack" channel with metadata.channel starting with D (Slack convention for direct messages)
  • Added Telegram DM detection via metadata.chat_type == "private"
  • API change: is_dm() and filter_tool_defs() now accept metadata: &serde_json::Value to support relay-channel DM detection
  • 8 new test assertions covering gateway, Slack DM vs channel, Telegram DM vs group

2. Owner scope fixed (High)

  • Changed hardcoded "default" to config.owner_id at startup. Non-default deployments now correctly load their channel routing config.

3. Hot-reload implemented (Medium)

  • channel_routing_arc (Arc<RwLock>) shared between AgentDeps and the SIGHUP handler
  • SIGHUP handler now calls load_from_store() with the correct owner ID and writes the new config into the shared RwLock
  • Subsequent dispatcher turns see the updated routing policy without restart

All checks pass (cargo check, clippy, fmt, 17 channel_routing tests).

Copy link
Copy Markdown
Collaborator

@zmanian zmanian left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-Review -- APPROVE

All 3 blockers addressed in 487cfc2:

  1. DM bypass channel names -- Fixed. Now uses "gateway" (not "web"), checks Slack metadata channel.starts_with('D'), checks Telegram chat_type == "private". filter_tool_defs accepts metadata parameter.
  2. Owner scope -- Fixed. Uses config.owner_id instead of "default" at both startup and SIGHUP reload.
  3. Hot-reload -- Fixed. SIGHUP handler now loads fresh config from DB, acquires write lock, and replaces the value. Complete reload path.

CI green across all configurations.

Minor note (non-blocking)

The changed detection (new_routing.is_some() != guard.is_some()) only detects presence/absence, not content changes. The new config is written regardless, but the log message may say "unchanged" when content actually changed.

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

Labels

contributor: experienced 6-19 merged PRs risk: medium Business logic, config, or moderate-risk modules scope: agent Agent core (agent loop, router, scheduler) scope: dependencies Dependency updates scope: docs Documentation size: XL 500+ changed lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants