Skip to content

feat(mcp): inject per-user MCP servers from Discord profiles into ACP sessions#330

Closed
Reese-max wants to merge 6 commits into
openabdev:mainfrom
Reese-max:feat/mcp-session-bridge-clean
Closed

feat(mcp): inject per-user MCP servers from Discord profiles into ACP sessions#330
Reese-max wants to merge 6 commits into
openabdev:mainfrom
Reese-max:feat/mcp-session-bridge-clean

Conversation

@Reese-max
Copy link
Copy Markdown

What problem does this solve?

Users can manage MCP servers via Discord slash commands (/mcp-add, /mcp-remove, /mcp-list), but those servers are never actually loaded into ACP sessions. The profile JSON is written but the backend (Claude/Copilot/Gemini/Codex) doesn't read it — making /mcp-add a dead-end UI with no runtime effect.

This PR completes the "UI management → runtime activation" loop.

Closes #

At a Glance

Discord User
  │
  ├── /mcp-add mempalace http://...
  │       ↓
  │   data/mcp-profiles/{bot}/{user_id}.json   ← existing (write-only)
  │       │
  │       ▼
  ├── sends message ──→ discord.rs ──→ pool.get_or_create(thread, mcp_servers)
  │                                         │
  │                            ┌─────────────┘
  │                            ▼
  │                     connection.session_new(cwd, mcp_servers)  ← NEW
  │                            │
  │                            ▼
  │                     ACP JSON-RPC: session/new
  │                     { "cwd": "...", "mcpServers": [{name, type, url, headers}] }
  │                            │
  │                            ▼
  │                     Backend (Claude/Copilot/Gemini/Codex)
  │                     loads MCP servers into session ✅

Prior Art & Industry Research

OpenClaw:

OpenClaw uses a two-layer merge architecture for MCP:

  • Bundle layer: plugin/skill .mcp.json defaults
  • Global config layer: openclaw.json mcp.servers (overrides bundle)

Both layers are merged at session creation in embedded-pi-mcp.ts, producing a SessionMcpRuntime cached by sessionId + configFingerprint(SHA1). When config changes, stale runtimes are auto-disposed and recreated.

Key differences from our approach:

  • OpenClaw uses a global single config — no per-user/per-channel separation. Anyone with owner+admin scope writes to the same namespace.
  • For CLI backends (Claude Code, Codex), OpenClaw injects MCP via --mcp-config CLI args (injectClaudeMcpConfigArgs()), not via ACP session/new.
  • Source: src/agents/embedded-pi-mcp.ts, src/agents/pi-bundle-mcp-runtime.ts, src/config/mcp-config.ts

Hermes Agent:

Hermes Agent has first-class MCP client support with a dedicated daemon thread running a persistent asyncio event loop per server:

  • Global config: ~/.hermes/config.yaml under mcp_servers key, loaded at startup.
  • ACP session injection: new_session/load_session accept mcp_servers parameter — but registered tools go into the process-wide singleton ToolRegistry, not per-session isolation.
  • Tools are namespaced as mcp_<server>_<tool> and merged into umbrella toolsets.
  • Supports tools/list_changed notifications for live refresh, and /reload-mcp slash command for hot-reload.
  • Security: filtered subprocess env, credential stripping from errors, OSV malware check before spawn.

Key differences:

  • Hermes accepts mcp_servers in ACP session/new (same approach as this PR), but merges into a global registry — no per-user isolation.
  • Hermes has rich CLI management (hermes mcp add with interactive curses wizard), while OpenAB uses Discord slash commands.
  • Source: tools/mcp_tool.py, hermes_cli/mcp_config.py, acp_adapter/server.py

Comparison:

Aspect OpenClaw Hermes Agent OpenAB (this PR)
MCP config scope Global single Global + ACP injection Per-user per-bot
Injection point Config file merge + CLI args ACP session/new params ACP session/new params
User isolation None None (shared registry) Yes (profile per Discord user)
Management UI Chat /mcp set + CLI CLI hermes mcp add Discord /mcp-* slash commands
Hot-reload Config fingerprint change /reload-mcp + notifications New session only

Proposed Solution

Add mcp_servers parameter threading through the session creation path:

  1. config.rs: McpServerEntry struct + read_mcp_profile() reads {mcp_profiles_dir}/{user_id}.json
  2. connection.rs: session_new() and session_load() accept &[serde_json::Value] for mcpServers
  3. pool.rs: get_or_create() accepts and passes through mcp_servers
  4. discord.rs: mcp_servers_for_user() helper reads profile and builds the JSON array; message handler + session-creating commands (/native, /plan, /mcp, /compact) pass user's MCP servers; diagnostic commands (/doctor, /stats, /tokens) pass &[]

Profile JSON format (written by existing /mcp-add):

{
  "discord_user_id": "844236700611379200",
  "mcpServers": {
    "mempalace": { "type": "http", "url": "http://...", "headers": [] }
  },
  "enabled": true
}

Converted to ACP format:

[{ "name": "mempalace", "type": "http", "url": "http://...", "headers": [] }]

Why this approach?

  1. ACP-native injection — all 4 backends already accept mcpServers in session/new (verified via testing). No config file manipulation needed.
  2. Per-user isolation — unlike OpenClaw (global) and Hermes (global registry), our profiles are keyed by Discord user ID, so different users get different MCP servers.
  3. Per-bot isolation — each bot has its own mcp_profiles_dir, so CICX and GITX can have different MCP configurations.
  4. Graceful fallbackread_mcp_profile() returns empty vec on any error. Backends that don't support mcpServers simply ignore the parameter (empty array is always valid).
  5. Comprehensive review — 7 rounds of Codex CLI review with all P1 findings fixed. Security (allowlists), correctness (permission protocol), and portability (no hardcoded paths) all addressed.

Alternatives Considered

  1. Config file manipulation (like OpenClaw's injectClaudeMcpConfigArgs): Rejected — modifying ~/.claude.json or ~/.copilot/mcp-config.json is fragile, backend-specific, and risks breaking user's existing config.

  2. Global singleton registry (like Hermes): Rejected — OpenAB runs as a Discord bot where multiple users share the same process. Global MCP would leak one user's tools to another.

  3. Hot-reload within session: Deferred — would require session/update or custom RPC. Current approach (effective on next session) is simpler and matches both OpenClaw and Hermes behavior.

Validation

  • cargo check passes
  • cargo build --release — 0 errors, 2 pre-existing warnings
  • cargo test — 31 passed, 0 failed
  • cargo fmt — all files formatted
  • cargo clippy — 0 new warnings
  • Format verification: all 4 backends (Claude, Copilot, Gemini, Codex) accept [{name, type:"http", url, headers:[]}] in session/new
  • E2E test: Claude ACP — profile → session/new(mcpServers) → ToolSearch → mempalace_search → GPU data ✅
  • Codex CLI review — 7 rounds, 14 P1 + 13 P2 findings. Fixed 14 P1 + 10 P2. Remaining 3 P2 are architectural known-limitations (not regressions)
  • Test scripts: scripts/test-mcp-acp-v3.js (format matrix), scripts/test-e2e-final.js (end-to-end)

Additional fixes from Codex review (not in original scope)

Fix Impact
Restored upstream pick_best_option + build_permission_response for ACP permissions Correct tool approval for all backends
kill_on_drop(true) on ACP child processes Windows subprocess cleanup
Copilot SDK/CLI paths dynamically resolved Works on any machine, any Copilot version
copilot_rpc_script_path() resolves exe-relative Removes all hardcoded local paths from Rust
Copilot bridge merges ACP request mcpServers with file-based config Per-user MCP works for CopilotBridge
All slash commands gated with copilot_guard_ok() Channel/user allowlist enforced everywhere
cleanup_idle uses saturating_duration_since Prevents Instant underflow panic on Windows reboot
Native command caching via tokio::spawn No 2s latency on first message
Copilot model refresh gated on has_copilot_rpc() Non-Copilot bots keep correct model cache
session_load() parses model metadata /model works in resumed sessions
E2E test output (Claude ACP)
=== E2E: claude | 1 MCP server(s) ===

1. Init OK
2. Session: f58571ab-9c90-47
3. Waited 8s for MCP
4. Prompting: "search mempalace for GPU"...
5. Prompt accepted (response id=3)

6. Analysis:
   Notifications received: 23
   Contains 'mempalace': true
   Contains GPU/VRAM: true
   [tool_call] ToolSearch → mcp__plugin_mempalace_mempalace__mempalace_search
   [tool_call] mempalace_search(query: "GPU")
   [agent_message] GPU: NVIDIA RTX 2000 Ada Laptop... irisx-gpu-switch...

>>> MCP BRIDGE FULLY WORKING ✅ <<<
Format compatibility matrix (all 4 backends)
=== claude ===
array-named-http:  OK ✅
array-named-cmd:   OK ✅

=== copilot ===
http:              OK ✅  (all formats accepted)
sse:               OK ✅
stdio:             OK ✅

=== gemini ===
array-named-http:  OK ✅
array-named-cmd:   OK ✅

=== codex ===
array-named-http:  OK ✅
array-named-cmd:   OK ✅

… sessions

When a user adds MCP servers via /mcp-add, the servers are now automatically
injected into ACP session/new and session/load calls. This completes the
"UI management → runtime activation" loop.

Changes:
- config.rs: add McpServerEntry struct + read_mcp_profile() + mcp_profiles_dir config
- connection.rs: session_new/session_load accept mcp_servers parameter + cfg(unix) guards
- pool.rs: get_or_create passes mcp_servers through + saturating_duration_since fix
- discord.rs: mcp_servers_for_user() reads profile dir, message handler injects
- main.rs: handler gets mcp_profiles_dir

Verified: all 4 backends (Claude, Copilot, Gemini, Codex) accept the unified
[{name, type:"http", url, headers:[]}] format. E2E tested with Claude ACP.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@Reese-max Reese-max requested a review from thepagent as a code owner April 14, 2026 12:59
Copilot AI review requested due to automatic review settings April 14, 2026 12:59
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR completes MCP activation by loading per-user MCP server profiles from disk and injecting them into ACP session/new and session/load calls when Discord messages create/resume sessions.

Changes:

  • Added read_mcp_profile() and new config field mcp_profiles_dir to load per-user MCP server entries from {dir}/{discord_user_id}.json.
  • Threaded mcpServers through the ACP session creation/resume path (discord.rsSessionPool::get_or_createAcpConnection::{session_new,session_load}).
  • Assorted formatting/readability refactors and minor improvements (e.g., safer idle cleanup duration arithmetic).

Reviewed changes

Copilot reviewed 13 out of 13 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
src/config.rs Adds MCP profile loading helper + new config fields (soul_file, mcp_profiles_dir).
src/discord.rs Loads per-user MCP servers and passes them into the session pool when handling messages.
src/acp/pool.rs Accepts mcp_servers in get_or_create and forwards into session creation/resume.
src/acp/connection.rs Adds mcpServers parameter injection to session/new and session/load; adjusts process-group kill behavior by platform.
src/main.rs Passes mcp_profiles_dir from config into the Discord handler.
src/acp/protocol.rs Formatting-only changes.
src/acp/mod.rs Reorders re-export (no functional change).
src/reactions.rs Formatting-only changes.
src/error_display.rs Formatting-only changes.
src/stt.rs Formatting-only changes.
src/setup/wizard.rs Formatting-only changes.
src/setup/validate.rs Formatting-only changes.
src/setup/config.rs Formatting-only changes (including test formatting).

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/discord.rs Outdated
let Some(ref dir) = self.mcp_profiles_dir else {
return vec![];
};
let entries = read_mcp_profile(dir, &user_id.to_string());
Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

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

mcp_servers_for_user() calls read_mcp_profile() (which uses std::fs::read_to_string) from inside the async Discord event handler. This is blocking filesystem I/O on the Tokio runtime thread, and it will also run on every message (even when the session already exists), which can add latency under load. Consider switching the profile read to async (tokio::fs) or wrapping it in spawn_blocking, and/or only loading the profile when SessionPool::get_or_create actually needs to create/resume a session (so existing sessions avoid disk reads).

Suggested change
let entries = read_mcp_profile(dir, &user_id.to_string());
let entries = tokio::task::block_in_place(|| read_mcp_profile(dir, &user_id.to_string()));

Copilot uses AI. Check for mistakes.
Comment thread src/discord.rs Outdated
Comment on lines +60 to +64
tracing::info!(
user_id,
count = entries.len(),
"injecting MCP servers from profile"
);
Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

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

This logs at info level every time a user has any MCP servers configured. Since mcp_servers_for_user() is called per message, this will spam logs and make it harder to spot real issues. Consider logging only when a new session is actually created/resumed, and/or lowering this to debug with optional rate limiting.

Copilot uses AI. Check for mistakes.
Comment thread src/discord.rs Outdated
Comment on lines +67 to +72
.map(|e| {
let mut obj = e.config.clone();
if let Some(map) = obj.as_object_mut() {
map.insert("name".to_string(), serde_json::Value::String(e.name));
}
obj
Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

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

mcp_servers_for_user() only injects the name field if the profile entry is a JSON object (as_object_mut()). If the stored value is not an object (corrupted profile, partial write, unexpected format), this will forward an invalid mcpServers entry without a name, which can cause backend/session creation failures. Consider validating that each entry is an object before including it (skip + warn), or normalizing invalid entries to an object so name is always present.

Copilot uses AI. Check for mistakes.
Comment thread src/discord.rs Outdated
Comment on lines +319 to +321
let thread_key = thread_id.to_string();
if let Err(e) = self.pool.get_or_create(&thread_key).await {
let mcp_servers = self.mcp_servers_for_user(msg.author.id.get());
if let Err(e) = self.pool.get_or_create(&thread_key, &mcp_servers).await {
Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

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

Sessions are keyed only by thread_key (thread ID), but the MCP servers are derived from msg.author.id. If multiple allowed users post in the same thread, they will share the same underlying ACP session (and whichever user's MCP servers were used when the session was created/resumed), which breaks the stated per-user isolation and can leak one user's MCP tools/headers to another user in that thread. To preserve per-user isolation, consider including user_id in the pool key (e.g., thread_id:user_id), or enforcing thread ownership so only the original author can interact with the thread/session.

Copilot uses AI. Check for mistakes.
Comment thread src/acp/connection.rs Outdated
- mcp_servers_for_user() now async with spawn_blocking (no blocking I/O on Tokio)
- Log level downgraded info → debug (avoid per-message log spam)
- Non-object MCP profile entries skipped with warning (validation)
- kill_on_drop(true) added to ACP child spawn (Windows cleanup)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@Reese-max Reese-max force-pushed the feat/mcp-session-bridge-clean branch from 1eff189 to cd5d061 Compare April 14, 2026 15:49
@Reese-max Reese-max force-pushed the feat/mcp-session-bridge-clean branch from cd5d061 to 15eec51 Compare April 14, 2026 15:58
@chaodu-agent
Copy link
Copy Markdown
Collaborator

Closing as not planned. PR 範圍遠超出標題所述的 MCP injection,包含大量未經獨立討論的新功能(20+ slash commands、usage framework、backend type detection 等)。建議拆成多個 focused PRs 分別提交。

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants