Build an MCP Server for an Internal API
为内部 API 构建 MCP Server
To let Claude Desktop or Cursor use a private REST API, put a small stdio MCP server in front of it: a few task-shaped tools, one HTTP helper that adds the base URL, token and timeout, and errors returned as tool results the model can read. Internal APIs rarely have an OpenAPI spec, and you do not need one.
This guide wraps an example helpdesk API in TypeScript and Python, starting from the scaffold MCP Server Generator downloads.
Both versions were run on 2026-10-18 (@modelcontextprotocol/sdk 1.32.1 on Node 20, mcp 2.3.0 with httpx 0.28.1 on Python 3.13) against a mock API, through an MCP client over stdio.
想让 Claude Desktop 或 Cursor 调用公司内部的 REST API,只需在它前面放一个小型 stdio MCP server:几个按任务设计的工具、一个统一处理地址/令牌/超时的 HTTP 辅助函数,并把错误作为工具结果返回给模型。内部 API 通常没有 OpenAPI 文档,也不需要。下文用 TypeScript 和 Python 包装一个示例工单 API,代码均已实测。
1. Pick tools, not endpoints
第一步:按任务设计工具,而不是照搬端点
The model reads every tool name, description and schema on every turn, so a tool list should look like the jobs people ask for, not like your route table. Our example is an internal helpdesk with three routes:
| Internal route | MCP tool | Parameters |
|---|---|---|
GET /tickets?q=&status= | search_tickets | query (string, required), status (string, optional) |
GET /tickets/{id} | get_ticket | ticket_id (string, required) |
POST /tickets/{id}/comments | add_ticket_comment | ticket_id, body (strings, required) |
Rules that hold up on real internal APIs:
- Start read-only. Ship search and get tools first; add one write tool once you trust the setup. Leave delete and bulk operations out.
- Hide plumbing. Auth headers, tenant IDs, pagination cursors and API versions belong in the server, never in tool parameters.
- Write descriptions for the model. “Ticket ID, e.g. T-1042” tells it the format; “id” makes it guess.
- Keep it to roughly 3–15 tools. If you need dozens and already have a spec, read form generator vs OpenAPI→MCP tools.
2. Generate the scaffold
第二步:生成项目骨架
Open the generator form, name the server helpdesk-api, and enter the three tools from the table (untick required on status). Pick TypeScript or Python and download the ZIP. You get a project where every tool is registered with its schema and the handler is a placeholder:
src/index.ts as generated (one tool shown)
server.registerTool(
"search_tickets",
{
description: "Search internal support tickets by keyword and optional status",
inputSchema: z.object({
query: z.string().describe("Keywords to match in the ticket title"),
status: z.string().describe("open, pending or closed").optional()
}),
},
async ({ query, status }) => {
// TODO: replace this placeholder with real logic for search_tickets
const text = "[search_tickets] " + "query=" + JSON.stringify(query) + ", " + "status=" + JSON.stringify(status);
return {
content: [{ type: "text", text }],
};
}
);
Everything else in the scaffold (McpServer, StdioServerTransport, package.json, the README host config) stays as it is. The template guide explains each generated file. The next step replaces the TODOs with real HTTP calls.
3a. TypeScript: call the internal API
第三步(TypeScript):调用内部 API
Add an api() helper below the McpServer constructor and point each handler at it. Node 18+ has fetch built in, so there are no new dependencies. This is the complete, tested file:
src/index.ts
#!/usr/bin/env node
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: "helpdesk-api",
version: "1.0.0",
});
// --- internal API client: one place for base URL, auth, timeout, errors ---
const API_URL = process.env.HELPDESK_API_URL;
const API_TOKEN = process.env.HELPDESK_API_TOKEN;
if (!API_URL || !API_TOKEN) {
console.error("Set HELPDESK_API_URL and HELPDESK_API_TOKEN");
process.exit(1);
}
async function api(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.ok) {
throw new Error(`${method} ${path} failed: HTTP ${res.status}`);
}
return res.json();
}
// Wrap a call so failures come back as a tool error the model can read.
async function asTool(call: () => Promise<unknown>) {
try {
const data = await call();
const text = JSON.stringify(data, null, 2).slice(0, 20_000);
return { content: [{ type: "text" as const, text }] };
} catch (err) {
return {
content: [{ type: "text" as const, text: String(err) }],
isError: true,
};
}
}
server.registerTool(
"search_tickets",
{
description: "Search internal support tickets by keyword and optional status",
inputSchema: z.object({
query: z.string().describe("Keywords to match in the ticket title"),
status: z.string().describe("open, pending or closed").optional(),
}),
},
async ({ query, status }) => {
const params = new URLSearchParams({ q: query });
if (status) params.set("status", status);
return asTool(() => api("GET", `/tickets?${params}`));
}
);
server.registerTool(
"get_ticket",
{
description: "Get one ticket with status, assignee and latest comments",
inputSchema: z.object({
ticket_id: z.string().describe("Ticket ID, e.g. T-1042"),
}),
},
async ({ ticket_id }) =>
asTool(() => api("GET", `/tickets/${encodeURIComponent(ticket_id)}`))
);
server.registerTool(
"add_ticket_comment",
{
description: "Add an internal comment to a ticket",
inputSchema: z.object({
ticket_id: z.string().describe("Ticket ID, e.g. T-1042"),
body: z.string().describe("Comment text"),
}),
},
async ({ ticket_id, body }) =>
asTool(() =>
api("POST", `/tickets/${encodeURIComponent(ticket_id)}/comments`, { body })
)
);
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
console.error("helpdesk-api MCP server running on stdio");
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
- Config comes from the environment. The server exits at startup with a message on stderr if
HELPDESK_API_URLorHELPDESK_API_TOKENis missing, which shows up in the host's MCP log. asTool()turns failures intoisError: trueresults likeError: GET /tickets/T-9999 failed: HTTP 404, so the model can tell the user, or retry with a different ID, instead of the call failing silently.encodeURIComponentkeeps a model-supplied ID from changing the path, and the output is capped at 20,000 characters so one big record cannot flood the context.
3b. Python: call the internal API
第三步(Python):调用内部 API
Add httpx to requirements.txt (and to dependencies in pyproject.toml if you launch with uv):
requirements.txt
mcp>=2.0.0
httpx>=0.27
Then switch the generated functions to async def and route them through one helper:
server.py
"""MCP server for the internal helpdesk API"""
import json
import os
from urllib.parse import quote
import httpx
from mcp.server import MCPServer
from mcp.server.mcpserver.exceptions import ToolError
mcp = MCPServer("helpdesk-api", version="1.0.0")
# --- internal API client: one place for base URL, auth, timeout, errors ---
API_URL = os.environ["HELPDESK_API_URL"]
API_TOKEN = os.environ["HELPDESK_API_TOKEN"]
async def api(method: str, path: str, **kwargs) -> str:
try:
async with httpx.AsyncClient(
base_url=API_URL,
headers={"Authorization": f"Bearer {API_TOKEN}"},
timeout=10.0,
) as client:
r = await client.request(method, path, **kwargs)
except httpx.HTTPError as exc:
raise ToolError(f"{method} {path} failed: {exc}") from exc
if r.is_error:
# ToolError -> isError: true with this message; other exceptions are hidden.
raise ToolError(f"{method} {path} failed: HTTP {r.status_code}")
return json.dumps(r.json(), indent=2)[:20_000]
@mcp.tool()
async def search_tickets(query: str, status: str = "") -> str:
"""Search internal support tickets by keyword and optional status
Args:
query: Keywords to match in the ticket title
status: open, pending or closed
"""
params = {"q": query}
if status:
params["status"] = status
return await api("GET", "/tickets", params=params)
@mcp.tool()
async def get_ticket(ticket_id: str) -> str:
"""Get one ticket with status, assignee and latest comments
Args:
ticket_id: Ticket ID, e.g. T-1042
"""
return await api("GET", f"/tickets/{quote(ticket_id, safe='')}")
@mcp.tool()
async def add_ticket_comment(ticket_id: str, body: str) -> str:
"""Add an internal comment to a ticket
Args:
ticket_id: Ticket ID, e.g. T-1042
body: Comment text
"""
return await api("POST", f"/tickets/{quote(ticket_id, safe='')}/comments", json={"body": body})
if __name__ == "__main__":
mcp.run()
- Raise
ToolError, not a plain exception. Inmcp2.x aToolErrormessage reaches the model (Error executing tool get_ticket: GET /tickets/T-9999 failed: HTTP 404). Any other exception is treated as a crash, and the model only seesError executing tool get_ticket. - Network failures are caught too (
httpx.HTTPError), so a VPN that is down reads as “All connection attempts failed” rather than a generic error. - On
mcp1.x, importFastMCPfrommcp.server.fastmcpinstead ofMCPServer; the decorators are the same.
4. Pass the URL and token through the host config
第四步:通过主机配置传入地址和令牌
Claude Desktop and Cursor both accept an env object per server. Start from the absolute-path config in the generated README and add it:
claude_desktop_config.json or .cursor/mcp.json
{
"mcpServers": {
"helpdesk-api": {
"command": "/absolute/path/to/helpdesk-api/node_modules/.bin/tsx",
"args": ["/absolute/path/to/helpdesk-api/src/index.ts"],
"env": {
"HELPDESK_API_URL": "https://helpdesk.internal.example.com/api",
"HELPDESK_API_TOKEN": "paste-a-scoped-token-here"
}
}
}
}
For Python, use .venv/bin/python and server.py as command and args, with the same env block. The server runs on your machine, so it reaches the API over whatever network you are on: if the API sits behind a VPN, the VPN has to be up. Config file locations and fixes for servers that do not appear are in connect an MCP server to Claude Desktop and Cursor.
5. Test before you connect a host
第五步:接入主机前先测试
# TypeScript
HELPDESK_API_URL=https://helpdesk.internal.example.com/api \
HELPDESK_API_TOKEN=... \
npx @modelcontextprotocol/inspector npm start
# Python
HELPDESK_API_URL=https://helpdesk.internal.example.com/api \
HELPDESK_API_TOKEN=... \
npx @modelcontextprotocol/inspector .venv/bin/python server.py
In the Inspector, call each tool with a good input, an ID that does not exist, and a wrong token. In our run, both servers returned the matching ticket for search_tickets with {"query": "vpn", "status": "open"}, an isError result with the HTTP status for a 404 and a 401, and a readable error when the API was unreachable. If a tool only works on the happy path, the model will hit the other paths first.
Security checklist for internal APIs
内部 API 安全清单
- Use a dedicated, least-privilege token, read-only if you can. The model can call any tool you register, with any arguments it chooses.
- Keep the config file out of git. The token in
envis stored in plain text in that file. - Treat write tools as actions. Claude Desktop and Cursor can ask you to approve each tool call; keep approval on for tools like
add_ticket_commentinstead of auto-running them. - Trim responses. Return only the fields the task needs, and drop personal data the model has no reason to see.
- Never print to stdout. stdout carries the protocol; log with
console.erroror to stderr.
This is a local stdio server: each person runs their own copy with their own token. Sharing one remote server with a team needs the Streamable HTTP transport plus authentication in front of it, which the generator does not write for you. Parameter types in the form are string, number and boolean; edit the Zod schema or type hints for enums, arrays or objects.
Generate the scaffold for your internal API
为你的内部 API 生成项目骨架
List your tools and parameters in the form and download a TypeScript or Python project with the schemas and host config already written. Free, no account, no OpenAPI.
FAQ
常见问题
Do I need an OpenAPI spec to build an MCP server for an internal API?
No. MCP tools are a name, a description and a typed input schema. You define those for the few tasks you want to expose, and each handler calls your API with fetch or httpx. A spec only helps if you want to generate many tools automatically.
中文: 不需要。MCP 工具只是名称、描述和参数结构;为少数任务定义工具,在 handler 里用 fetch 或 httpx 调用 API 即可。只有想批量自动生成工具时 OpenAPI 才有用。
Where should the internal API token go?
In the env block of the server entry in claude_desktop_config.json or mcp.json, read by the server with process.env or os.environ. Never make the token a tool parameter, because the model would then see and supply it.
中文: 放在主机配置里该 server 的 env 字段中,由服务端通过 process.env 或 os.environ 读取。不要把令牌做成工具参数,否则模型会看到并传递它。
Can a local MCP server reach an API behind a VPN?
Yes. Claude Desktop and Cursor launch the stdio server as a process on your machine, so it uses your machine's network. If the API is only reachable on the VPN, connect the VPN before you start the host.
中文: 可以。Claude Desktop 和 Cursor 在你本机以子进程启动 stdio server,使用的是本机网络;API 只能经 VPN 访问时,先连上 VPN 再启动主机。
Why does my Python tool only return "Error executing tool"?
In mcp 2.x only ToolError messages are passed to the model; any other exception is treated as a crash and its text is withheld. Raise ToolError from mcp.server.mcpserver.exceptions with a message that says what failed, such as the HTTP status.
中文: mcp 2.x 只会把 ToolError 的消息传给模型,其他异常被视为崩溃、消息会被隐藏。请从 mcp.server.mcpserver.exceptions 导入 ToolError 并写明失败原因(如 HTTP 状态码)。
Can my whole team share one MCP server for the internal API?
Not with the generated stdio project as it is: each person runs a local copy with their own token. A shared server needs the Streamable HTTP transport and authentication in front of it, which you add yourself.
中文: 按生成的 stdio 项目不行:每个人在本地运行一份并使用自己的令牌。团队共享需要改用 Streamable HTTP 传输并加上认证,需自行实现。