Back to guides

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

Jake McCluskeyIntermediate50 min read
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:

bash
npm install @modelcontextprotocol/sdk zod

The 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:

typescript
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():

typescript
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:

typescript
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:

typescript
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:

  1. Tight input validation. Zod schema with length limits and enums. Never accept freeform objects.
  2. 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:

bash
npx tsc mcp-server/index.ts --outDir mcp-server/dist --module nodenext --target es2022

Add to Claude Desktop's config (~/Library/Application Support/Claude/claude_desktop_config.json) or your Claude Code .claude/mcp-config.json:

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 prestart script or watch compile, or just tsx the entry point directly during development.
  • Env vars not propagating. The env block in the MCP config is the only env your server sees. .env files in the project directory aren't loaded unless you load them yourself. Put creds in the MCP config or explicitly dotenv.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_stuff tool 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

Want this built for you instead?

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
Questions from readers

Frequently asked

Why TypeScript instead of Python for the MCP server?

Use the language your backend is already in. TypeScript MCP servers share your existing types, fit into your existing build and deploy pipeline, and don't add a second language to maintain. The Python SDK is equally capable; the decision is stack alignment, not SDK features.

What's the difference between stdio and HTTP transport?

Stdio means Claude launches your server as a subprocess and talks over stdin/stdout — simplest setup, one client per server. HTTP means the server runs independently and any number of Claude sessions can connect. Start with stdio; migrate to HTTP only when you need multi-client or remote access.

How do I stop Claude from calling a destructive tool by accident?

Three layers. Tight Zod schemas refuse bad input. Tool descriptions explicitly state when to use them ('Only use when the user has confirmed they want to create a ticket'). Claude Code's interactive prompt-before-action catches the last case. For unattended use, prefer read-only tools.

Can I expose my production API safely?

Yes, with the right token scope. Create a token that only permits the operations you're exposing as tools — never use your root admin token. Log every MCP-driven call on the API side with a 'source: mcp' tag so you can audit and rate-limit specifically.

What about authentication — does Claude see my token?

No. The token lives in the MCP server's environment. Claude sends tool-call arguments to the server; the server adds the Authorization header when it hits your API. Claude never sees the credential.