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 (1)
The OpenCode crash at ~140k tokens is worth reproducing outside the agent before blaming the context window. A model's context limit is applied by the client after it has already read the whole tool result, so a full window normally shows up as a client-side truncation or an API error, not a blocked stdio pipe. A silent broken pipe on a big payload looks more like a line-buffer or message-size limit in the client's reader, or the host killing the child process. Pipe the same 140k-token JSON through a ten-line script that reads the server's stdout and see whether it stalls; that tells you whether the fix is truncation or a client bug to report.
Two smaller things in the code:
length / 3is one estimate for three different tokenizers, and the 50-entry cut is fixed, so a 64k agent and a 200k agent get the same slice. Returning a page size derived from the budget plus a cursor and the total count lets the agent ask for the next page instead of guessing what was dropped.Also
AGENT_IDis self-declared in the environment, so it is a routing hint rather than an access control; worth saying so if the profiles are meant to limit what an agent can do.For the tool-subset side I built canli-mcp (
npx -y canli-mcp, github.com/arhancanli/canlicapital, free, MIT): three front tools stand in for 306, 1,109 tokens against 34,508 for listing them all. Did you measure how many tokens the three-tool Grok profile saves against the full list?