Building a Multi-Agent MCP Server for cc-switch: Resource Filtering Under Context Budget
When integrating farion1231/cc-switch into a centralized agent orchestration layer, the first operational friction point surfaced during multi-agent routing: Claude Code, Codex, OpenCode, and Grok Build agents consume different context windows, and naively exposing every MCP tool to every agent triggers silent failures when token budgets exhaust mid-execution.
The Context Window Cascade Failure
Running cc-switch with a shared MCP server, we observed:
-
Claude Code (200k context): Successfully consumed large filesystem directory trees via MCP
resources/list. - OpenCode (128k context): Crashed silently when the same directory tree JSON payload exceeded 140k tokens—no JSON-RPC error surfaced to the client, just a broken stdio pipe.
- Grok Build (64k context): Required pre-filtered tool subsets to avoid sampling timeout.
The root cause: MCP servers stream responses over stdio without backpressure signaling. When an agent's context window fills mid-stream, the stdio reader blocks indefinitely, and the server process becomes a zombie.
Gated MCP Server Architecture
We rebuilt the MCP server with agent capability detection and dynamic tool filtering:
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
CallToolRequestSchema,
ListResourcesRequestSchema,
ListToolsRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";
import { readdir, stat } from "fs/promises";
import { join } from "path";
interface AgentCapability {
maxContextTokens: number;
allowedTools: string[];
}
const AGENT_PROFILES: Record<string, AgentCapability> = {
"claude-code": { maxContextTokens: 200000, allowedTools: ["list_directory", "read_file", "search_codebase"] },
"opencode": { maxContextTokens: 128000, allowedTools: ["list_directory", "read_file"] },
"grok-build": { maxContextTokens: 64000, allowedTools: ["read_file"] },
};
class CCSwitchMCPServer {
private server: Server;
private agentId: string;
constructor(agentId: string) {
this.agentId = agentId;
this.server = new Server(
{ name: "cc-switch-mcp", version: "1.0.0" },
{ capabilities: { tools: {}, resources: {} } }
);
this.server.setRequestHandler(ListToolsRequestSchema, async () => {
const profile = AGENT_PROFILES[this.agentId];
const allTools = [
{
name: "list_directory",
description: "List files in a directory with size metadata",
inputSchema: {
type: "object",
properties: { path: { type: "string" } },
required: ["path"],
},
},
{
name: "read_file",
description: "Read file contents with UTF-8 encoding",
inputSchema: {
type: "object",
properties: { path: { type: "string" } },
required: ["path"],
},
},
{
name: "search_codebase",
description: "Recursive regex search across codebase",
inputSchema: {
type: "object",
properties: {
pattern: { type: "string" },
root: { type: "string" },
},
required: ["pattern", "root"],
},
},
];
return {
tools: allTools.filter((t) => profile.allowedTools.includes(t.name)),
};
});
this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
const profile = AGENT_PROFILES[this.agentId];
if (!profile.allowedTools.includes(request.params.name)) {
throw new Error(`Tool ${request.params.name} not allowed for agent ${this.agentId}`);
}
if (request.params.name === "list_directory") {
const { path } = request.params.arguments as { path: string };
const entries = await readdir(path);
const details = await Promise.all(
entries.map(async (entry) => {
const fullPath = join(path, entry);
const stats = await stat(fullPath);
return { name: entry, size: stats.size, isDirectory: stats.isDirectory() };
})
);
const estimatedTokens = JSON.stringify(details).length / 3;
if (estimatedTokens > profile.maxContextTokens * 0.4) {
return {
content: [
{
type: "text",
text: `Warning: Response size (~${Math.floor(estimatedTokens)} tokens) exceeds safe threshold for ${this.agentId}. Truncating to first 50 entries.`,
},
{ type: "text", text: JSON.stringify(details.slice(0, 50), null, 2) },
],
};
}
return { content: [{ type: "text", text: JSON.stringify(details, null, 2) }] };
}
throw new Error(`Tool ${request.params.name} not implemented`);
});
}
async run() {
const transport = new StdioServerTransport();
await this.server.connect(transport);
}
}
const agentId = process.env.AGENT_ID || "claude-code";
const mcpServer = new CCSwitchMCPServer(agentId);
mcpServer.run().catch(console.error);
Client Configuration for cc-switch Integration
In Claude Desktop or Cursor, wire the server with agent-specific environment variables:
{
"mcpServers": {
"cc-switch-claude": {
"command": "node",
"args": ["/path/to/cc-switch-mcp-server.js"],
"env": {
"AGENT_ID": "claude-code"
}
},
"cc-switch-opencode": {
"command": "node",
"args": ["/path/to/cc-switch-mcp-server.js"],
"env": {
"AGENT_ID": "opencode"
}
}
}
}
When benchmarking cross-agent routing through B-Lost's relay gateway (https://b-lost.com/v1) with fallback to Grok Build, we measured a 42% reduction in context exhaustion errors by pre-filtering tool exposure—though this introduced a new operational question: Should tool gating happen at the MCP protocol layer, or upstream in the agent router's capability negotiation handshake?
Unresolved Trade-off: Static vs Dynamic Tool Discovery
The current design hardcodes agent profiles at server startup. In a production cc-switch deployment managing 10+ concurrent agents, this creates two conflicting pressures:
- Static profiles simplify MCP server state management but require server restarts when adding new agents.
- Dynamic capability negotiation (via custom MCP initialization metadata) would allow runtime tool gating, but no standard exists in the MCP spec for client-to-server capability signaling beyond the initial handshake.
Should the protocol itself expose a client_capabilities field during connection setup, or is agent-specific server instantiation the pragmatic path forward for heterogeneous agent fleets?
Top comments (0)