AI Dev Tools
·7 min read·framework

TypeScript MCP Agent Framework: A Production Guide

Build a lightweight MCP agent framework in TypeScript using native JSON-RPC and stdio — no bloated SDKs, no framework tax.

Building LLM agents with heavy orchestrators like LangChain or CrewAI often feels like wearing boxing gloves to perform brain surgery. You are forced into rigid abstractions, bloated dependency trees, and proprietary tool definitions. When Anthropic released the Model Context Protocol (MCP), it solved the tool-standardization problem. However, we still lack a lightweight, non-opinionated runtime to orchestrate these tools.

We don't need heavy wrappers. We need a highly composable execution layer that connects LLMs to standardized MCP servers directly, handling tool discovery, execution loops, and state management without the framework tax.

Here is a complete, production-grade implementation of an mcp agent framework typescript orchestrator. This implementation bypasses heavy SDK dependencies, establishing direct JSON-RPC communication over a Node.js subprocess transport to execute tools and manage an agentic loop.

typescript
import { spawn, ChildProcess } from 'child_process';
import readline from 'readline';
import { OpenAI } from 'openai';
 
interface JSONRPCRequest {
  jsonrpc: '2.0';
  id: number;
  method: string;
  params?: any;
}
 
interface JSONRPCResponse {
  jsonrpc: '2.0';
  id: number;
  result?: any;
  error?: { code: number; message: string; data?: any };
}
 
export class MCPServerClient {
  private process: ChildProcess;
  private requestId = 0;
  private pendingRequests = new Map<number, (value: any) => void>();
  private rl: readline.Interface;
 
  constructor(command: string, args: string[] = []) {
    this.process = spawn(command, args, { stdio: ['pipe', 'pipe', 'inherit'] });
    this.rl = readline.createInterface({
      input: this.process.stdout!,
      terminal: false,
    });
 
    this.rl.on('line', (line) => {
      try {
        const response: JSONRPCResponse = JSON.parse(line);
        const resolve = this.pendingRequests.get(response.id);
        if (resolve) {
          this.pendingRequests.delete(response.id);
          resolve(response.result || response.error);
        }
      } catch (err) {
        console.error('Failed to parse MCP response frame:', err);
      }
    });
  }
 
  public async request(method: string, params?: any): Promise<any> {
    return new Promise((resolve) => {
      const id = ++this.requestId;
      const payload: JSONRPCRequest = { jsonrpc: '2.0', id, method, params };
      this.pendingRequests.set(id, resolve);
      this.process.stdin!.write(JSON.stringify(payload) + '\n');
    });
  }
 
  public terminate() {
    this.process.kill();
  }
}
 
export class MCPAgentOrchestrator {
  private openai: OpenAI;
  private mcpClient: MCPServerClient;
 
  constructor(apiKey: string, mcpClient: MCPServerClient) {
    this.openai = new OpenAI({ apiKey });
    this.mcpClient = mcpClient;
  }
 
  public async run(userPrompt: string): Promise<string> {
    const mcpTools = await this.mcpClient.request('tools/list');
    const systemTools = mcpTools.tools.map((tool: any) => ({
      type: 'function' as const,
      function: {
        name: tool.name,
        description: tool.description,
        parameters: tool.inputSchema,
      },
    }));
 
    const messages: any[] = [
      { role: 'system', content: 'You are an autonomous agent utilizing MCP tools.' },
      { role: 'user', content: userPrompt }
    ];
 
    while (true) {
      const response = await this.openai.chat.completions.create({
        model: 'gpt-4o',
        messages,
        tools: systemTools,
      });
 
      const message = response.choices[0].message;
      messages.push(message);
 
      if (!message.tool_calls || message.tool_calls.length === 0) {
        return message.content || '';
      }
 
      for (const toolCall of message.tool_calls) {
        const name = toolCall.function.name;
        const args = JSON.parse(toolCall.function.arguments);
 
        const result = await this.mcpClient.request('tools/call', { name, arguments: args });
 
        messages.push({
          role: 'tool',
          tool_call_id: toolCall.id,
          name,
          content: JSON.stringify(result),
        });
      }
    }
  }
}

The Architectural Void Filled by an MCP Agent Framework TypeScript Core

To appreciate why a native, lightweight orchestrator is necessary, we must look at how tool-use architectures have evolved. Traditional agent frameworks compile tool schemas into proprietary internal classes. When the LLM decides to trigger a tool, the framework intercepts the request, maps it to an internal runner class, executes it, and formats the output back into the message array.

This approach creates tight coupling. If you want to use the same tools across a Python backend, a Node.js microservice, and a desktop application, you must rewrite the tool definitions for each language-specific SDK.

Model Context Protocol decouples tool execution from the orchestrator completely. The tools live inside an isolated server process (or a remote endpoint via SSE). The orchestrator does not need to know how the tool works; it only needs to speak the standardized JSON-RPC 2.0 protocol over stdio or WebSockets.

By keeping the orchestrator thin, we reduce memory footprints, eliminate dependency vulnerabilities, and gain full control over the asynchronous loop. This is critical when executing high-throughput workflows where managing concurrent calls efficiently is a priority.


Architecture Deep Dive: Transport and Protocol Layers

An enterprise-grade mcp agent framework typescript core must handle three distinct architectural layers:

  1. The Transport Layer: Raw communication over standard input/output (stdio) or Server-Sent Events (SSE).
  2. The Protocol Layer: Parsing and validating JSON-RPC messages, matching responses to their originating requests, and handling errors.
  3. The Agentic Loop Layer: Managing LLM context windows, tool routing, retry mechanisms, and state recovery.

The Transport Layer: Why Stdio?

For local developer tools, IDE integrations, and background daemons, stdio is the standard transport protocol. It is incredibly fast, carries zero network overhead, and shares the lifecycle of the host process. If your agent orchestrator crashes, the OS automatically cleans up the child processes running the MCP servers.

However, Node.js streams are asynchronous and chunked. When an MCP server outputs data, it might not arrive in a single data event. The code example above solves this by wrapping the child process's stdout in the readline interface. This ensures we parse messages line-by-line, matching the newline-delimited JSON specification of MCP.

For remote contexts, such as exposing tools to browser-based agents or distributed systems, you should transition to an SSE (Server-Sent Events) or WebSocket transport. This separation of concerns allows you to run your agent engine on edge runtimes while your heavy scraping and database tools run in isolated environments. For more on this pattern, check out how to stop scraping and start serving with MCP Next.js integration.


Comparing Architectures: Custom MCP Client vs. Monolithic Agent Frameworks

If you are evaluating whether to write your own lightweight orchestrator or adopt a heavy framework, consider these architectural tradeoffs:

| Feature | Custom MCP Agent Framework (TypeScript) | Monolithic Frameworks (LangChain, CrewAI) | | :--- | :--- | :--- | | Dependency Footprint | Near zero (native node modules + LLM SDK) | High (hundreds of transient packages) | | Tool Portability | Universal (any MCP-compliant server) | Proprietary (locked to framework's tool wrapper) | | Memory Overhead | Minimal (< 50MB baseline) | Significant (often > 250MB baseline) | | Execution Latency | Low (direct IPC / JSON-RPC) | Medium (multiple layers of internal middleware) | | Type Safety | Strict (direct TypeScript interface mapping) | Often loose or heavily cast (any / generic records) | | Debugging Complexity| Low (inspect raw JSON-RPC streams) | High (stepping through deep call stacks) |

The advantages of a custom engine become clear when building high-performance systems. For instance, if you need to execute dozens of lookup tools concurrently to gather context for a single prompt, you can optimize execution using advanced async patterns for high-throughput rather than being bottlenecked by a framework's sequential execution engine.


Building a Advanced Orchestrator: Multi-Server Coordination and Tool Multiplexing

In a real-world scenario, a single MCP server is rarely enough. An agent might need a file-system tool server, a database tool server, and a web search tool server simultaneously.

To support this, our TypeScript framework must multiplex requests across multiple child processes and namespace tool names to avoid collisions (e.g., fs_readFile vs db_readFile).

Here is how to extend the framework to support multi-server orchestration with namespacing and fallback routing:

typescript
import { MCPServerClient } from './index'; // assuming base client is in index.ts
 
interface ServerConfig {
  name: string;
  command: string;
  args: string[];
}
 
export class MultiServerOrchestrator {
  private clients = new Map<string, MCPServerClient>();
  private toolMap = new Map<string, { clientName: string; originalName: string }>();
 
  constructor(configs: ServerConfig[]) {
    for (const config of configs) {
      const client = new MCPServerClient(config.command, config.args);
      this.clients.set(config.name, client);
    }
  }
 
  public async initialize(): Promise<any[]> {
    const combinedTools: any[] = [];
 
    for (const [serverName, client] of this.clients.entries()) {
      try {
        const response = await client.request('tools/list');
        if (response && response.tools) {
          for (const tool of response.tools) {
            const namespacedName = `${serverName}_${tool.name}`;
            this.toolMap.set(namespacedName, {
              clientName: serverName,
              originalName: tool.name
            });
 
            combinedTools.push({
              ...tool,
              name: namespacedName,
            });
          }
        }
      } catch (err) {
        console.error(`Failed to initialize tools for server [${serverName}]:`, err);
      }
    }
 
    return combinedTools;
  }
 
  public async executeTool(namespacedName: string, args: any): Promise<any> {
    const mapping = this.toolMap.get(namespacedName);
    if (!mapping) {
      throw new Error(`Tool ${namespacedName} not found in namespaced map.`);
    }
 
    const client = this.clients.get(mapping.clientName);
    if (!client) {
      throw new Error(`Client ${mapping.clientName} is no longer active.`);
    }
 
    return await client.request('tools/call', {
      name: mapping.originalName,
      arguments: args
    });
  }
 
  public destroy() {
    for (const client of this.clients.values()) {
      client.terminate();
    }
    this.clients.clear();
    this.toolMap.clear();
  }
}

This multiplexing architecture mirrors how modern IDE tools handle background execution tasks. For instance, when looking at the Cursor Composer 2.5 architecture, you see a similar separation of concerns where background agents run tools concurrently and aggregate outputs back to the central LLM engine.


Production Mitigations: Resilience, Timeouts, and Memory Leaks

When deploying an MCP-native agent framework to production, you must design for failure. Subprocesses will hang, APIs will rate limit, and LLMs will occasionally generate invalid arguments that break your tool schemas.

1. Enforcing Call Timeouts

A child process executing a complex tool (e.g., scraping a slow webpage) can easily block your orchestrator. You must wrap your JSON-RPC requests in a timeout promise:

typescript
public async requestWithTimeout(method: string, params: any, timeoutMs = 10000): Promise<any> {
  return Promise.race([
    this.request(method, params),
    new Promise((_, reject) => 
      setTimeout(() => reject(new Error(`MCP request to ${method} timed out after ${timeoutMs}ms`)), timeoutMs)
    )
  ]);
}

2. Graceful Error Recovery and Schema Corrections

If an LLM passes arguments that fail a tool's JSON Schema validation, don't crash the agent loop. Catch the validation error, format it clean, and feed it back to the LLM. Most frontier models will automatically correct their parameters in the next turn of the loop.

typescript
try {
  const result = await this.mcpClient.request('tools/call', { name, arguments: args });
  // Process result...
} catch (error: any) {
  // Feed error back to LLM context
  messages.push({
    role: 'tool',
    tool_call_id: toolCall.id,
    name,
    content: JSON.stringify({
      status: 'error',
      message: error.message || 'Unknown execution error',
      suggestion: 'Please verify the parameter types against the schema and try again.'
    }),
  });
}

When to Use This Framework

Use it if:

  • You are building an IDE extension, terminal assistant, or developer tool: The stdio transport is fast, reliable, and keeps local resources isolated.
  • You need to deploy agents in low-resource environments: A custom TypeScript client runs comfortably inside serverless functions, lightweight containers, or edge devices.
  • You have existing tools written in multiple languages: You can write tools in Python or Go, wrap them in an MCP server, and invoke them seamlessly from your central TypeScript orchestrator.
  • You need strict security boundaries: Running tools in isolated child processes allows you to restrict filesystem permissions and environment variables on a per-process basis.

Do NOT use it if:

  • Your entire codebase is Python-centric: If your team lives in Jupyter notebooks and relies heavily on PyTorch or HuggingFace, stick to Python-native tools rather than bridging to a Node.js runtime.
  • You require complex visual canvas builders out-of-the-box: If your project demands drag-and-drop agent building, a custom code-first engine will require you to write your own frontend integration layer. Instead, inspect how to interface with modern UI elements using generative UI with LLMs and React.
  • Your tools are highly dynamic and lack formal schemas: MCP relies on rigid JSON Schema declarations. If your tools are completely unstructured and change format per invocation, the overhead of maintaining schemas will slow you down.

By writing a lean, custom MCP client, you decouple your system from fragile framework abstractions. You get direct control over the transport layer, execution loop, and error mitigation strategies—ensuring your agents are fast, predictable, and production-ready. For a deeper look at the tooling landscape, review our curated list of the best MCP tools for developers.

ShareTweet

Related Posts