Mcp

MCP Client Implementation: Connecting to MCP Servers from Node.js

A practical guide to building MCP clients in Node.js, covering protocol basics, SDK usage, tool discovery, execution, error handling, and building interactive CLI applications.

MCP Client Implementation: Connecting to MCP Servers from Node.js

Overview

The Model Context Protocol (MCP) defines how AI models interact with external tools and data sources through a standardized protocol. Building an MCP client means you can connect to any MCP server -- whether it exposes file system tools, database queries, API integrations, or custom business logic -- and invoke those tools programmatically from your own Node.js application. This guide covers everything from protocol fundamentals to building a production-quality CLI application that discovers and executes MCP tools interactively.

Prerequisites

  • Node.js 18+ installed (LTS recommended)
  • npm for package management
  • Basic familiarity with JSON-RPC 2.0 (MCP is built on it)
  • An MCP server to connect to (we will build a simple one for testing)
  • Understanding of child processes and streams in Node.js
  • Familiarity with async/await patterns

How the MCP Protocol Works

MCP is built on top of JSON-RPC 2.0. Every message exchanged between client and server is a JSON-RPC request, response, or notification. The protocol defines three categories of capabilities that a server can expose:

  1. Tools -- Functions the model can call to perform actions
  2. Resources -- Data the model can read
  3. Prompts -- Reusable prompt templates

As a client developer, your primary job is establishing a transport connection, discovering what the server offers, and invoking those capabilities. The JSON-RPC layer handles the serialization and message routing.

Here is what a raw tools/list request looks like on the wire:

{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "tools/list",
  "params": {}
}

And the server responds with:

{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "tools": [
      {
        "name": "get_weather",
        "description": "Get current weather for a city",
        "inputSchema": {
          "type": "object",
          "properties": {
            "city": { "type": "string", "description": "City name" }
          },
          "required": ["city"]
        }
      }
    ]
  }
}

You never write this JSON manually. The SDK handles all of it. But understanding the wire format helps immensely when debugging transport issues.

Transport Options

MCP defines two standard transports:

stdio -- The client spawns the server as a child process and communicates over stdin/stdout. This is the most common transport for local tools. The server reads JSON-RPC messages from stdin and writes responses to stdout. All logging must go to stderr.

Streamable HTTP -- The client connects to a remote HTTP endpoint. The server accepts POST requests with JSON-RPC payloads and returns responses either as JSON or as Server-Sent Events (SSE) streams. This replaced the older SSE-only transport.

For most Node.js tool integrations, stdio is what you will use. Streamable HTTP is for remote servers where you cannot spawn a local process.

Installing the MCP SDK

The official TypeScript SDK is published as @modelcontextprotocol/sdk. It is an ESM-only package, which has implications for how you structure your project.

mkdir mcp-client-demo
cd mcp-client-demo
npm init -y
npm install @modelcontextprotocol/sdk zod

The SDK requires zod as a peer dependency for schema validation.

Important: The @modelcontextprotocol/sdk package is ESM-only. You must set "type": "module" in your package.json. This means you will use import instead of require() in files that directly consume the SDK. I know, I said we use var and require() in this project. But the SDK forces our hand here. If you are wrapping MCP in a larger CommonJS project, you can isolate the ESM code in separate .mjs files or use dynamic import() calls.

{
  "name": "mcp-client-demo",
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "start": "node client.js"
  },
  "dependencies": {
    "@modelcontextprotocol/sdk": "^1.12.0",
    "zod": "^3.25.0"
  }
}

If you absolutely need CommonJS compatibility in your parent project, wrap your MCP client in a separate ESM module and call it with dynamic import:

// main.js (CommonJS entry point)
var path = require("path");

async function startMcpClient(serverPath) {
  var mcpModule = await import("./mcp-client.mjs");
  return mcpModule.connectAndRun(serverPath);
}

startMcpClient(process.argv[2]).catch(function(err) {
  console.error("Fatal:", err);
  process.exit(1);
});

Creating a Client That Connects to an MCP Server

The SDK provides a high-level Client class that handles the protocol handshake, capability negotiation, and message routing. You pair it with a transport to establish the connection.

Here is the minimal client that connects over stdio:

// client.js
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";

var client = new Client({
  name: "my-mcp-client",
  version: "1.0.0"
});

var transport = new StdioClientTransport({
  command: "node",
  args: ["server.js"]
});

await client.connect(transport);
console.log("Connected to MCP server");

The StdioClientTransport spawns node server.js as a child process and wires up stdin/stdout for JSON-RPC communication. The connect() call performs the MCP initialization handshake -- it sends an initialize request, receives the server's capabilities, and sends an initialized notification to confirm.

For remote servers using Streamable HTTP:

import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";

var client = new Client({
  name: "my-mcp-client",
  version: "1.0.0"
});

var transport = new StreamableHTTPClientTransport(
  new URL("http://localhost:3001/mcp")
);

await client.connect(transport);
console.log("Connected to remote MCP server");

Backward Compatibility with SSE Servers

Some older MCP servers only support the deprecated SSE transport. The SDK provides SSEClientTransport for those. A robust client tries Streamable HTTP first and falls back:

import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";

async function connectWithFallback(url) {
  var client = new Client({ name: "my-client", version: "1.0.0" });

  try {
    var transport = new StreamableHTTPClientTransport(new URL(url));
    await client.connect(transport);
    console.log("Connected via Streamable HTTP");
    return client;
  } catch (err) {
    console.log("Streamable HTTP failed, trying SSE fallback...");
    var sseTransport = new SSEClientTransport(new URL(url));
    await client.connect(sseTransport);
    console.log("Connected via SSE");
    return client;
  }
}

Discovering Available Tools

Once connected, querying the server for its capabilities is straightforward:

var toolsResult = await client.listTools();

console.log("Available tools:");
toolsResult.tools.forEach(function(tool) {
  console.log("  " + tool.name + " - " + tool.description);
  console.log("  Input schema:", JSON.stringify(tool.inputSchema, null, 2));
});

The response includes the full JSON Schema for each tool's input parameters. This is critical -- it tells you exactly what arguments each tool expects, their types, which ones are required, and what constraints apply.

Example output:

Available tools:
  get_weather - Get current weather for a city
  Input schema: {
    "type": "object",
    "properties": {
      "city": {
        "type": "string",
        "description": "City name"
      }
    },
    "required": ["city"]
  }
  search_files - Search files by pattern
  Input schema: {
    "type": "object",
    "properties": {
      "pattern": {
        "type": "string",
        "description": "Glob pattern to match files"
      },
      "directory": {
        "type": "string",
        "description": "Root directory to search from"
      }
    },
    "required": ["pattern"]
  }

You can also discover resources and prompts:

var resourcesResult = await client.listResources();
resourcesResult.resources.forEach(function(resource) {
  console.log("Resource:", resource.name, "-", resource.uri);
});

var promptsResult = await client.listPrompts();
promptsResult.prompts.forEach(function(prompt) {
  console.log("Prompt:", prompt.name, "-", prompt.description);
});

Calling Tools Programmatically

Calling a tool requires the tool name and an arguments object matching the input schema:

var result = await client.callTool({
  name: "get_weather",
  arguments: {
    city: "San Francisco"
  }
});

console.log("Tool result:", JSON.stringify(result, null, 2));

The result object contains a content array. Each element has a type field (usually "text") and the corresponding data:

{
  "content": [
    {
      "type": "text",
      "text": "Current weather in San Francisco: 62°F, partly cloudy, humidity 78%"
    }
  ],
  "isError": false
}

Handling Different Content Types

Tool responses can include text, images, or embedded resources:

var result = await client.callTool({
  name: "generate_chart",
  arguments: { data: [10, 20, 30, 40] }
});

result.content.forEach(function(item) {
  if (item.type === "text") {
    console.log("Text:", item.text);
  } else if (item.type === "image") {
    console.log("Image received, MIME type:", item.mimeType);
    // item.data contains base64-encoded image data
    var fs = await import("fs");
    fs.writeFileSync("chart.png", Buffer.from(item.data, "base64"));
  } else if (item.type === "resource") {
    console.log("Embedded resource:", item.resource.uri);
    console.log("Content:", item.resource.text);
  }
});

Reading Resources

Resources represent data the server exposes for reading:

var resource = await client.readResource({
  uri: "file:///project/config.json"
});

resource.contents.forEach(function(content) {
  console.log("URI:", content.uri);
  console.log("MIME type:", content.mimeType);
  console.log("Content:", content.text);
});

Error Handling Patterns

MCP error handling operates at two levels: transport errors and tool execution errors.

Transport-Level Errors

These occur when the connection itself fails -- the server process crashes, the HTTP endpoint is unreachable, or the JSON-RPC message is malformed:

try {
  await client.connect(transport);
} catch (err) {
  if (err.code === "ENOENT") {
    console.error("Server executable not found. Check your command path.");
  } else if (err.code === "ECONNREFUSED") {
    console.error("Server refused connection. Is it running?");
  } else {
    console.error("Connection failed:", err.message);
  }
  process.exit(1);
}

Tool Execution Errors

When a tool call fails, the response has isError: true in the result:

var result = await client.callTool({
  name: "query_database",
  arguments: { sql: "SELECT * FROM nonexistent_table" }
});

if (result.isError) {
  console.error("Tool execution failed:");
  result.content.forEach(function(item) {
    if (item.type === "text") {
      console.error("  ", item.text);
    }
  });
} else {
  console.log("Query succeeded:");
  result.content.forEach(function(item) {
    console.log(item.text);
  });
}

Wrapping Calls with Timeout Protection

MCP servers can hang, especially when they call external APIs. Always add timeout protection:

function callToolWithTimeout(client, toolName, args, timeoutMs) {
  return new Promise(function(resolve, reject) {
    var timer = setTimeout(function() {
      reject(new Error("Tool call timed out after " + timeoutMs + "ms"));
    }, timeoutMs);

    client.callTool({ name: toolName, arguments: args })
      .then(function(result) {
        clearTimeout(timer);
        resolve(result);
      })
      .catch(function(err) {
        clearTimeout(timer);
        reject(err);
      });
  });
}

// Usage
try {
  var result = await callToolWithTimeout(client, "slow_api_call", { url: "https://example.com" }, 30000);
  console.log("Result:", result);
} catch (err) {
  console.error("Call failed:", err.message);
}

Handling Server Disconnection

The client emits events you can listen for:

client.onclose = function() {
  console.log("Server disconnected");
};

client.onerror = function(err) {
  console.error("Protocol error:", err);
};

Connecting to Multiple Servers

Real-world applications often need tools from multiple MCP servers simultaneously. Each server gets its own client instance:

import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";

var servers = [
  { name: "filesystem", command: "npx", args: ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"] },
  { name: "database", command: "node", args: ["./db-server.js"] },
  { name: "weather", command: "node", args: ["./weather-server.js"] }
];

var clients = {};

for (var i = 0; i < servers.length; i++) {
  var serverConfig = servers[i];
  var client = new Client({
    name: "multi-client",
    version: "1.0.0"
  });

  var transport = new StdioClientTransport({
    command: serverConfig.command,
    args: serverConfig.args
  });

  try {
    await client.connect(transport);
    var tools = await client.listTools();
    clients[serverConfig.name] = {
      client: client,
      tools: tools.tools
    };
    console.log("Connected to " + serverConfig.name + " (" + tools.tools.length + " tools)");
  } catch (err) {
    console.error("Failed to connect to " + serverConfig.name + ":", err.message);
  }
}

// Build a unified tool registry
var allTools = [];
Object.keys(clients).forEach(function(serverName) {
  clients[serverName].tools.forEach(function(tool) {
    allTools.push({
      serverName: serverName,
      tool: tool
    });
  });
});

console.log("\nAll available tools:");
allTools.forEach(function(entry) {
  console.log("  [" + entry.serverName + "] " + entry.tool.name + " - " + entry.tool.description);
});

// Call a tool on the right server
async function callToolOnServer(serverName, toolName, args) {
  var serverClient = clients[serverName];
  if (!serverClient) {
    throw new Error("Unknown server: " + serverName);
  }
  return serverClient.client.callTool({ name: toolName, arguments: args });
}

Session Management and Reconnection

MCP connections can drop. A production client needs reconnection logic:

function createReconnectingClient(serverConfig, maxRetries) {
  var retryCount = 0;
  var client = null;
  var isConnected = false;

  async function connect() {
    client = new Client({
      name: "resilient-client",
      version: "1.0.0"
    });

    var transport = new StdioClientTransport({
      command: serverConfig.command,
      args: serverConfig.args
    });

    client.onclose = function() {
      console.log("Connection lost to " + serverConfig.name);
      isConnected = false;
      scheduleReconnect();
    };

    await client.connect(transport);
    isConnected = true;
    retryCount = 0;
    console.log("Connected to " + serverConfig.name);
    return client;
  }

  function scheduleReconnect() {
    if (retryCount >= maxRetries) {
      console.error("Max retries reached for " + serverConfig.name);
      return;
    }
    retryCount++;
    var delay = Math.min(1000 * Math.pow(2, retryCount), 30000);
    console.log("Reconnecting in " + delay + "ms (attempt " + retryCount + "/" + maxRetries + ")");
    setTimeout(function() {
      connect().catch(function(err) {
        console.error("Reconnect failed:", err.message);
        scheduleReconnect();
      });
    }, delay);
  }

  return {
    connect: connect,
    getClient: function() { return client; },
    isConnected: function() { return isConnected; }
  };
}

Building a Test Server

Before we build the CLI client, let us create a small MCP server to test against. This server exposes three tools: one for math, one for getting the current time, and one that simulates a failure.

// test-server.js
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";

var server = new McpServer({
  name: "test-tools",
  version: "1.0.0"
});

server.tool(
  "calculate",
  "Perform basic arithmetic",
  {
    operation: z.enum(["add", "subtract", "multiply", "divide"]),
    a: z.number(),
    b: z.number()
  },
  function(params) {
    var result;
    switch (params.operation) {
      case "add": result = params.a + params.b; break;
      case "subtract": result = params.a - params.b; break;
      case "multiply": result = params.a * params.b; break;
      case "divide":
        if (params.b === 0) {
          return { content: [{ type: "text", text: "Error: Division by zero" }], isError: true };
        }
        result = params.a / params.b;
        break;
    }
    return { content: [{ type: "text", text: "Result: " + result }] };
  }
);

server.tool(
  "current_time",
  "Get the current date and time",
  {},
  function() {
    var now = new Date();
    return { content: [{ type: "text", text: now.toISOString() }] };
  }
);

server.tool(
  "fail_on_purpose",
  "A tool that always fails (for testing error handling)",
  {
    message: z.string().optional()
  },
  function(params) {
    var msg = params.message || "Intentional failure for testing";
    return { content: [{ type: "text", text: msg }], isError: true };
  }
);

async function main() {
  var transport = new StdioServerTransport();
  await server.connect(transport);
  console.error("Test server running on stdio");
}

main().catch(function(err) {
  console.error("Fatal:", err);
  process.exit(1);
});

Complete Working Example: Interactive MCP CLI Client

This is a full, working CLI application that connects to an MCP server, discovers tools, and lets you execute them interactively from the command line.

// mcp-cli.js
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
import readline from "readline";

var client = null;
var availableTools = [];

async function connectToServer(command, args) {
  client = new Client({
    name: "mcp-cli",
    version: "1.0.0"
  });

  var transport = new StdioClientTransport({
    command: command,
    args: args
  });

  client.onclose = function() {
    console.log("\nServer disconnected.");
    process.exit(1);
  };

  client.onerror = function(err) {
    console.error("Protocol error:", err.message);
  };

  await client.connect(transport);

  var result = await client.listTools();
  availableTools = result.tools;

  console.log("Connected to MCP server. " + availableTools.length + " tools available.\n");
  printToolList();
}

function printToolList() {
  console.log("Available tools:");
  console.log("─".repeat(60));
  availableTools.forEach(function(tool, index) {
    console.log("  " + (index + 1) + ". " + tool.name);
    console.log("     " + (tool.description || "No description"));
    if (tool.inputSchema && tool.inputSchema.required && tool.inputSchema.required.length > 0) {
      console.log("     Required args: " + tool.inputSchema.required.join(", "));
    }
  });
  console.log("─".repeat(60));
  console.log('\nCommands: "list", "call <toolname> <json-args>", "schema <toolname>", "quit"');
}

function printToolSchema(toolName) {
  var tool = availableTools.find(function(t) { return t.name === toolName; });
  if (!tool) {
    console.log("Unknown tool: " + toolName);
    return;
  }
  console.log("\nSchema for " + tool.name + ":");
  console.log(JSON.stringify(tool.inputSchema, null, 2));
}

async function callTool(toolName, argsJson) {
  var tool = availableTools.find(function(t) { return t.name === toolName; });
  if (!tool) {
    console.log("Unknown tool: " + toolName + ". Type 'list' to see available tools.");
    return;
  }

  var args = {};
  if (argsJson) {
    try {
      args = JSON.parse(argsJson);
    } catch (err) {
      console.error("Invalid JSON arguments:", err.message);
      console.log('Example: call ' + toolName + ' {"key": "value"}');
      return;
    }
  }

  console.log("Calling " + toolName + "...");
  var startTime = Date.now();

  try {
    var result = await client.callTool({
      name: toolName,
      arguments: args
    });

    var elapsed = Date.now() - startTime;

    if (result.isError) {
      console.log("\n[ERROR] Tool returned an error (" + elapsed + "ms):");
    } else {
      console.log("\n[OK] Response (" + elapsed + "ms):");
    }

    result.content.forEach(function(item) {
      if (item.type === "text") {
        console.log(item.text);
      } else if (item.type === "image") {
        console.log("[Image: " + item.mimeType + ", " + item.data.length + " bytes base64]");
      } else {
        console.log("[" + item.type + "]", JSON.stringify(item));
      }
    });

  } catch (err) {
    console.error("Tool call failed:", err.message);
  }
}

function parseCommand(input) {
  var trimmed = input.trim();
  if (!trimmed) return null;

  if (trimmed === "list") {
    return { action: "list" };
  }
  if (trimmed === "quit" || trimmed === "exit") {
    return { action: "quit" };
  }

  var schemaMatch = trimmed.match(/^schema\s+(\S+)$/);
  if (schemaMatch) {
    return { action: "schema", toolName: schemaMatch[1] };
  }

  var callMatch = trimmed.match(/^call\s+(\S+)\s*(.*)$/);
  if (callMatch) {
    return { action: "call", toolName: callMatch[1], args: callMatch[2] || null };
  }

  return { action: "unknown", raw: trimmed };
}

async function startRepl() {
  var rl = readline.createInterface({
    input: process.stdin,
    output: process.stdout
  });

  function prompt() {
    rl.question("\nmcp> ", function(input) {
      var cmd = parseCommand(input);

      if (!cmd) {
        prompt();
        return;
      }

      switch (cmd.action) {
        case "list":
          printToolList();
          prompt();
          break;

        case "schema":
          printToolSchema(cmd.toolName);
          prompt();
          break;

        case "call":
          callTool(cmd.toolName, cmd.args).then(function() {
            prompt();
          });
          break;

        case "quit":
          console.log("Disconnecting...");
          client.close().then(function() {
            rl.close();
            process.exit(0);
          });
          break;

        default:
          console.log('Unknown command. Try "list", "call <tool> <args>", "schema <tool>", or "quit"');
          prompt();
          break;
      }
    });
  }

  prompt();
}

// --- Main ---

var serverScript = process.argv[2];
if (!serverScript) {
  console.log("Usage: node mcp-cli.js <server-script>");
  console.log("  Example: node mcp-cli.js ./test-server.js");
  process.exit(1);
}

var command = "node";
var args = [serverScript];

if (serverScript.endsWith(".py")) {
  command = process.platform === "win32" ? "python" : "python3";
}

connectToServer(command, args)
  .then(function() {
    return startRepl();
  })
  .catch(function(err) {
    console.error("Failed to start:", err.message);
    process.exit(1);
  });

Running the CLI

node mcp-cli.js ./test-server.js

Example session:

Connected to MCP server. 3 tools available.

Available tools:
────────────────────────────────────────────────────────────────
  1. calculate
     Perform basic arithmetic
     Required args: operation, a, b
  2. current_time
     Get the current date and time
  3. fail_on_purpose
     A tool that always fails (for testing error handling)
────────────────────────────────────────────────────────────────

Commands: "list", "call <toolname> <json-args>", "schema <toolname>", "quit"

mcp> call calculate {"operation": "multiply", "a": 7, "b": 6}
Calling calculate...

[OK] Response (12ms):
Result: 42

mcp> call current_time
Calling current_time...

[OK] Response (3ms):
2026-02-08T15:30:22.451Z

mcp> call fail_on_purpose {"message": "Testing error path"}
Calling fail_on_purpose...

[ERROR] Tool returned an error (4ms):
Testing error path

mcp> schema calculate
Schema for calculate:
{
  "type": "object",
  "properties": {
    "operation": {
      "type": "string",
      "enum": ["add", "subtract", "multiply", "divide"]
    },
    "a": {
      "type": "number"
    },
    "b": {
      "type": "number"
    }
  },
  "required": ["operation", "a", "b"]
}

mcp> quit
Disconnecting...

Integrating MCP Tools with an LLM

The real power of MCP comes from feeding tool descriptions to an LLM and letting it decide which tools to call. Here is a pattern for wiring MCP tools into Claude's tool-use API:

import Anthropic from "@anthropic-ai/sdk";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";

var anthropic = new Anthropic();

// Connect to MCP server
var mcpClient = new Client({ name: "llm-client", version: "1.0.0" });
var transport = new StdioClientTransport({ command: "node", args: ["server.js"] });
await mcpClient.connect(transport);

// Convert MCP tools to Anthropic tool format
var mcpTools = await mcpClient.listTools();
var anthropicTools = mcpTools.tools.map(function(tool) {
  return {
    name: tool.name,
    description: tool.description,
    input_schema: tool.inputSchema
  };
});

// Send user message with tools
var response = await anthropic.messages.create({
  model: "claude-sonnet-4-20250514",
  max_tokens: 1024,
  messages: [{ role: "user", content: "What is 42 times 17?" }],
  tools: anthropicTools
});

// Process tool calls
for (var i = 0; i < response.content.length; i++) {
  var block = response.content[i];
  if (block.type === "tool_use") {
    console.log("Claude wants to call:", block.name, block.input);

    var toolResult = await mcpClient.callTool({
      name: block.name,
      arguments: block.input
    });

    console.log("Tool returned:", toolResult.content[0].text);
  }
}

Common Issues & Troubleshooting

1. "Error: spawn node ENOENT"

Error: spawn node ENOENT
    at ChildProcess._handle.onexit (node:internal/child_process:285:19)

This means the command in your StdioClientTransport configuration cannot be found. On Windows, ensure node is in your PATH. If spawning Python servers, use "python" on Windows or "python3" on macOS/Linux.

2. "SyntaxError: Cannot use import statement in a module"

SyntaxError: Cannot use import statement in a module

You forgot to set "type": "module" in your package.json. The MCP SDK is ESM-only. Without this setting, Node.js treats .js files as CommonJS and chokes on import statements. Add "type": "module" to your package.json or rename your file to .mjs.

3. "Error: Server closed unexpectedly" during tool calls

McpError: MCP error -32000: Server closed unexpectedly

The server process crashed mid-execution. Common causes: the server wrote to stdout accidentally (breaking JSON-RPC), an unhandled exception in the tool handler, or the server ran out of memory. Check the server's stderr output for clues. Remember, MCP servers must log to stderr, never stdout.

4. Tool call returns result but content is empty

{
  "content": [],
  "isError": false
}

The server's tool handler returned a result with an empty content array. This usually means the handler function forgot to return the content structure. Every tool handler must return { content: [{ type: "text", text: "..." }] }. Check the server implementation.

5. "Error: connect ECONNREFUSED 127.0.0.1:3001"

Error: connect ECONNREFUSED 127.0.0.1:3001

When using StreamableHTTPClientTransport, the remote server is not running or is listening on a different port. Verify the server URL and port. Also check that no firewall rules are blocking the connection.

6. "pkce-challenge" ESM incompatibility

Error [ERR_REQUIRE_ESM]: require() of ES Module .../pkce-challenge/index.js not supported

This occurs in older versions of the SDK when used in CommonJS contexts. The SDK's OAuth module depends on pkce-challenge, which is ESM-only. Upgrade to @modelcontextprotocol/sdk@^1.12.0 or avoid importing the auth modules in CommonJS code.

Best Practices

  • Always check isError on tool results. A tool call can succeed at the transport level but fail at the application level. The isError flag on the result object tells you whether the tool itself reported an error. Never assume a non-throwing call means success.

  • Add timeouts to every tool call. MCP servers can hang indefinitely if they are waiting on external APIs, database queries, or file system operations. Wrap callTool with a timeout of 30 seconds or less. Adjust based on the tool's expected behavior.

  • Log to stderr when building servers. This bears repeating: stdout is reserved for JSON-RPC messages in stdio transport. Any stray console.log in a server will corrupt the message stream and cause cryptic parse errors in the client.

  • Namespace tools when connecting to multiple servers. If you connect to three servers and two of them expose a tool named search, you have a collision. Prefix tool names with the server name in your internal registry to avoid ambiguity.

  • Validate arguments against the input schema before calling. Do not send malformed arguments and rely on the server to reject them. Parse the tool's inputSchema and validate client-side first. This catches errors faster and produces better error messages.

  • Handle server lifecycle events. Listen for the close and error events on the client. If the server dies, you need to know immediately rather than discovering it on the next tool call.

  • Use connection pooling for HTTP transports. When using Streamable HTTP with multiple concurrent tool calls, the underlying HTTP connections should be reused. The SDK handles this internally, but be aware of connection limits if you are running many parallel calls.

  • Pin your SDK version. The MCP SDK is evolving rapidly. What works in 1.12.0 might have breaking changes in 2.0.0. Pin your dependency version in package.json and test upgrades explicitly.

  • Clean up on process exit. Always call client.close() before your process exits. This sends a proper shutdown signal to the server process. Without it, you may leave orphaned child processes consuming resources.

References

Powered by Contentful