How Do I Build a TypeScript MCP Server That Exposes My Internal API to Claude?

Your internal API already does the thing Claude needs — look up orders, resolve tickets, fetch customer records. The problem is Claude can't reach it. An MCP server written in TypeScript turns your existing internal API into tools Claude can call directly, in the language your team already maintains. Fifty minutes of work to replace the "copy this curl output into Claude" workflow forever.
Why this matters
Every internal tool you've built is a potential Claude capability. The customer-lookup admin page, the deployment trigger, the ticket-assignment endpoint — each is an MCP tool waiting to be exposed. Once it is, Claude can: "find the last three tickets this customer opened, check if any are still unresolved, and draft a follow-up email in our tone."
The Python version of this guide (MCP server Postgres) covers the database case. This guide covers the common case for engineering teams: you have a Node/TypeScript backend and you want Claude to use it, not raw SQL. TypeScript MCP servers share your existing types, run in your existing runtime, and don't introduce a second language to your stack.
Before you start
You need:
- Node 20+.
- A TypeScript project with a backend you're comfortable adding a new directory to. Could be your main API repo or a new sidecar service.
- An internal API you want to expose. Even one endpoint is enough to learn the pattern.
- Claude Desktop or Claude Code with connector config access.
Step 1: Install the MCP SDK
In your TypeScript project:
npm install @modelcontextprotocol/sdk zodThe SDK handles the MCP protocol (message framing, capability negotiation, tool listing). zod is for input schema validation — the SDK integrates with it natively and you get type-safe tool handlers as a result.
Step 2: Scaffold the server
Create mcp-server/index.ts:
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
const server = new Server(
{ name: "acme-internal", version: "0.1.0" },
{ capabilities: { tools: {} } }
);
// Tools registered here — see Step 3.
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
console.error("acme-internal MCP server listening on stdio");
}
main().catch((err) => {
console.error(err);
process.exit(1);
});Stdio transport means Claude launches your server as a subprocess and talks to it over stdin/stdout. That's the simplest deployment — no ports, no auth dance. You can move to HTTP transport later if you need a shared server.
Step 3: Register a tool
Add the handlers above main():
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
const LookupCustomerInput = z.object({
email: z.string().email(),
});
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [
{
name: "lookup_customer",
description: "Find a customer by email address. Returns id, name, plan, and signup date.",
inputSchema: {
type: "object",
properties: {
email: { type: "string", description: "Customer email" },
},
required: ["email"],
},
},
],
}));
server.setRequestHandler(CallToolRequestSchema, async (request) => {
if (request.params.name === "lookup_customer") {
const { email } = LookupCustomerInput.parse(request.params.arguments);
const customer = await callInternalApi(`/admin/customers/lookup?email=${encodeURIComponent(email)}`);
return {
content: [{ type: "text", text: JSON.stringify(customer, null, 2) }],
};
}
throw new Error(`Unknown tool: ${request.params.name}`);
});callInternalApi is a thin wrapper around fetch with your internal API base URL and auth token:
async function callInternalApi(path: string) {
const res = await fetch(`${process.env.INTERNAL_API_URL}${path}`, {
headers: { Authorization: `Bearer ${process.env.INTERNAL_API_TOKEN}` },
});
if (!res.ok) {
throw new Error(`Internal API ${res.status}: ${await res.text()}`);
}
return res.json();
}The token lives in your environment, never in the MCP config. Claude talks to the MCP server; the MCP server talks to your API with credentials Claude never sees.
Step 4: Add a tool that mutates (carefully)
Read tools are easy. Write tools need care. A create_support_ticket tool:
const CreateTicketInput = z.object({
customerId: z.string(),
subject: z.string().min(1).max(200),
body: z.string().min(10).max(5000),
priority: z.enum(["low", "normal", "high"]).default("normal"),
});
// In your ListTools response, add:
// { name: "create_support_ticket", description: "...", inputSchema: ... }
// In CallTool:
if (request.params.name === "create_support_ticket") {
const input = CreateTicketInput.parse(request.params.arguments);
const ticket = await callInternalApi("/admin/tickets", {
method: "POST",
body: JSON.stringify(input),
});
return { content: [{ type: "text", text: `Created ticket #${ticket.id}` }] };
}Two guardrails on write tools:
- Tight input validation. Zod schema with length limits and enums. Never accept freeform objects.
- Audit log on the API side. Every MCP-driven write gets logged with a "source: mcp" tag. When you're debugging "why was this ticket created," you want to know Claude did it, not a human.
Step 5: Wire the server into Claude
Build the server once:
npx tsc mcp-server/index.ts --outDir mcp-server/dist --module nodenext --target es2022Add to Claude Desktop's config (~/Library/Application Support/Claude/claude_desktop_config.json) or your Claude Code .claude/mcp-config.json:
{
"mcpServers": {
"acme-internal": {
"command": "node",
"args": ["/absolute/path/to/mcp-server/dist/index.js"],
"env": {
"INTERNAL_API_URL": "https://internal.acme.com",
"INTERNAL_API_TOKEN": "prod-scoped-token-here"
}
}
}
}Restart Claude. In a new chat, ask: "Look up the customer with email [email protected]." Claude should invoke lookup_customer and show the result.
Step 6: Iterate — add tools as needed
Resist the urge to expose everything on day one. Start with two or three tools, use them for a week, notice what you wish Claude could do, add that as a new tool. The signal you got it right: you stop copying things out of your admin panel.
A typical mature server ends up with 8–15 tools: lookups, one or two mutations, and a handful of domain-specific operations ("check deployment status for environment X," "list recent failed jobs").
Verify it worked
1. Server starts without errors. Run node mcp-server/dist/index.js directly — you should see "listening on stdio" and no crashes. Ctrl-C to quit.
2. Claude lists your tools. In Claude, type /mcp or the connector settings panel should show acme-internal with the tools you registered.
3. A real tool call returns a real result. The lookup above is the canonical test. If the data matches your API, you're wired correctly.
Where this breaks
- The build step getting out of sync. Running old compiled JS after editing the TS is the #1 "my changes aren't taking effect" cause. Add a
prestartscript or watch compile, or justtsxthe entry point directly during development. - Env vars not propagating. The
envblock in the MCP config is the only env your server sees..envfiles in the project directory aren't loaded unless you load them yourself. Put creds in the MCP config or explicitlydotenv.config()in your server. - Token leakage in tool outputs. Be careful when a tool echoes API responses verbatim — if your internal API includes internal IDs, cursor tokens, or other leaky fields, filter them before returning. The output ends up in the conversation and, if training is on, potentially in the model.
- Tools that do too much. A single
do_customer_stufftool with 15 optional parameters is harder for Claude to use correctly than five focused tools. Split by intent. - Schema drift. When your internal API changes a field name, the MCP tool silently returns the new shape and Claude's reasoning breaks. Version your tools and pin to a specific API version header if you can.
What to try next
- How Do I Build an MCP Server That Lets Claude Query My Postgres Database? — the database sibling of this pattern, for direct SQL access with a locked-down role.
- How Do I Set Up a Claude MCP Stack for SEO Work? — consumer side: wiring up pre-built MCPs from the MCP Directory instead of rolling your own.
- How Do I Set Up Claude Code Hooks for Auto-Quality? — enforce that every MCP tool file passes a schema lint before it ships.
Let's talk about your AI + SEO stack
If you'd rather skip the how-to and have it shipped for you, that's what I do. Start a conversation and we'll figure out the fastest path to results.
Let's Talk