Menu

AI

How to Build an MCP Server for an Internal API (TypeScript SDK v2)

Expose a private company API to AI tools safely. Build a TypeScript MCP server with strict input schemas, a service token, readable errors, and an HTTP mode.

By: Irina ShvayaNovember 2, 2026

How to Build an MCP Server for an Internal API (TypeScript SDK v2)

This tutorial is part of Build an MCP Server, within The MCP Guide. Want it built for you? See MCP development.

To build an MCP server for an internal API, you write a thin TypeScript service that holds the API's credentials and exposes a few tightly typed tools. Staff connect their AI app to the MCP server, never to the internal API directly. The server checks every input, calls the API with its own token, and returns short, readable results.

This guide uses the official TypeScript SDK, version 2, and a small inventory API as the example. You will build a read tool and a write tool, run the server over stdio for one person, then serve the same code over HTTP for a team.

Checked against: the TypeScript SDK repository and docs on 2 November 2026, with @modelcontextprotocol/server 2.4.0, @modelcontextprotocol/node 2.1.2, zod 4.6.7 and Node.js 24. The code ran locally on 2 November 2026 against a mock inventory API, tested with MCP Inspector 2.13.0.

Key Takeaways

  • The MCP server is a gatekeeper. It holds the service token; the model and the AI app never see it.
  • Strict Zod schemas, such as a regex for SKUs, stop bad or hostile input before your handler runs.
  • In the v2 TypeScript SDK, an error thrown in a tool handler comes back to the model as a tool result with isError: true.
  • Start on stdio for one user. Move to createMcpHandler over HTTP when a team needs it, and put sign-in in front.
  • Install @modelcontextprotocol/server. Tutorials that use @modelcontextprotocol/sdk and server.tool() are written for v1.

Why internal APIs need extra care

Internal APIs were often built on the assumption that only trusted code calls them. They may skip input checks, return more fields than needed, or allow powerful writes. Putting an AI model in front of one changes that assumption.

The model chooses tool arguments, and it can be steered by text it reads elsewhere. That is the core of prompt injection. So the MCP server should accept only the inputs each job needs, in a strict format, and expose only safe operations.

Prerequisites

  • Node.js 20 or newer for the SDK (npm lists engines as >=20), and 22.19+ for the Inspector.
  • An internal API with a service account or token that has only the rights the tools need.
  • Basic TypeScript. Our TypeScript first-tool lesson covers the SDK basics.

Install

mkdir inventory-mcp && cd inventory-mcp
npm init -y && npm pkg set type=module
npm install @modelcontextprotocol/server @modelcontextprotocol/node zod
npm install -D tsx typescript

The TypeScript SDK README splits v2 into packages. @modelcontextprotocol/server builds servers, and @modelcontextprotocol/node adapts them to Node's HTTP server. On npm on 2 November 2026, the v1 package @modelcontextprotocol/sdk was at 1.32.2, while the v2 server package was at 2.4.0.

Minimal working server

Create src/server.ts:

import { McpServer } from '@modelcontextprotocol/server';
import * as z from 'zod/v4';

const API_URL = process.env.INTERNAL_API_URL ?? 'http://127.0.0.1:4010';
const API_TOKEN = process.env.INTERNAL_API_TOKEN;
if (!API_TOKEN) {
    console.error('INTERNAL_API_TOKEN is not set');
    process.exit(1);
}

async function callApi(method: string, path: string, body?: unknown): Promise<unknown> {
    const res = await fetch(`${API_URL}${path}`, {
        method,
        headers: { authorization: `Bearer ${API_TOKEN}`, 'content-type': 'application/json' },
        body: body === undefined ? undefined : JSON.stringify(body),
        signal: AbortSignal.timeout(10_000)
    });
    if (res.status === 404) throw new Error(`Not found: ${path}`);
    if (res.status === 409) throw new Error('Not enough stock for that reservation.');
    if (!res.ok) throw new Error(`Inventory API returned ${res.status}`);
    return res.json();
}

export function createServer(): McpServer {
    const server = new McpServer({ name: 'inventory', version: '1.0.0' });

    server.registerTool(
        'get_stock_level',
        {
            description: 'Get the on-hand quantity and warehouse for one SKU, e.g. SKU-100.',
            inputSchema: z.object({ sku: z.string().regex(/^SKU-\d{3,6}$/).describe('Product SKU') }),
            annotations: { readOnlyHint: true, openWorldHint: false }
        },
        async ({ sku }) => {
            const item = await callApi('GET', `/v1/items/${encodeURIComponent(sku)}`);
            return { content: [{ type: 'text', text: JSON.stringify(item) }] };
        }
    );

    return server;
}

And src/stdio.ts:

import { serveStdio } from '@modelcontextprotocol/server/stdio';
import { createServer } from './server.js';

void serveStdio(createServer);
console.error('inventory MCP server running on stdio');

How it works:

  • callApi is the only code that talks to the internal API. It adds the token, sets a 10-second timeout, and turns status codes into plain messages.
  • registerTool takes a name, a config and a handler. The SDK's Tools guide says the Zod inputSchema drives the JSON Schema the model sees, validates arguments before the handler runs, and types the handler's arguments.
  • The SKU regex is a security control. A value like ../admin never reaches your URL.
  • createServer is a factory. serveStdio calls it, and later the HTTP handler will too. The same tools serve both transports.
  • console.error writes to stderr. On stdio, stdout carries the protocol, so never use console.log.

Add a write tool

Add this inside createServer, before return server:

server.registerTool(
    'reserve_stock',
    {
        description: 'Reserve units of a SKU for an order. Confirm SKU and quantity with the user first.',
        inputSchema: z.object({
            sku: z.string().regex(/^SKU-\d{3,6}$/),
            qty: z.number().int().min(1).max(50)
        }),
        annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }
    },
    async ({ sku, qty }) => {
        const reservation = await callApi('POST', '/v1/reservations', { sku, qty });
        return { content: [{ type: 'text', text: JSON.stringify(reservation) }] };
    }
);

The max(50) cap is a business rule in code. Even if the model is convinced to reserve 10,000 units, the call fails validation. The annotations tell hosts this tool changes data and is not safe to repeat, so a careful host asks the user first. Our page on MCP tool annotations explains each hint.

Test with the MCP Inspector

Run your API (or a mock), then call the tools through the Inspector CLI:

npx @modelcontextprotocol/inspector --cli -e INTERNAL_API_TOKEN=svc-token-123 \
  npx tsx src/stdio.ts --protocol-era modern \
  --method tools/call --tool-name get_stock_level \
  --tool-args-json '{"sku":"SKU-100"}' --format json

Results from the mock API on 2 November 2026:

get_stock_level SKU-100  -> {"sku":"SKU-100","name":"Desk lamp","on_hand":12,"warehouse":"DAL-1"}
reserve_stock SKU-200 x2 -> isError: true, "Not enough stock for that reservation."
get_stock_level ../admin -> isError: true, "Input validation error: Invalid arguments for tool get_stock_level: sku: Invalid string: must match pattern /^SKU-\d{3,6}$/"
wrong service token      -> isError: true, "Inventory API returned 401"

The Inspector exited with code 5 for each tool error. Every failure came back as a result the model can read, and the path-style SKU never reached the API. The SDK's errors guide says "the SDK catches anything a tool handler throws and converts it to the same isError: true shape." Our Inspector lesson shows how to put checks like these in CI.

Serve it over HTTP for a team

A stdio server runs on each person's machine and needs the service token there. For a team, run one server centrally instead. Create src/http.ts:

import { createServer as createHttpServer } from 'node:http';
import { createMcpHandler } from '@modelcontextprotocol/server';
import { localhostHostValidation, localhostOriginValidation, toNodeHandler } from '@modelcontextprotocol/node';
import { createServer } from './server.js';

const handler = createMcpHandler(createServer);
const nodeHandler = toNodeHandler(handler);
const validateHost = localhostHostValidation();
const validateOrigin = localhostOriginValidation();

createHttpServer((req, res) => {
    if (!validateHost(req, res) || !validateOrigin(req, res)) return;
    void nodeHandler(req, res);
}).listen(3000, '127.0.0.1');

This follows the SDK's Serve over HTTP guide. The guide says the handler "validates no Host header, no Origin header, and no token," so you mount those checks in front. On 2 November 2026, an Inspector call over HTTP returned stock for SKU-200, and a request with Host: evil.example got 403.

Before other people can reach it:

  1. Add sign-in. The guide says to verify the bearer token in front of the handler and pass the result as authInfo. Tools then read the caller from ctx.http.authInfo. See adding OAuth to a remote MCP server.
  2. Bind to the right interface and allow only your internal hostname. The SDK's Express guide shows createMcpExpressApp({ host: '0.0.0.0', allowedHosts: [...] }) for this, and says to pass allowedHosts when binding to 0.0.0.0.
  3. Keep it on the internal network, reachable through your VPN or zero-trust gateway.
  4. Log each tool call with the user from authInfo, the tool and the arguments.

Connect it to a host

For a local stdio setup in Claude Code:

claude mcp add -e INTERNAL_API_TOKEN=svc-token-123 --transport stdio inventory \
  -- npx tsx /ABSOLUTE/PATH/inventory-mcp/src/stdio.ts

For the HTTP server, claude mcp add --transport http inventory https://mcp.internal.example.com/mcp. Our MCP config generator writes configs for other clients.

Deploy notes

  • Compile for production. Build with tsc and run node build/http.js, rather than tsx.
  • Use one service account per MCP server, with only the API rights these tools need.
  • Never forward the user's MCP token to the internal API. The MCP security best practices call that token passthrough and say servers "MUST NOT accept any tokens that were not explicitly issued for the MCP server." If the API needs to know the user, pass the verified user ID as data, or exchange the token through your identity provider. Our page on the confused deputy problem explains why.
  • Version the tool set. Removing or renaming a tool breaks saved prompts. Treat changes like an API release.

Pitfalls

  1. v1 tutorials. import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' and server.tool(...) are v1 patterns. v2 uses @modelcontextprotocol/server and registerTool.
  2. Old HTTP wiring. The SDK's HTTP guide says createMcpHandler replaces the per-request StreamableHTTPServerTransport plus connect() setup. Old SSE server examples are outdated too.
  3. Loose strings. z.string() with no pattern lets anything through. Constrain IDs, enums and lengths.
  4. Returning internal fields. Strip cost prices, internal notes and user IDs the job does not need.
  5. console.log on stdio. It corrupts the protocol stream.

Next step

Work through the free Learn MCP course for resources, OAuth and deployment. If you want help deciding which internal systems to expose and how to secure them, eSEOspace MCP development can run that planning with your IT team. For public APIs, see building an MCP server for a REST API.

More in the MCP Guide

Free tools: server.json Validator · MCP Error Code Lookup

Latest research: State of MCP: October 2026

Frequently asked questions

Can I build this in Python instead?

Yes. The Python SDK v2 follows the same pattern with MCPServer and @mcp.tool(). Our REST API guide shows the Python version with httpx.

Does the AI provider see my internal data?

The tool results go into the model's context, so they reach whichever model your AI app uses. Return only the fields the job needs, and check your AI provider's data terms.

Should the MCP server run inside the firewall?

Yes, if the API is internal. Remote AI apps then need a route to it. Many teams use a gateway or a local client such as Claude Code that runs on the company network.

How do I let only some staff use the write tool?

Check the caller's scopes or group inside the tool handler, using authInfo from the verified token. Refuse with a clear error when the caller lacks the right.

What about rate limits on the internal API?

Add limits in the MCP server too, per user. A model in a loop can call a tool many times quickly.

You Might Also like to Read

Get a FREE Website Audit

Enter your website and get a 20–30 page SEO, GEO, AEO & CRO audit within 24 hours. Completely free.

Don't have a site yet? Get in touch →

Ready to speak with a marketing expert?
Give us a call! (916) 866-7893

Websites Launched
1407
Websites Launched
Marketing Experts
29
Marketing Experts
Hours of Dedicated Work
204533
Hours of Dedicated Work
Irina S.Benjamin G.Jeanette M.David G.