feat(routing): per-channel MCP and built-in tool filtering#1378
feat(routing): per-channel MCP and built-in tool filtering#1378nick-stebbings wants to merge 4 commits intonearai:stagingfrom
Conversation
Summary of ChangesHello, 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
Using Gemini Code AssistThe 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
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 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
|
There was a problem hiding this comment.
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.
30bc208 to
350ea37
Compare
82a2d3f to
7114583
Compare
|
Addressed Gemini review feedback in 7114583:
All checks pass (clippy, fmt, compilation). |
6a34799 to
f019316
Compare
ilblackdragon
left a comment
There was a problem hiding this comment.
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
-
MCP server name prefix matching is fragile:
extract_mcp_servercheckstool_name.starts_with("{server}_")by iterating all known servers. If one server name is a prefix of another (e.g.,KitandKitchenAI),Kit_matchesKitchenAI_recipe_searchif checked first. Fix: sort by length descending, or pre-compute a lookup map keyed by longest-prefix match. -
is_dmfalse-positives on channel names starting with "web":DM_PREFIXESincludes"web", so a channel namedweb-team-standupwould bypass all filtering viastarts_with. Use exact matches or require a delimiter. -
default_groupis not validated at load time: Ifdefault_group: "typo"doesn't match any key ingroups, 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.rsis untested).
|
Addressed all review feedback in two commits (caab172, 6cb5d12): Architecture: SettingsStore migration (6cb5d12)
Bug fixes (caab172)
Cleanup
Not yet addressed
@ilblackdragon ready for re-review. |
6cb5d12 to
f9c76b7
Compare
4cc8648 to
22d8832
Compare
|
Addressed review feedback in latest push: Architecture (ilblackdragon's main concern):
Correctness fixes (from previous push, still included):
Rebased onto latest staging ( |
zmanian
left a comment
There was a problem hiding this comment.
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.
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>
22d8832 to
487cfc2
Compare
|
Addressed all three points from @serrrfirat in 487cfc2, rebased onto latest staging ( 1. DM/web bypass aligned with runtime channel names (Medium)
2. Owner scope fixed (High)
3. Hot-reload implemented (Medium)
All checks pass (cargo check, clippy, fmt, 17 channel_routing tests). |
zmanian
left a comment
There was a problem hiding this comment.
Re-Review -- APPROVE
All 3 blockers addressed in 487cfc2:
- DM bypass channel names -- Fixed. Now uses
"gateway"(not"web"), checks Slack metadatachannel.starts_with('D'), checks Telegramchat_type == "private".filter_tool_defsaccepts metadata parameter. - Owner scope -- Fixed. Uses
config.owner_idinstead of"default"at both startup and SIGHUP reload. - 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.
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" }slack-dm,telegram-dm,cli,web) bypass filtering entirelyMCP tools identified by server prefix (
Notion_post_search→ serverNotion). Filtering applied per-iteration in the dispatcher so newly registered tools are always correctly scoped.Test plan
cargo test --libpasses (3162 tests, includes 10 routing-specific tests)cargo clippy --all --all-featurescleancargo fmt --checkclean🤖 Generated with Claude Code