Connecting Legacy APIs to Claude Code with an MCP Server — Building a Custom Server from Scratch with the TypeScript SDK
Imagine your company has a five-year-old Java-based order management API. You want to feed natural language instructions like "grab all open orders from this sprint and fix the code" into Claude Code, but you have no idea how an AI would know to interact with your internal API. That was exactly where I was at first. The bridge that makes this possible is MCP (Model Context Protocol).
MCP is an open standard protocol Anthropic released in late 2024 that standardizes how AI models interact with external tools, data, and APIs. Previously, every team would write separate scripts or bespoke wrappers for internal APIs — with MCP, a single server supports Claude Code, Cursor, and Windsurf simultaneously. Build a custom MCP server once, and Claude Code transforms into a team AI assistant that can directly work with your legacy APIs, private databases, and proprietary workflow tools.
This article walks through building an MCP server from scratch with the TypeScript SDK — from project initialization, to registering tools, to connecting with Claude Code, and the security points that are easy to miss in practice. We'll briefly introduce the Resources and Prompts primitives as well, but this article focuses on Tools, the most immediately useful of the three. Resources and Prompts will be covered in a separate article.
Core Concepts
MCP Architecture: Keep These Three Layers in Mind
Honestly, it took me a bit of time to understand this structure at first, but once it clicks, everything else becomes much easier.
| Layer | Role | Example |
|---|---|---|
| Host | The AI application that consumes MCP | Claude Code, Claude Desktop |
| Client | The connector inside the Host that communicates with MCP servers | Protocol handler (internal implementation) |
| Server | The server the developer implements directly | What we're building today |
The layer we'll focus on is the Server layer. This server exposes three primitives to Claude.
| Primitive | Role | Practical Example |
|---|---|---|
| Tools | Functions and actions Claude can invoke | DB queries, API calls, deployment triggers |
| Resources | Data sources Claude can read | Team wikis, DB schemas, config files |
| Prompts | Reusable prompt templates | Code review forms, bug report formats |
Communication Protocol MCP operates on top of JSON-RPC 2.0. The SDK handles this protocol for you so you won't need to deal with it directly, but for local development environments use
stdio(subprocess standard I/O), and for team-shared remote servers useStreamable HTTP. The March 2025 spec update replaced the previous HTTP+SSE approach with Streamable HTTP, so if you're starting fresh, go with Streamable HTTP from the beginning.
Project Setup
First, install the packages. Since Example 2 requires direct DB access, install postgres as well. postgres is a lightweight SQL client based on node-postgres. If your environment uses an ORM like Prisma or Drizzle, you can substitute the appropriate package.
mkdir my-mcp-server && cd my-mcp-server
pnpm init
pnpm add @modelcontextprotocol/sdk zod postgres
pnpm add -D typescript @types/node tsxHere, zod is a TypeScript runtime schema validation library. Since the MCP SDK uses Zod schemas directly for validating tool inputs, install it alongside.
Set up tsconfig.json like this:
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"outDir": "./dist",
"strict": true,
"esModuleInterop": true
},
"include": ["src"]
}Also add "type": "module" and build scripts to package.json:
{
"type": "module",
"main": "dist/index.js",
"scripts": {
"build": "tsc",
"dev": "tsx src/index.ts"
}
}The Basic Skeleton of an MCP Server
The server skeleton is simpler than you might expect:
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
const server = new McpServer({
name: "my-legacy-api",
version: "1.0.0",
});
// Tool registration is covered in the practical examples below
const transport = new StdioServerTransport();
await server.connect(transport);It's just three steps: create a McpServer instance, register tools, and connect to a transport.
SDK Version Note The examples below use the
server.tool()API based on@modelcontextprotocol/sdkv1.x. Some older tutorials useserver.registerTool(), but the current official SDK documentation is based onserver.tool(). If code you've copied verbatim isn't working, check the SDK version first.
Practical Application
Example 1: Wrapping an Internal Legacy REST API as a Tool
This is the most common pattern in practice — placing an MCP layer in front of an old Java service or PHP legacy API.
Validating required environment variables at server startup prevents the server from silently starting without process.env.API_KEY set.
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
// Validate required environment variables before server start
const requiredEnvVars = ["API_BASE_URL", "API_KEY"] as const;
for (const key of requiredEnvVars) {
if (!process.env[key]) {
throw new Error(`Environment variable ${key} is not set`);
}
}
const API_BASE = process.env.API_BASE_URL as string;
const server = new McpServer({
name: "order-management-api",
version: "1.0.0",
});
// Order lookup tool
server.tool(
"get_order",
{
description:
"Retrieves order details by order ID, including status, items, and shipping information.",
inputSchema: {
orderId: z.string().describe("The order ID to look up (e.g., ORD-2024-00123)"),
},
},
async ({ orderId }) => {
try {
const res = await fetch(`${API_BASE}/orders/${orderId}`, {
headers: { Authorization: `Bearer ${process.env.API_KEY}` },
});
if (!res.ok) {
return {
content: [
{
type: "text",
text: `Order lookup failed: ${res.status} ${res.statusText}`,
},
],
isError: true,
};
}
const data = await res.json();
return {
content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
};
} catch (err) {
// Handles errors thrown by fetch itself: network timeouts, DNS failures, etc.
return {
content: [
{ type: "text", text: `Network error: ${(err as Error).message}` },
],
isError: true,
};
}
}
);
// Order status update tool
server.tool(
"update_order_status",
{
description: "Updates the processing status of an order.",
inputSchema: {
orderId: z.string(),
status: z.enum(["processing", "shipped", "delivered", "cancelled"]),
note: z.string().optional().describe("Reason for status change (optional)"),
},
},
async ({ orderId, status, note }) => {
try {
const res = await fetch(`${API_BASE}/orders/${orderId}/status`, {
method: "PATCH",
headers: {
Authorization: `Bearer ${process.env.API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ status, note }),
});
if (!res.ok) {
return {
content: [
{
type: "text",
text: `Status update failed: ${res.status} ${res.statusText}`,
},
],
isError: true,
};
}
const result = await res.json();
return {
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
};
} catch (err) {
return {
content: [
{ type: "text", text: `Network error: ${(err as Error).message}` },
],
isError: true,
};
}
}
);
const transport = new StdioServerTransport();
await server.connect(transport);Because MCP servers are called automatically by Claude, if the process crashes without error handling, the entire Claude session stops. That's why it matters to wrap every async handler in a try/catch.
| Point | Description |
|---|---|
description |
The basis Claude uses to decide when to use a tool. The more specific, the better |
z.enum(...) |
Restricts allowed values to an enum to block invalid inputs |
isError: true |
Clearly signals error responses to Claude, which can help its retry logic |
process.env.* |
Credentials must always be injected via environment variables. Once an API key ends up in git, removing it from history is far more painful than you'd expect |
Example 2: A Tool That Queries the DB Directly (Security-Hardened Version)
Tools with direct DB access are especially risky due to SQL injection. I once thought "it's an internal network, it should be fine" — and then got thoroughly called out in a team code review. Using parameterized queries together with an allowlist approach is the safe way.
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import postgres from "postgres";
// Throw an error immediately if DATABASE_URL is missing at startup
if (!process.env.DATABASE_URL) {
throw new Error("Environment variable DATABASE_URL is not set");
}
const sql = postgres(process.env.DATABASE_URL);
const server = new McpServer({ name: "db-query-server", version: "1.0.0" });
server.tool(
"query_orders",
{
description: "Queries the orders table and returns a list of orders matching the given conditions.",
inputSchema: {
status: z
.enum(["pending", "processing", "shipped", "delivered"])
.optional(),
limit: z.number().int().min(1).max(100).default(20),
},
},
async ({ status, limit }) => {
try {
// Dynamic WHERE clauses are also handled as parameterized queries
const rows = status
? await sql`SELECT * FROM orders WHERE status = ${status} LIMIT ${limit}`
: await sql`SELECT * FROM orders LIMIT ${limit}`;
return {
content: [
{
type: "text",
text: `${rows.length} record(s) found\n\n${JSON.stringify(rows, null, 2)}`,
},
],
};
} catch (err) {
return {
content: [
{ type: "text", text: `DB query error: ${(err as Error).message}` },
],
isError: true,
};
}
}
);
const transport = new StdioServerTransport();
await server.connect(transport);The postgres library automatically converts template literal queries into parameterized queries. If you're already using an ORM like Prisma or Drizzle, you can use that directly. This example simply shows the approach with the fewest dependencies.
Example 3: Registering the MCP Server with Claude Code and Sharing It with Your Team
Here's how to build the server and register it with Claude Code.
# Build
pnpm build
# Register for your local environment only
claude mcp add my-order-api -- node /absolute/path/to/dist/index.js
# Share with the whole team (creates .mcp.json in the project root)
claude mcp add --scope project my-order-api -- node dist/index.jsCommitting the .mcp.json created in the project root to git gives every team member the same MCP configuration immediately.
{
"mcpServers": {
"my-order-api": {
"command": "node",
"args": ["./mcp-server/dist/index.js"],
"env": {
"API_BASE_URL": "https://internal-api.company.com",
"API_KEY": "${MY_INTERNAL_API_KEY}"
}
}
}
}MCP Inspector During development,
npx @modelcontextprotocol/inspectoris a great way to verify that your tools are working correctly. Like Postman, it lets you invoke tools, resources, and prompts directly from a browser UI and inspect responses at the JSON-RPC level — which significantly speeds up development. The default ports are 6274 for the UI and 6277 for the proxy.
Pros and Cons
Advantages
| Item | Details |
|---|---|
| Standardization | The same server can be reused across any MCP-compatible client — Claude, Cursor, Windsurf, and others |
| Legacy integration | Just add an MCP layer on top of existing REST/SOAP APIs to enable AI access. No need to rewrite the legacy system |
| Type safety | Zod schemas validate tool inputs at runtime and synergize well with TypeScript strict mode |
| Team collaboration | Commit .mcp.json to git and the entire team shares the same AI tooling environment |
| Vendor independence | No lock-in to a specific AI platform, keeping the cost of switching low |
Disadvantages and Caveats
| Item | Details | Mitigation |
|---|---|---|
| Operational overhead | Managing deployments and monitoring becomes complex as the number of MCP servers grows into the dozens | Consolidating servers by domain is effective. Our team grouped orders, shipping, and billing into three servers, which dramatically reduced the management burden |
| Security risk | Incidents of API keys and passwords being exposed in bulk through misconfigured MCP servers are reported regularly | Inject credentials via environment variables and never hardcode them in source code |
| Prompt injection | Malicious data can be injected into Claude through MCP tool responses | Treat tool response data as untrusted external input |
| Rapid protocol changes | The spec changes quickly, as seen with the HTTP+SSE → Streamable HTTP transition | Check SDK release notes regularly and share changes with the team |
| Tool call consistency | LLMs can call tools inconsistently in multi-step tasks | Writing specific description fields and clearly separating each tool's scope of responsibility makes a significant improvement |
The Confused Deputy Problem This actually happens. If a "lookup order" tool internally uses an API key that also has delete permissions, Claude can inadvertently trigger a delete operation. Once that happens, it's difficult to undo. Applying the principle of least privilege per tool is the right approach. A practical and safe structure is: read-only API keys for read-only tools, and separately issued write keys for write tools.
The Most Common Mistakes in Practice
-
Writing vague
descriptionfields: Claude decides which tool to use based on the description. Vague descriptions like "retrieve data" don't help — write something specific like "retrieves order status, items, and shipping tracking information by order ID" so Claude calls it in the right situations. -
Hardcoding credentials in source code: Use the
envfield in.mcp.jsonto inject them as environment variables. Once an API key ends up in git history, fully removing it is far more troublesome than you'd think. -
Passing inputs directly to shell commands or SQL without validation: Use
z.enum()to restrict an allowlist of values, and always use parameterized queries for DB access. "It's on an internal network, it should be fine" is an assumption I once trusted — and regretted.
Closing Thoughts
Implementing an MCP server yourself turns Claude Code into a team AI assistant that understands and can actually operate your internal legacy systems. No need to rewrite the legacy — just build a thin adapter layer in TypeScript.
Here are 3 steps you can start with right now:
-
Initialize your dev environment: Install packages with
pnpm add @modelcontextprotocol/sdk zodand copy thetsconfig.jsonconfiguration above directly into your project. -
Register your first tool: Pick one of your team's most-used internal APIs and wrap it with
server.tool(). Spin upnpx @modelcontextprotocol/inspectorto verify the behavior right in your browser. -
Connect to Claude Code: Register it in your project with
claude mcp add --scope project [name] -- node dist/index.js, then commit.mcp.jsonto your team's git repository so everyone gets the same environment.
The first time you give Claude a natural language instruction and watch your internal legacy system respond, it's a genuinely strange feeling. I hope you get to experience that moment for yourself. And as your tools multiply, a new set of questions begins — how to structure them and build an environment where the whole team shares a single server.
References
- Model Context Protocol Official Docs — Build an MCP Server
- MCP TypeScript SDK Official Docs
- modelcontextprotocol/typescript-sdk (GitHub)
- Connect Claude Code to tools via MCP — Claude Code Official Docs
- Remote MCP servers — Claude API Official Docs
- FreeCodeCamp — How to Build a Custom MCP Server with TypeScript
- Build an MCP Server in TypeScript: From Scratch 2026 — Digital Applied
- Building MCP Servers: Custom Context for Claude Code — SitePoint
- MCP Inspector — Official Docs
- MCP Security Best Practices — modelcontextprotocol.io
- Model Context Protocol Security Risks and Controls — Red Hat
- The Pros and Cons of Adopting MCP Today — Knit