v1.15.0-mcp-multi: multi-server MCP client + stdio transport + config file + tool globs
Generalizes the v1.14.1 single-server Context7 PoC into a multi-server MCP client registry with per-server graceful degradation. JSON config at /data/mcp.json (bind-mounted alongside AGENTS.md) matches opencode's mcpServers schema shape. Config file missing = no MCP (opt-in by presence). Two transports: Streamable HTTP (remote servers like Context7) and stdio (local subprocess servers like codecontext). Stdio spawns a persistent child via the SDK's StdioClientTransport; shutdown hook closes all transports. Tool prefix generalized from context7_<name> to <serverName>_<toolName> with a toolToServer reverse map for dispatch routing. AGENTS.md tools: field now supports glob patterns (context7_*, !web_*) via matchToolGlob — last-match- wins with ! deny prefix. Replaces exact-match .includes() in stream-phase.ts. refreshToolNames() in agents.ts rebuilds the DEFAULT_TOOLS snapshot after appendMcpTools so agents without explicit tools: lists see MCP tools — reviewer caught that the module-load-time snapshot would permanently exclude late-registered tools. Read-only invariant: readOnlyHint === false rejected at discovery. Result size capped at 5MB. v1.14.1 env vars removed — superseded by config file. Default data/mcp.json ships with Context7 disabled. 363/363 server tests passing. No schema changes, no frontend changes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
59
openspec/changes/v1.15-mcp-multi/design.md
Normal file
59
openspec/changes/v1.15-mcp-multi/design.md
Normal file
@@ -0,0 +1,59 @@
|
||||
# v1.15.0-mcp-multi — design decisions
|
||||
|
||||
## D1. Config file path
|
||||
|
||||
`/data/mcp.json` (alongside `AGENTS.md` at `/data/AGENTS.md`). Both are bind-mounted from the host's `data/` directory. Override via `MCP_CONFIG_PATH` env var.
|
||||
|
||||
File missing = no MCP (opt-in by file presence, not by env var). Simpler than the v1.14.1 approach of always-defaulting a URL.
|
||||
|
||||
## D2. Config schema matches opencode's `mcpServers` shape
|
||||
|
||||
opencode uses `~/.opencode/config.json` with a `mcpServers` key. BooCode uses `mcp.json` with the same `mcpServers` key so server entries are copy-pasteable. Property names match: `type`, `url`, `command`, `args`, `env`, `headers`. BooCode adds `enabled` (boolean toggle per server, default true) which opencode doesn't have — harmless extra key.
|
||||
|
||||
## D3. Transport types: streamableHttp + stdio only
|
||||
|
||||
- **streamableHttp**: For remote servers (Context7, future cloud MCP services). Uses `@modelcontextprotocol/sdk`'s `StreamableHTTPClientTransport`.
|
||||
- **stdio**: For local subprocess servers (codecontext, future local tools). Uses `@modelcontextprotocol/sdk`'s `StdioClientTransport` (spawns child process, NDJSON framing over stdin/stdout).
|
||||
- **SSE**: Skipped. Streamable HTTP supersedes SSE per the MCP spec (May 2025 protocol update). If a legacy server requires SSE, it can be added later.
|
||||
|
||||
## D4. Tool name prefixing: `<serverName>_<toolName>`
|
||||
|
||||
Generalizes v1.14.1's `context7_<name>` pattern. Server name comes from the config key (e.g. `"context7"`, `"codecontext"`). Collisions between servers with the same name are impossible (config keys are unique). Collisions between an MCP tool and a native tool are possible if someone names a server entry the same as a native tool prefix — but that's a user-configuration error, not a code bug.
|
||||
|
||||
## D5. Per-agent glob patterns: last-match-wins
|
||||
|
||||
AGENTS.md `tools:` field already supports exact-match arrays. Globs extend the same field:
|
||||
|
||||
```yaml
|
||||
tools: [view_file, grep, context7_*]
|
||||
```
|
||||
|
||||
Evaluation: for each tool in `ALL_TOOLS`, scan the pattern list left-to-right. A `!` prefix denies. Last matching pattern wins. This matches the roadmap's "wildcard rule matcher" language.
|
||||
|
||||
Examples:
|
||||
- `[*]` — all tools (same as omitting `tools:` entirely)
|
||||
- `[*, !web_*]` — all tools except web
|
||||
- `[view_file, grep, context7_*]` — only view_file, grep, and all Context7 tools
|
||||
- `[*]` on Architect + `[view_file]` on Prompt Builder — each agent gets its intended scope
|
||||
|
||||
Globs use a simple `minimatch`-style check: `*` matches any characters. No `?` or `**` — tool names are flat (no path separators).
|
||||
|
||||
## D6. No DB tables in v1.15
|
||||
|
||||
The roadmap listed `permissions`, `agent_permissions`, `session_permissions`, `mcp_servers` tables. All deferred to v2.0:
|
||||
|
||||
- **Permission tables**: Enterprise multi-user pattern. BooChat is single-user behind Authelia. The read-only invariant guard is the BooChat-era defense. Formal permission rulesets land when BooCoder adds write tools.
|
||||
- **`mcp_servers` table**: In-memory registry is sufficient. No need to persist server state to DB when the config file is the source of truth and tools are re-discovered on every boot.
|
||||
|
||||
## D7. Stdio child lifecycle
|
||||
|
||||
- Spawn on `initialize()`. Persistent connection for server lifetime (not per-call).
|
||||
- On child exit (unexpected): mark server unavailable, log error. Do NOT auto-restart. BooCode continues with remaining servers.
|
||||
- On BooCode shutdown (`app.addHook('onClose')`): send SIGTERM to all stdio children. Wait up to 5s, then SIGKILL.
|
||||
- On ENOENT (command not found): skip server with a warning. Matches the graceful-degradation pattern from v1.14.1.
|
||||
|
||||
## D8. v1.14.1 env vars removed
|
||||
|
||||
`MCP_CONTEXT7_URL` and `MCP_CONTEXT7_API_KEY` are deleted from `config.ts`. They're superseded by the JSON config file's `context7` entry. The PoC was explicitly designed as throwaway.
|
||||
|
||||
Migration path for anyone who had the env vars set: add a `data/mcp.json` with the Context7 entry. The CHANGELOG entry will note this.
|
||||
130
openspec/changes/v1.15-mcp-multi/proposal.md
Normal file
130
openspec/changes/v1.15-mcp-multi/proposal.md
Normal file
@@ -0,0 +1,130 @@
|
||||
# v1.15.0-mcp-multi — multi-server MCP client + stdio transport + config file
|
||||
|
||||
Generalize the v1.14.1 single-server Context7 PoC into a multi-server MCP client. Add stdio transport (for local subprocess MCP servers like codecontext). JSON config file matching opencode's schema shape. Per-agent tool glob patterns in AGENTS.md frontmatter.
|
||||
|
||||
## Why
|
||||
|
||||
v1.14.1 proved the MCP loop works end-to-end but is hardcoded to one server (Context7) via env vars. Real value comes from multiple servers: Context7 for docs, codecontext re-wired as a proper MCP server (stdio), future local tools. The config shape should match opencode's so Sam can copy `mcp` blocks between the two without translation.
|
||||
|
||||
## Scope
|
||||
|
||||
### S1. JSON config file for MCP servers
|
||||
|
||||
New file at `/data/mcp.json` (bind-mounted like `AGENTS.md`). Env var `MCP_CONFIG_PATH` points to it (default `/data/mcp.json`).
|
||||
|
||||
Schema (matching opencode's shape):
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"context7": {
|
||||
"type": "streamableHttp",
|
||||
"url": "https://mcp.context7.com/mcp",
|
||||
"headers": { "X-API-Key": "optional-key" },
|
||||
"enabled": true
|
||||
},
|
||||
"codecontext": {
|
||||
"type": "stdio",
|
||||
"command": "/usr/local/bin/codecontext",
|
||||
"args": ["--mcp"],
|
||||
"env": { "WORKSPACE": "/opt" },
|
||||
"enabled": false
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Zod-validated at startup. Unknown keys silently ignored (forward-compat). Each server entry has:
|
||||
- `type`: `"streamableHttp"` | `"stdio"` (SSE deferred — Streamable HTTP supersedes it per the MCP spec)
|
||||
- `url` (HTTP) or `command` + `args` + `env` (stdio)
|
||||
- `headers` (HTTP, optional) — for API keys
|
||||
- `enabled` (boolean, default true)
|
||||
|
||||
### S2. Multi-server MCP client
|
||||
|
||||
Refactor `mcp-client.ts` from a singleton to a registry of named MCP clients. On startup:
|
||||
1. Read `/data/mcp.json` (or path from `MCP_CONFIG_PATH`)
|
||||
2. For each enabled server: create a Client + transport, connect, discover tools via `tools/list`
|
||||
3. Wrap tools with `<server-name>_<tool-name>` prefix (generalizes the `context7_` pattern)
|
||||
4. Apply read-only invariant guard per-tool (reject `readOnlyHint: false`)
|
||||
5. Append all MCP tools to `ALL_TOOLS` in a single `appendMcpTools()` call
|
||||
6. Per-server graceful degradation: one server failing doesn't block others
|
||||
|
||||
Expose: `getMcpServers(): McpServerStatus[]` for debug/status endpoint, `callTool(prefixedName, args)` routed to the correct server by prefix.
|
||||
|
||||
### S3. Stdio transport
|
||||
|
||||
For `type: "stdio"` servers: spawn a subprocess via `child_process.spawn(command, args, {env, stdio: 'pipe'})`. Use `@modelcontextprotocol/sdk`'s `StdioClientTransport` (or implement the NDJSON framing ourselves — the SDK should have it). The subprocess runs for the lifetime of the BooCode server (persistent connection, not per-call spawn).
|
||||
|
||||
Child lifecycle:
|
||||
- Spawn on initialize. If spawn fails, log warn, skip server (graceful degradation).
|
||||
- On child exit: log error, mark server as unavailable. Do NOT restart automatically (v1.15 keeps it simple; auto-restart is a v2.0 concern).
|
||||
- On BooCode shutdown (`app.addHook('onClose')`): kill child processes.
|
||||
|
||||
### S4. Per-agent tool glob patterns in AGENTS.md
|
||||
|
||||
Currently `tools:` in AGENTS.md frontmatter is an exact-match whitelist (array of tool names). Extend to support glob patterns via a lightweight matcher:
|
||||
- `context7_*` — all tools from the context7 server
|
||||
- `view_*` — all tools starting with `view_`
|
||||
- `!web_*` — exclude web tools (deny pattern)
|
||||
- Plain names (`grep`, `view_file`) work as before (exact match)
|
||||
|
||||
Evaluation order: for each tool in `ALL_TOOLS`, check if it matches any pattern in the agent's `tools:` list. A `!` prefix means exclude. Last-match-wins.
|
||||
|
||||
Parser change in `agents.ts`: when validating `tools:`, don't reject unknown names if they contain `*` (glob patterns can't be validated against the current tool list since MCP tools are discovered at runtime). Exact names are still validated.
|
||||
|
||||
### S5. Remove v1.14.1 env-var config
|
||||
|
||||
Delete `MCP_CONTEXT7_URL` and `MCP_CONTEXT7_API_KEY` from `config.ts`. They're superseded by the JSON config file. The v1.14.1 PoC is throwaway-by-design (proposal said "throwaway-if-needed").
|
||||
|
||||
### S6. Read-only invariant preserved
|
||||
|
||||
BooChat's read-only guarantee stays: every MCP tool with `readOnlyHint: false` is rejected at discovery. This applies globally, not per-server. Config has no `allowWriteTools` flag — that's a v2.0 BooCoder concern.
|
||||
|
||||
## Deferred to v2.0
|
||||
|
||||
- **Permission ruleset tables** (`permissions`, `agent_permissions`, `session_permissions`). Enterprise pattern that doesn't serve until BooCoder adds write tools. The read-only invariant guard is the BooChat-era defense-in-depth.
|
||||
- **OAuth / Dynamic Client Registration.** Needs secret storage primitive first.
|
||||
- **SSE transport.** Streamable HTTP supersedes it per the MCP spec. SSE is a legacy fallback.
|
||||
- **Per-session MCP toggle.** No `session.mcp_enabled` column in v1.15. MCP servers are globally configured; agent tool globs are the scoping mechanism.
|
||||
- **`mcp_servers` DB table.** In-memory registry is sufficient for single-user. DB tracking deferred to v2.0.
|
||||
- **codecontext re-wiring to MCP.** Separate batch after v1.15 proves stdio transport works.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- No frontend changes. MCP tools surface via the existing tool registry; results render as normal tool-result parts.
|
||||
- No schema changes. No new DB tables or columns.
|
||||
- No changes to the inference loop (v1.14.0 outer loop unchanged).
|
||||
- No changes to `executeToolCall` dispatch (transparent via ToolDef.execute).
|
||||
|
||||
## Hard rules
|
||||
|
||||
- No git commit/push. Sam commits.
|
||||
- Read-only invariant: reject any MCP tool with `readOnlyHint: false`.
|
||||
- Graceful degradation: any server down → that server's tools unavailable, rest unaffected.
|
||||
- Alpha-sort of ALL_TOOLS preserved.
|
||||
- One new dep only: none (MCP SDK already installed from v1.14.1).
|
||||
- 348+ existing tests still pass.
|
||||
|
||||
## Files expected to touch
|
||||
|
||||
- `apps/server/src/services/mcp-client.ts` — refactor from singleton to multi-server registry (~200→300 lines)
|
||||
- `apps/server/src/services/tools.ts` — no changes expected (appendMcpTools already works for multiple tools)
|
||||
- `apps/server/src/config.ts` — replace MCP env vars with `MCP_CONFIG_PATH`
|
||||
- `apps/server/src/index.ts` — startup reads config file, iterates servers
|
||||
- `apps/server/src/services/agents.ts` — glob pattern support in `tools:` whitelist
|
||||
- `data/mcp.json` — NEW, example config with Context7 (disabled by default, enabled via edit)
|
||||
- `apps/server/src/services/__tests__/mcp-client.test.ts` — update for multi-server, add stdio transport tests
|
||||
- `apps/server/src/services/__tests__/agents-glob.test.ts` — NEW, glob pattern matching tests
|
||||
|
||||
## Estimate
|
||||
|
||||
~350 LoC. The MCP SDK handles both transports; BooCode's job is config parsing, multi-server lifecycle, and glob matching.
|
||||
|
||||
## Smoke plan
|
||||
|
||||
1. Create `/data/mcp.json` with Context7 enabled. Restart. Confirm tools discovered + logged.
|
||||
2. Send a chat asking about library docs. Confirm `context7_*` tools called + results rendered.
|
||||
3. Disable Context7 in config (`"enabled": false`). Restart. Confirm zero MCP tools.
|
||||
4. Add a dummy stdio server entry pointing to `/bin/cat` (will fail). Confirm graceful degradation: Context7 works, dummy fails with a logged warning.
|
||||
5. Add `tools: [context7_*]` to the Architect agent in AGENTS.md. Confirm Architect sees only Context7 tools (via AgentPicker or by chatting with Architect selected).
|
||||
6. Stop boocode, confirm child processes are killed (no orphans).
|
||||
87
openspec/changes/v1.15-mcp-multi/tasks.md
Normal file
87
openspec/changes/v1.15-mcp-multi/tasks.md
Normal file
@@ -0,0 +1,87 @@
|
||||
# v1.15.0-mcp-multi tasks
|
||||
|
||||
## B1 — Backups
|
||||
|
||||
- [ ] `mcp-client.ts`, `config.ts`, `index.ts`, `agents.ts`, `mcp-client.test.ts`
|
||||
|
||||
## B2 — MCP config file schema + loader
|
||||
|
||||
- [ ] NEW `apps/server/src/services/mcp-config.ts` (~50 lines)
|
||||
- [ ] Zod schema for `mcp.json`: `McpServerConfig` with `type`, `url/command/args/env`, `headers`, `enabled`
|
||||
- [ ] `loadMcpConfig(configPath: string, log): McpServerConfig[]` — reads JSON, validates, returns enabled servers
|
||||
- [ ] Graceful: file missing → log info, return empty array (no MCP)
|
||||
- [ ] Graceful: parse error → log warn with details, return empty array
|
||||
|
||||
## B3 — Config.ts: replace MCP env vars
|
||||
|
||||
- [ ] Remove `MCP_CONTEXT7_URL` and `MCP_CONTEXT7_API_KEY` from Zod schema
|
||||
- [ ] Add `MCP_CONFIG_PATH: z.string().optional()` (no default — opt-in)
|
||||
|
||||
## B4 — Refactor mcp-client.ts to multi-server registry
|
||||
|
||||
- [ ] Replace module-level singleton with `Map<serverName, {client, transport, tools}>`
|
||||
- [ ] `initialize(servers: McpServerConfig[], log)` — iterate servers, connect each, discover tools, wrap with `<serverName>_<toolName>` prefix, apply read-only guard
|
||||
- [ ] Streamable HTTP transport: reuse existing pattern from v1.14.1
|
||||
- [ ] Stdio transport: use `@modelcontextprotocol/sdk`'s `StdioClientTransport` (check SDK exports; fallback to `child_process.spawn` + NDJSON if SDK doesn't expose it)
|
||||
- [ ] `callTool(prefixedName, args)` — extract server name from prefix, route to correct client
|
||||
- [ ] `getTools()` — return all tools from all servers, flattened
|
||||
- [ ] `getMcpServers()` — return status of each server (name, type, toolCount, connected)
|
||||
- [ ] Per-server graceful degradation: catch per-server errors, log, skip; continue with others
|
||||
- [ ] `shutdown()` — kill stdio child processes, close HTTP clients
|
||||
- [ ] `app.addHook('onClose')` calls shutdown
|
||||
|
||||
## B5 — Startup wiring (index.ts)
|
||||
|
||||
- [ ] Read config: `const mcpConfigPath = config.MCP_CONFIG_PATH ?? '/data/mcp.json'`
|
||||
- [ ] `const mcpServers = loadMcpConfig(mcpConfigPath, app.log)`
|
||||
- [ ] `await mcpClient.initialize(mcpServers, app.log)`
|
||||
- [ ] `appendMcpTools(mcpClient.getTools())`
|
||||
- [ ] Log summary: "mcp: N servers connected, M tools registered"
|
||||
- [ ] `app.addHook('onClose', () => mcpClient.shutdown())`
|
||||
|
||||
## B6 — AGENTS.md glob patterns
|
||||
|
||||
- [ ] `apps/server/src/services/agents.ts` — in tool whitelist validation, skip validation for entries containing `*` (can't validate against runtime-discovered tools)
|
||||
- [ ] NEW helper `matchToolGlob(toolName: string, patterns: string[]): boolean` — supports `*` wildcard and `!` deny prefix, last-match-wins
|
||||
- [ ] Wire into `executeStreamPhase` (stream-phase.ts) where agent tools are filtered: replace exact-match `.includes()` with `matchToolGlob()`
|
||||
- [ ] Export `matchToolGlob` for test access
|
||||
|
||||
## B7 — Example config file
|
||||
|
||||
- [ ] NEW `data/mcp.json` with Context7 entry (enabled: true, with URL, no API key)
|
||||
- [ ] Comment in the file noting it's bind-mounted at `/data/mcp.json` inside the container
|
||||
|
||||
## B8 — Tests
|
||||
|
||||
- [ ] Update `mcp-client.test.ts` for multi-server wrapping (tools from two servers, prefix routing)
|
||||
- [ ] Test: server A fails, server B succeeds — only B's tools registered
|
||||
- [ ] Test: callTool routes to correct server by prefix
|
||||
- [ ] Test: shutdown kills stdio transports
|
||||
- [ ] NEW `apps/server/src/services/__tests__/mcp-glob.test.ts`
|
||||
- [ ] Test: exact match ("grep" matches "grep")
|
||||
- [ ] Test: wildcard ("context7_*" matches "context7_query-docs")
|
||||
- [ ] Test: deny ("!web_*" excludes "web_search")
|
||||
- [ ] Test: last-match-wins ("*" then "!web_*" → web tools excluded)
|
||||
- [ ] Test: empty pattern list → nothing matches (agent gets no tools — same as current behavior for explicit whitelists)
|
||||
|
||||
## B9 — Verification
|
||||
|
||||
- [ ] `npx tsc --noEmit -p apps/server` — 0 errors
|
||||
- [ ] `pnpm -C apps/server test` — all passing
|
||||
- [ ] `pnpm -C apps/web build` — green (no web changes)
|
||||
|
||||
## B10 — Deploy + smoke
|
||||
|
||||
- [ ] Create `/data/mcp.json` on the host with Context7 enabled
|
||||
- [ ] Update docker-compose bind mount if needed (data/ already mounted)
|
||||
- [ ] `docker compose up --build -d`
|
||||
- [ ] Check logs for multi-server init
|
||||
- [ ] Live-smoke: Context7 tool call from chat
|
||||
- [ ] Disable Context7 in config, restart, confirm zero MCP tools
|
||||
|
||||
## B11 — Docs + tag
|
||||
|
||||
- [ ] `CHANGELOG.md` entry
|
||||
- [ ] `boocode_roadmap.md` retrospective bullet on v1.15 section
|
||||
- [ ] `CLAUDE.md` — update MCP references
|
||||
- [ ] Commit, tag `v1.15.0-mcp-multi`, push, rebuild
|
||||
Reference in New Issue
Block a user