{"slug": "build-an-mcp-client-in-typescript-that-connects-claude-to-any-tool-server", "title": "Build an MCP Client in TypeScript That Connects Claude to Any Tool Server", "summary": "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.", "body_md": "# Build an MCP Client in TypeScript That Connects Claude to Any Tool Server\n\nWire 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.\n\n[Priya Nair](https://sourcefeed.dev/u/priya_nair)\n\n## What you'll build\n\nA 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.\n\n## Prerequisites\n\n- Node.js 18 or newer (\n`node -v`\n\nto check) and npm - An Anthropic API key with billing enabled (\n[console.anthropic.com](https://console.anthropic.com)) - Basic familiarity with async/await and JSON Schema\n- macOS, Linux, or Windows all work; one Windows-specific gotcha is called out below\n\nMCP 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.\n\n## 1. Set up the project\n\n```\nmkdir mcp-claude-client && cd mcp-claude-client\nnpm init -y\nnpm install @modelcontextprotocol/sdk @anthropic-ai/sdk dotenv\nnpm install -D typescript tsx @types/node\nmkdir src sandbox\necho \"The launch code is 4-8-15-16-23-42.\" > sandbox/notes.txt\n```\n\nThe `sandbox`\n\ndirectory 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.\n\nAdd `\"type\": \"module\"`\n\nto `package.json`\n\n(required for the SDK's ESM exports), then create `tsconfig.json`\n\n:\n\n```\n{\n  \"compilerOptions\": {\n    \"target\": \"ES2022\",\n    \"module\": \"NodeNext\",\n    \"moduleResolution\": \"NodeNext\",\n    \"esModuleInterop\": true,\n    \"strict\": true,\n    \"skipLibCheck\": true,\n    \"outDir\": \"dist\"\n  },\n  \"include\": [\"src\"]\n}\n```\n\nCreate `.env`\n\nwith your key (add `.env`\n\nto `.gitignore`\n\nimmediately, don't skip that):\n\n```\necho \"ANTHROPIC_API_KEY=sk-ant-your-key-here\" > .env\necho \".env\" >> .gitignore\n```\n\n## 2. Connect the MCP client to a server\n\nMCP servers running locally are just child processes that speak JSON-RPC over stdin/stdout. The SDK's `StdioClientTransport`\n\nspawns the process for you:\n\n``` js\nimport { Client } from \"@modelcontextprotocol/sdk/client/index.js\";\nimport { StdioClientTransport } from \"@modelcontextprotocol/sdk/client/stdio.js\";\n\nconst transport = new StdioClientTransport({\n  command: process.platform === \"win32\" ? \"npx.cmd\" : \"npx\",\n  args: [\"-y\", \"@modelcontextprotocol/server-filesystem\", `${process.cwd()}/sandbox`],\n});\n\nconst mcp = new Client(\n  { name: \"mcp-claude-client\", version: \"1.0.0\" },\n  { capabilities: {} }\n);\n\nawait mcp.connect(transport);\n```\n\nThe second argument to `Client`\n\nis where you'd declare capabilities like `sampling`\n\nor `roots`\n\nif your client supported them. We don't need any for a tool-calling client.\n\n## 3. Turn MCP tools into Claude tools\n\nOnce connected, ask the server what it can do:\n\n```\nconst { tools: mcpTools } = await mcp.listTools();\nconsole.log(`Discovered ${mcpTools.length} tools:`, mcpTools.map(t => t.name).join(\", \"));\n```\n\nEach tool comes back as `{ name, description, inputSchema }`\n\n, and `inputSchema`\n\nis already plain JSON Schema. Claude's tool-use API wants the same shape under a slightly different key (`input_schema`\n\n), so the conversion is a one-liner:\n\n``` js\nconst anthropicTools = mcpTools.map((tool) => ({\n  name: tool.name,\n  description: tool.description ?? \"\",\n  input_schema: tool.inputSchema,\n}));\n```\n\nThis 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.\n\n## 4. Build the agent loop\n\nClaude doesn't call tools directly, it returns a `tool_use`\n\ncontent block and stops. Your client executes the tool and sends the result back as a `tool_result`\n\n, and you loop until Claude replies with plain text instead of a tool call:\n\n``` python\nimport Anthropic from \"@anthropic-ai/sdk\";\n\nconst anthropic = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });\nconst MODEL = \"claude-3-5-sonnet-20241022\"; // swap for the current model in Anthropic's docs\n\nasync function runAgent(question: string) {\n  const messages: Anthropic.MessageParam[] = [{ role: \"user\", content: question }];\n\n  while (true) {\n    const response = await anthropic.messages.create({\n      model: MODEL,\n      max_tokens: 1024,\n      tools: anthropicTools,\n      messages,\n    });\n\n    messages.push({ role: \"assistant\", content: response.content });\n\n    if (response.stop_reason !== \"tool_use\") {\n      const text = response.content.find((b) => b.type === \"text\");\n      console.log(\"\\nClaude:\", text?.type === \"text\" ? text.text : \"(no text returned)\");\n      return;\n    }\n\n    const toolResults: Anthropic.MessageParam[\"content\"] = [];\n    for (const block of response.content) {\n      if (block.type !== \"tool_use\") continue;\n\n      console.log(`\\n-> calling \"${block.name}\" with`, block.input);\n      const result = await mcp.callTool({\n        name: block.name,\n        arguments: block.input as Record<string, unknown>,\n      });\n\n      toolResults.push({\n        type: \"tool_result\",\n        tool_use_id: block.id,\n        content: result.content as any, // MCP and Anthropic content blocks overlap but aren't identically typed\n        is_error: result.isError ?? false,\n      });\n    }\n\n    messages.push({ role: \"user\", content: toolResults });\n  }\n}\n```\n\nThe `as any`\n\ncast is a deliberate shortcut: MCP tool results and Anthropic tool_result content blocks are both arrays of `{ type: \"text\", text }`\n\n-shaped objects at runtime, but the two SDKs type them slightly differently. Fine for a tutorial; in production, map explicitly.\n\n## 5. Put it all together\n\nSave everything above into `src/main.ts`\n\n, wrap it in a `main()`\n\nfunction, and close the client when done:\n\n```\nimport \"dotenv/config\";\n// ...(imports and code from steps 2-4 go here)\n\nasync function main() {\n  const question = process.argv[2] ?? \"List the files in the sandbox and tell me what's in notes.txt\";\n  await runAgent(question);\n  await mcp.close();\n}\n\nmain().catch((err) => {\n  console.error(err);\n  process.exit(1);\n});\n```\n\nAdd a script to `package.json`\n\n:\n\n```\n\"scripts\": { \"start\": \"tsx src/main.ts\" }\n```\n\n## Verify it works\n\n```\nnpm start -- \"What's in notes.txt?\"\n```\n\nFirst run downloads the filesystem server via `npx`\n\n, so expect a short pause. You should see something like:\n\n```\nDiscovered 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\n\n-> calling \"read_file\" with { path: '.../sandbox/notes.txt' }\n\nClaude: The file contains: \"The launch code is 4-8-15-16-23-42.\"\n```\n\nIf you get a real tool call and a grounded final answer, the whole discover -> call -> respond loop is working end to end.\n\n## Troubleshooting\n\n** ANTHROPIC_API_KEY is not set or 401 from the API** - Make sure\n\n`import \"dotenv/config\"`\n\nis the first import in `main.ts`\n\n, and that `.env`\n\nsits in the project root, not `src/`\n\n.** Error: Access denied - path outside allowed directories** - The filesystem server sandboxes itself to the directories passed as CLI args. Pass an absolute path (\n\n`${process.cwd()}/sandbox`\n\n), and make sure Claude's tool call isn't trying to read something outside it.** spawn npx ENOENT on Windows** - Windows needs\n\n`npx.cmd`\n\n, not `npx`\n\n. 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\n\n`\"type\": \"object\"`\n\nat minimum). This shouldn't happen with the reference server, but if you point the client at a hand-rolled server, validate its `inputSchema`\n\noutput first.## Next steps\n\nSwap the transport for `StreamableHTTPClientTransport`\n\n(also in `@modelcontextprotocol/sdk`\n\n) 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](https://modelcontextprotocol.io) for the other two primitives we skipped here: resources (readable context, not actions) and prompts (reusable templates a server can offer).\n\n[Priya Nair](https://sourcefeed.dev/u/priya_nair)· AI & Developer Experience Writer\n\nPriya 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.\n\n## Discussion 0\n\nNo comments yet\n\nBe the first to weigh in.", "url": "https://wpnews.pro/news/build-an-mcp-client-in-typescript-that-connects-claude-to-any-tool-server", "canonical_source": "https://sourcefeed.dev/a/build-an-mcp-client-in-typescript-that-connects-claude-to-any-tool-server", "published_at": "2026-07-12 17:32:39+00:00", "updated_at": "2026-07-12 17:39:21.716416+00:00", "lang": "en", "topics": ["developer-tools", "artificial-intelligence", "large-language-models", "ai-tools", "ai-agents"], "entities": ["Priya Nair", "Anthropic", "Claude", "Model Context Protocol", "TypeScript", "Node.js", "npm", "JSON-RPC"], "alternates": {"html": "https://wpnews.pro/news/build-an-mcp-client-in-typescript-that-connects-claude-to-any-tool-server", "markdown": "https://wpnews.pro/news/build-an-mcp-client-in-typescript-that-connects-claude-to-any-tool-server.md", "text": "https://wpnews.pro/news/build-an-mcp-client-in-typescript-that-connects-claude-to-any-tool-server.txt", "jsonld": "https://wpnews.pro/news/build-an-mcp-client-in-typescript-that-connects-claude-to-any-tool-server.jsonld"}}