cd /news/developer-tools/build-an-mcp-client-in-typescript-th… · home topics developer-tools article
[ARTICLE · art-56409] src=sourcefeed.dev ↗ pub= topic=developer-tools verified=true sentiment=· neutral

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

Priya Nair published a tutorial on building a TypeScript CLI that connects to any Model Context Protocol (MCP) server, enabling Claude to discover and call tools at runtime without hardcoded function lists. The project uses the official filesystem reference server as a test target and demonstrates how to wire up MCP's JSON-RPC transport for dynamic tool invocation.

read6 min views1 publishedJul 12, 2026
Build an MCP Client in TypeScript That Connects Claude to Any Tool Server
Image: Sourcefeed (auto-discovered)

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

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 . 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· 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 #

No comments yet

Be the first to weigh in.

── more in #developer-tools 4 stories · sorted by recency
── more on @priya nair 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/build-an-mcp-client-…] indexed:0 read:6min 2026-07-12 ·