Skip to content
AI Intermediate Tutorial

Build an MCP Client in TypeScript That Connects Claude to Any Tool Server

Wire up the Model Context Protocol from the client side so Claude can discover a server's tools at runtime and call them itself, no hardcoded function list required.

Priya Nair
Priya Nair
AI & Developer Experience Writer · Jul 12, 2026 · 10 min read
Build an MCP Client in TypeScript That Connects Claude to Any Tool Server

What you'll build

A small TypeScript CLI that connects to an MCP (Model Context Protocol) server over stdio, asks it what tools it has, hands that tool list to Claude, and executes whatever Claude decides to call. Point it at a different MCP server and the same client code works unchanged. We'll use the official filesystem reference server as our test target since it needs zero custom code to run.

Prerequisites

  • Node.js 18 or newer (node -v to check) and npm
  • An Anthropic API key with billing enabled (console.anthropic.com)
  • Basic familiarity with async/await and JSON Schema
  • macOS, Linux, or Windows all work; one Windows-specific gotcha is called out below

MCP itself is just JSON-RPC 2.0 over a transport (stdio for local processes, HTTP/SSE for remote servers). A server exposes tools, resources, and prompts; a client discovers and invokes them. We're only dealing with tools here, that's 90% of real MCP usage.

1. Set up the project

mkdir mcp-claude-client && cd mcp-claude-client
npm init -y
npm install @modelcontextprotocol/sdk @anthropic-ai/sdk dotenv
npm install -D typescript tsx @types/node
mkdir src sandbox
echo "The launch code is 4-8-15-16-23-42." > sandbox/notes.txt

The sandbox directory is what we'll let the filesystem MCP server touch. Never point it at your home directory or repo root, it can read and write files.

Add "type": "module" to package.json (required for the SDK's ESM exports), then create tsconfig.json:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "esModuleInterop": true,
    "strict": true,
    "skipLibCheck": true,
    "outDir": "dist"
  },
  "include": ["src"]
}

Create .env with your key (add .env to .gitignore immediately, don't skip that):

echo "ANTHROPIC_API_KEY=sk-ant-your-key-here" > .env
echo ".env" >> .gitignore

2. Connect the MCP client to a server

MCP servers running locally are just child processes that speak JSON-RPC over stdin/stdout. The SDK's StdioClientTransport spawns the process for you:

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

const transport = new StdioClientTransport({
  command: process.platform === "win32" ? "npx.cmd" : "npx",
  args: ["-y", "@modelcontextprotocol/server-filesystem", `${process.cwd()}/sandbox`],
});

const mcp = new Client(
  { name: "mcp-claude-client", version: "1.0.0" },
  { capabilities: {} }
);

await mcp.connect(transport);

The second argument to Client is where you'd declare capabilities like sampling or roots if your client supported them. We don't need any for a tool-calling client.

3. Turn MCP tools into Claude tools

Once connected, ask the server what it can do:

const { tools: mcpTools } = await mcp.listTools();
console.log(`Discovered ${mcpTools.length} tools:`, mcpTools.map(t => t.name).join(", "));

Each tool comes back as { name, description, inputSchema }, and inputSchema is already plain JSON Schema. Claude's tool-use API wants the same shape under a slightly different key (input_schema), so the conversion is a one-liner:

const anthropicTools = mcpTools.map((tool) => ({
  name: tool.name,
  description: tool.description ?? "",
  input_schema: tool.inputSchema,
}));

This is the whole trick that makes MCP useful: no manual tool definitions synced by hand between your server and your prompt. The server is the source of truth.

4. Build the agent loop

Claude doesn't call tools directly, it returns a tool_use content block and stops. Your client executes the tool and sends the result back as a tool_result, and you loop until Claude replies with plain text instead of a tool call:

import Anthropic from "@anthropic-ai/sdk";

const anthropic = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });
const MODEL = "claude-3-5-sonnet-20241022"; // swap for the current model in Anthropic's docs

async function runAgent(question: string) {
  const messages: Anthropic.MessageParam[] = [{ role: "user", content: question }];

  while (true) {
    const response = await anthropic.messages.create({
      model: MODEL,
      max_tokens: 1024,
      tools: anthropicTools,
      messages,
    });

    messages.push({ role: "assistant", content: response.content });

    if (response.stop_reason !== "tool_use") {
      const text = response.content.find((b) => b.type === "text");
      console.log("\nClaude:", text?.type === "text" ? text.text : "(no text returned)");
      return;
    }

    const toolResults: Anthropic.MessageParam["content"] = [];
    for (const block of response.content) {
      if (block.type !== "tool_use") continue;

      console.log(`\n-> calling "${block.name}" with`, block.input);
      const result = await mcp.callTool({
        name: block.name,
        arguments: block.input as Record<string, unknown>,
      });

      toolResults.push({
        type: "tool_result",
        tool_use_id: block.id,
        content: result.content as any, // MCP and Anthropic content blocks overlap but aren't identically typed
        is_error: result.isError ?? false,
      });
    }

    messages.push({ role: "user", content: toolResults });
  }
}

The as any cast is a deliberate shortcut: MCP tool results and Anthropic tool_result content blocks are both arrays of { type: "text", text }-shaped objects at runtime, but the two SDKs type them slightly differently. Fine for a tutorial; in production, map explicitly.

5. Put it all together

Save everything above into src/main.ts, wrap it in a main() function, and close the client when done:

import "dotenv/config";
// ...(imports and code from steps 2-4 go here)

async function main() {
  const question = process.argv[2] ?? "List the files in the sandbox and tell me what's in notes.txt";
  await runAgent(question);
  await mcp.close();
}

main().catch((err) => {
  console.error(err);
  process.exit(1);
});

Add a script to package.json:

"scripts": { "start": "tsx src/main.ts" }

Verify it works

npm start -- "What's in notes.txt?"

First run downloads the filesystem server via npx, so expect a short pause. You should see something like:

Discovered 11 tools: read_file, read_multiple_files, write_file, edit_file, create_directory, list_directory, move_file, search_files, get_file_info, list_allowed_directories

-> calling "read_file" with { path: '.../sandbox/notes.txt' }

Claude: The file contains: "The launch code is 4-8-15-16-23-42."

If you get a real tool call and a grounded final answer, the whole discover -> call -> respond loop is working end to end.

Troubleshooting

ANTHROPIC_API_KEY is not set or 401 from the API - Make sure import "dotenv/config" is the first import in main.ts, and that .env sits in the project root, not src/.

Error: Access denied - path outside allowed directories - The filesystem server sandboxes itself to the directories passed as CLI args. Pass an absolute path (${process.cwd()}/sandbox), and make sure Claude's tool call isn't trying to read something outside it.

spawn npx ENOENT on Windows - Windows needs npx.cmd, not npx. The platform check in step 2 handles this; if you copy-pasted around it, add it back.

400 error mentioning tools.0.input_schema - Claude's API is strict about JSON Schema shape (needs "type": "object" at minimum). This shouldn't happen with the reference server, but if you point the client at a hand-rolled server, validate its inputSchema output first.

Next steps

Swap the transport for StreamableHTTPClientTransport (also in @modelcontextprotocol/sdk) to talk to remote MCP servers over HTTP instead of spawning a local process. Try connecting to more than one server at once and namespace tool names to avoid collisions. And read the spec at modelcontextprotocol.io for the other two primitives we skipped here: resources (readable context, not actions) and prompts (reusable templates a server can offer).

Priya Nair
Written by
Priya Nair · AI & Developer Experience Writer

Priya covers AI frameworks, developer productivity tooling, and the startup ecosystem across South and Southeast Asia, bringing a researcher's rigour and a practitioner's empathy to every story. She is deeply sceptical of benchmarks and asks hard questions so her readers don't have to.

Discussion 0

Join the discussion

Sign in or create an account to comment and vote.

No comments yet

Be the first to weigh in.

Related Reading