DEV Community

Cover image for Building a Multi-Agent MCP Server for cc-switch: Resource Filtering Under Context Budget
Lucas Vance
Lucas Vance

Posted on

Building a Multi-Agent MCP Server for cc-switch: Resource Filtering Under Context Budget

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);
Enter fullscreen mode Exit fullscreen mode

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"
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. Static profiles simplify MCP server state management but require server restarts when adding new agents.
  2. 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)

Collapse
 
arhancanli profile image
Arhan Canli •

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 / 3 is 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_ID is 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?