Key takeaways
- An MCP server registers tools with an official SDK and serves them over stdio or Streamable HTTP. The current spec revision is 2026-07-28.
- With the TypeScript SDK v2 (@modelcontextprotocol/server), a remote server with two tools over an existing REST API is 52 lines.
- Revision 2026-07-28 removed protocol sessions and the GET stream from Streamable HTTP. Every request now carries its own version and headers.
- Build a new server instance per request with a factory. The SDK calls it for each request and for version probes.
- On a remote server, check every token was issued for your server, and never pass the caller's token on to your API.
Pricing an app? Try the free app cost calculator or get a range for your project in the instant quote.
To build an MCP server, you register tools with an official SDK and serve them over stdio or Streamable HTTP. With the TypeScript SDK v2, a remote server with two tools over an existing REST API is 52 lines. This guide builds that server, tests it with the MCP Inspector and adds sign-in. Everything follows the current spec revision, 2026-07-28.
Simon Dziak, App369’s one developer, ran every command and code block below on November 8, 2026. The versions were @modelcontextprotocol/server 2.4.0, @modelcontextprotocol/express 2.0.2, @modelcontextprotocol/node 2.1.2, the Inspector 2.13.0 and Python mcp 2.3.0. If you are deciding which tools to expose and how customers sign in, start with how to add an MCP server to your SaaS. This post is the code.
What is an MCP server?
An MCP server is a program that offers tools, resources and prompts to AI applications over the Model Context Protocol. The 2026-07-28 specification defines three server features:
- Tools: functions for the AI model to execute.
- Resources: context and data, for the user or the AI model to use.
- Prompts: templated messages and workflows for users.
Messages are JSON-RPC 2.0. The client lives inside a host such as Claude, and the host asks the user before it calls a tool. Most servers built over an API only need tools, so this guide covers tools.
What do you need before you start?
You need Node.js 20 or later, an official SDK and an API for the tools to call. The MCP SDK page lists six Tier 1 SDKs: TypeScript, Python, C#, Go, Rust and Ruby. Java is Tier 2. Swift, PHP and Kotlin are Tier 3.
This guide uses the TypeScript SDK v2. It ships as separate packages: @modelcontextprotocol/server and @modelcontextprotocol/client, plus adapters such as @modelcontextprotocol/express. The older v1 package, @modelcontextprotocol/sdk, is now the maintenance line and implements the spec up to 2025-11-25.
Which transport should your MCP server use?
Use Streamable HTTP when people connect to your server by URL, and stdio when it runs on the user’s machine. The transports page defines only these two.
| Transport | How it works | Use it for |
|---|---|---|
| stdio | The client starts your server as a subprocess and sends newline-delimited messages over its standard streams | Local tools, such as a server in Claude Desktop that reads files |
| Streamable HTTP | Each message is an HTTP POST to one endpoint. Replies are a JSON object or an SSE stream for that request | A server over your SaaS API that many users reach by URL |
| HTTP+SSE (2024-11-05) | Deprecated since 2025-03-26 | Nothing new |
Revision 2026-07-28 changed Streamable HTTP. It removed the GET stream endpoint and protocol-level sessions, so Mcp-Session-Id is gone. Every POST now carries an MCP-Protocol-Version header and an Mcp-Method header. Tool calls also carry Mcp-Name. The server checks each header against the body.
Step 1: How do you set up the project?
Create a Node.js project and install the server package, the Express adapter, Express and zod. The Express guide lists these packages.
mkdir invoices-mcp && cd invoices-mcp
npm init -y
npm pkg set type=module
npm install @modelcontextprotocol/server @modelcontextprotocol/express @modelcontextprotocol/node express zod tsx
mkdir src
The example wraps a small invoices API. It has two endpoints, GET /v1/invoices and GET /v1/invoices/:id, and needs an API key. Swap in your own API.
Step 2: How do you register tools over your API?
Register each tool with a name, a description for the model, a zod input schema and a handler that calls your API. Put this in src/server.ts:
import { createMcpExpressApp } from '@modelcontextprotocol/express';
import { toNodeHandler } from '@modelcontextprotocol/node';
import { createMcpHandler, McpServer } from '@modelcontextprotocol/server';
import * as z from 'zod/v4';
const API_BASE = process.env.API_BASE ?? 'http://127.0.0.1:4000';
const API_KEY = process.env.API_KEY; // the server's own key, never the caller's token
async function callApi(path: string) {
const res = await fetch(`${API_BASE}${path}`, { headers: { Authorization: `Bearer ${API_KEY}` } });
if (!res.ok) throw new Error(`API returned HTTP ${res.status} for ${path}`);
return res.json();
}
const handler = createMcpHandler(() => {
const server = new McpServer({ name: 'invoices', version: '1.0.0' });
server.registerTool(
'list_invoices',
{
description: 'List invoices. Use when the user asks which invoices are open or paid. Returns id, client, total and status.',
inputSchema: z.object({
status: z.enum(['open', 'paid']).optional().describe('Only invoices with this status'),
}),
annotations: { readOnlyHint: true },
},
async ({ status }) => {
const rows = await callApi(`/v1/invoices${status ? `?status=${status}` : ''}`);
return { content: [{ type: 'text', text: JSON.stringify(rows) }] };
},
);
server.registerTool(
'get_invoice',
{
description: 'Get one invoice by its id, such as inv_101.',
inputSchema: z.object({ id: z.string().describe('Invoice id, for example inv_101') }),
annotations: { readOnlyHint: true },
},
async ({ id }) => {
const row = await callApi(`/v1/invoices/${encodeURIComponent(id)}`);
return { content: [{ type: 'text', text: JSON.stringify(row) }] };
},
);
return server;
});
Three details matter here:
- The factory builds a new
McpServereach time. The HTTP guide runs it once per request, so no state lives on the instance and the endpoint scales sideways. - The description is for the model. It says when to use the tool and what comes back. The model picks tools by these words.
- Errors become tool errors. When the API returns 404, the SDK sends back the message with
isError: true, so the model can tell the user.
The SDK validates inputs against the schema before your handler runs. A bad argument comes back as an Input validation error result, and your API never sees it.
Step 3: How do you serve it over Streamable HTTP?
Mount the handler on one /mcp route. Add these lines to the end of src/server.ts:
const app = createMcpExpressApp();
const node = toNodeHandler(handler);
app.all('/mcp', (req, res) => void node(req, res, req.body));
app.listen(3000, '127.0.0.1');
Start it with API_KEY=your-key npx tsx src/server.ts. It listens on 127.0.0.1 only, as the spec recommends for local servers. createMcpExpressApp() turns on Host and Origin checks by default, and a request from another origin gets HTTP 403. The spec requires the Origin check: servers MUST validate it to stop DNS rebinding attacks.
For a public deployment, name your hosts:
const publicApp = createMcpExpressApp({ host: '0.0.0.0', allowedHosts: ['api.example.com'] });
The same handler also has a web-standard handler.fetch(request), which takes a Request and returns a Response. So the server can run on hosts without Express.
Step 4: How do you test an MCP server?
Use the MCP Inspector CLI, once in each protocol era. The modern era sends 2026-07-28 requests. The legacy era sends the 2025 initialize handshake.
npx @modelcontextprotocol/inspector --cli http://127.0.0.1:3000/mcp \
--transport http --protocol-era modern --method tools/list
npx @modelcontextprotocol/inspector --cli http://127.0.0.1:3000/mcp \
--transport http --protocol-era modern --method tools/call \
--tool-name get_invoice --tool-arg id=inv_101
The first command lists both tools with their JSON schemas. The second returns the invoice as text. Run both again with --protocol-era legacy. createMcpHandler serves both eras from the same factory by default.
You can also send a raw 2026-07-28 request with curl:
curl -s -X POST http://127.0.0.1:3000/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-H 'MCP-Protocol-Version: 2026-07-28' \
-H 'Mcp-Method: tools/call' \
-H 'Mcp-Name: list_invoices' \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"list_invoices","arguments":{"status":"open"},"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientInfo":{"name":"curl","version":"1.0.0"},"io.modelcontextprotocol/clientCapabilities":{}}}}'
If Mcp-Name names a different tool than the body, the server answers HTTP 400 with error code -32020 (HeaderMismatch). That is the spec working as designed: a load balancer that routes on headers can’t be fooled by a different body.
Step 5: How do you add sign-in to an MCP server?
Gate the /mcp route with a bearer-token check, and accept only tokens issued for your server. The authorization spec makes auth optional. When you use it, three rules decide most designs:
- stdio servers should not use the OAuth flow. They take credentials from the environment, like the
API_KEYabove. - HTTP servers act as an OAuth 2.1 resource server. They MUST publish OAuth 2.0 Protected Resource Metadata (RFC 9728). Clients find your authorization server there.
- Tokens are bound to your server. Servers MUST check that a token was issued for them, and MUST NOT accept or pass on any other tokens.
The SDK’s authorization guide wires it like this. It continues from the publicApp and node variables above. You supply two things: verifyAccessToken, which checks the token with your authorization server, and oauthMetadata, that server’s own RFC 8414 metadata document.
import type { OAuthTokenVerifier } from '@modelcontextprotocol/express';
import { getOAuthProtectedResourceMetadataUrl, mcpAuthMetadataRouter, requireBearerAuth } from '@modelcontextprotocol/express';
const mcpServerUrl = new URL('https://api.example.com/mcp');
const verifier: OAuthTokenVerifier = { verifyAccessToken };
const auth = requireBearerAuth({
verifier,
requiredScopes: ['mcp'],
resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(mcpServerUrl),
expectedResource: mcpServerUrl,
});
publicApp.use(mcpAuthMetadataRouter({ oauthMetadata, resourceServerUrl: mcpServerUrl }));
publicApp.all('/mcp', auth, (req, res) => void node(req, res, req.body));
Your verifyAccessToken must do three things, or valid users get locked out:
- Reject a bad token with
OAuthError. Thrownew OAuthError(OAuthErrorCode.InvalidToken, 'reason'), both from@modelcontextprotocol/server. Any other exception comes back as HTTP 500, not 401. - Return
expiresAtfrom the token’sexpclaim. A token without it gets 401. - Return
resourcefrom the token’saudclaim. WithexpectedResourceset, a token for any other server gets 401.
Without a valid token, the server answers 401 with a WWW-Authenticate header that points to the metadata. Inside a tool, ctx.http?.authInfo holds the caller’s client id and scopes. Use it to load that user’s own API credentials, not a shared admin key. App369’s auth guide compares API keys and OAuth for each client.
How do you connect your MCP server to Claude?
For a local stdio server, add it under mcpServers in Claude Desktop’s claude_desktop_config.json. On macOS the file is at ~/Library/Application Support/Claude/claude_desktop_config.json. The official tutorial uses this shape:
{
"mcpServers": {
"weather": {
"command": "node",
"args": ["/ABSOLUTE/PATH/TO/PARENT/FOLDER/weather/build/index.js"]
}
}
}
A stdio server must never write to stdout, because that corrupts the JSON-RPC messages. Log with console.error() instead. In TypeScript, serve stdio with serveStdio(createServer) from @modelcontextprotocol/server/stdio.
A remote server is added by its URL as a custom connector. Which sign-in each client accepts differs, and the SaaS guide covers Claude and ChatGPT.
The Python SDK has the same model. This stdio server lists its tool in both protocol eras with mcp 2.3.0:
from mcp.server import MCPServer
mcp = MCPServer("invoices")
@mcp.tool()
async def get_invoice(id: str) -> str:
"""Get one invoice by its id, such as inv_101."""
return f"invoice {id}"
if __name__ == "__main__":
mcp.run(transport="stdio")
What breaks most often when you build an MCP server?
Most failures are stdout logging, a reused server instance, or a token passed through. Each one maps to a spec rule:
| Symptom | Cause | Fix |
|---|---|---|
| A stdio server connects, then drops | Something printed to stdout | Log to stderr with console.error() or Python’s logging |
| Newer clients can’t connect | Wired with server.connect(new StdioServerTransport()), which the official tutorial says only serves 2025-era clients | Use serveStdio or createMcpHandler. Both negotiate the version |
| Odd state shared between users | One McpServer reused across requests | Return a new instance from the factory every time |
| HTTP 400, code -32020 | Mcp-Name or Mcp-Method doesn’t match the body | Send the headers your client library sets, or fix the hand-built request |
| HTTP 403 on a local server | The Origin or Host check refused the request | Add your host to allowedHosts |
| HTTP 500 instead of 401 for a bad token | The verifier threw a plain Error | Throw OAuthError with OAuthErrorCode.InvalidToken |
| Your API sees the AI client’s token | The token is forwarded upstream | Validate it, then call the API with that user’s own credentials |
If a client gets HTTP 405 or 406, see MCP server 405 and 406 errors. When a tool call fails in a client and you can’t tell why, the AI API errors guide lists the common MCP errors and fixes. For a full reference build with per-user scoping, gated writes and rate limits, see Simon’s open-source saas-mcp-connector on GitHub.
Or have it built
App369 builds this server over your existing API at a fixed price. Simon Dziak is the one developer who scopes, builds and tests it.
How much does an MCP server cost?
At App369, $2,500 fixed for an MCP server with up to 8 tools over your existing API, or $3,500 with the OAuth sign-in ChatGPT needs. Built in-house, plan on one to three weeks of an engineer who knows your API.
- MCP server, up to 8 tools$2,500Over your existing documented API, with API-key or token auth, deployed to your own cloud account and tested in Claude
- OAuth sign-in add-on$1,000The sign-in ChatGPT needs, tested in Claude and ChatGPT
What isn’t included: new API endpoints, and any guarantee that a directory approves the listing, because that is up to each directory. The full scope is on the MCP Connector page.
Want this done at a fixed price?
Fixed scope, fixed price, paid upfront. The work happens in writing; no call needed.
Frequently asked questions
- What do I need to build an MCP server?
- You need an official MCP SDK and something for its tools to call, usually your own REST API. For the TypeScript SDK v2 that means Node.js 20 or later and the @modelcontextprotocol/server package with zod. Python, C#, Go, Rust and Ruby also have Tier 1 official SDKs.
- Should my MCP server use stdio or Streamable HTTP?
- Use Streamable HTTP for a server other people connect to by URL. Use stdio for a server that runs on the user's own machine as a subprocess. The older HTTP+SSE transport is deprecated, so new servers should not use it.
- What changed in the 2026-07-28 MCP spec?
- Streamable HTTP lost protocol-level sessions and the GET stream endpoint. Each request now carries its protocol version in the body and in an MCP-Protocol-Version header, plus Mcp-Method and Mcp-Name headers. Servers no longer send their own requests; they return an InputRequiredResult instead.
- Will my server still work with older MCP clients?
- Yes, if you serve it with the SDK v2 helpers. createMcpHandler and serveStdio answer both 2026-07-28 clients and clients that open with the 2025-era initialize handshake. A server wired by hand with server.connect(new StdioServerTransport()) only serves 2025-era clients.
- How do I test an MCP server without Claude?
- Use the MCP Inspector CLI. Run npx @modelcontextprotocol/inspector --cli with your server URL or start command, --protocol-era modern and --method tools/list. Run it again with --protocol-era legacy to check older clients.
- Does an MCP server need OAuth?
- No. The spec makes authorization optional. A stdio server should take credentials from its environment. A remote server that holds user data should use OAuth 2.1 as the spec describes, and it must reject tokens that were not issued for it.
Sources
- MCP specification, revision 2026-07-28
- MCP transports (stdio and Streamable HTTP)
- MCP Streamable HTTP transport
- MCP authorization
- Build an MCP server (official tutorial)
- MCP SDKs and tiers
- TypeScript SDK v2: serving over HTTP
- TypeScript SDK v2: Express
- TypeScript SDK v2: authorization
- TypeScript SDK v2: legacy clients
Related Resources
Related guides
How to Add an MCP Server to Your SaaS: A Practical Guide (2026)
Add an MCP server to your SaaS so it works in Claude and ChatGPT, over your REST API: choosing tools, authentication, hosting, testing and directory listing.
Read moreMCP Server Authentication for SaaS: API Keys vs OAuth (2026)
How should users sign in to your remote MCP server? What the MCP spec requires, when API keys are enough, and what Claude and ChatGPT expect today.
Read moreAI API Errors: 7 OpenAI, Claude, Gemini and MCP Fixes (2026)
AI feature broken by a 401, 429 or 529, or an MCP connector that won't connect? 7 OpenAI, Anthropic, Gemini and MCP errors, what each means and how to fix it.
Read more