This MCP server tutorial builds one real thing: a server that gives Claude read-only access to a database of orders. You'll test it in the MCP Inspector, call it from Claude Code and Claude Desktop, and then serve it over HTTP behind bearer-token auth. If you're deciding which tools agents should be allowed to touch at all, AI in the engineering workflow: tools, permissions and policy covers the permission side. This page covers the code.
The direction matters. Here the model calls your server. If you want your code to call the model instead, that's a different tutorial: build an AI feature with the Claude API.
What you'll build
An orders-db server over a SQLite file, orders.db, with two tables (customers, orders) and two tools: list_orders(status, limit) and get_order(order_id). SQLite stands in for your internal database. The tools, the names and the sample data stay the same from step 1 to step 10. Plan on 60 to 90 minutes.
The steps use TypeScript and the v2 SDK. A complete Python stdio version of the same server is in the Full code section. The HTTP transport and auth are TypeScript only.
Here's the finished server answering a question in Claude Code. The output is illustrative, not captured from a run:
> claude: which orders are still pending?
[tool call] list_orders {"status":"pending","limit":10}
[result] 2 orders: #1057 (2026-09-30, EUR 89.50, pending, Nordlicht AB); #1042 (2026-09-28, EUR 310.00, pending, Acme GmbH).And the project layout you'll end up with:
orders-db/
package.json
tsconfig.json
.gitignore
.env.example
.env (your local copy, not committed)
orders.db (created by the seed script)
src/
db.ts read-only database access
server.ts the two tools
stdio.ts local transport
http.ts remote transport with auth
seed.ts sample dataWhat you need before you start
- Node.js 22.19 or newer. The server package accepts Node 20 or later, but Node 20 is past end of life, so use Node 22.19 or later. The Inspector's docs also say it needs Node 22.19.0 or newer.
- A macOS or Linux shell (bash or zsh) with
curl. On Windows, run every command in WSL. The steps useexport,$(pwd)andcurlone-liners that PowerShell doesn't run as written. - Claude Code and Claude Desktop installed, if you want steps 5, 6, 9 and 10. The Inspector alone gets you through step 4.
- Comfort reading TypeScript. For the Python version, Python 3.10 or later and
uv.
Quickstart
Already know what MCP is and want the smallest working server? Start a project, install the v2 SDK and compile one file:
mkdir mcp-quickstart && cd mcp-quickstart
mkdir src
npm init -y && npm pkg set type=module
npm install @modelcontextprotocol/server zod
npm install -D @types/node typescriptSave this as src/quickstart.ts:
import { McpServer } from "@modelcontextprotocol/server";
import { StdioServerTransport } from "@modelcontextprotocol/server/stdio";
import { z } from "zod";
const server = new McpServer({ name: "orders-db", version: "1.0.0" });
server.registerTool(
"echo",
{ description: "Echoes the text back.", inputSchema: z.object({ text: z.string().describe("Text to echo") }) },
async ({ text }) => ({ content: [{ type: "text", text }] })
);
await server.connect(new StdioServerTransport());
console.error("quickstart server running on stdio");Compile and open it in the Inspector:
npx tsc src/quickstart.ts --outDir build --module node16 --moduleResolution node16 --target es2022 --strict --types node --skipLibCheck
npx @modelcontextprotocol/inspector node build/quickstart.jsThree things to get right before you build on it:
- Never write to stdout from a stdio server.
console.log()andprint()corrupt the protocol stream. Log to stderr (console.error, or Python'slogging). - Build before you wire. The server runs from compiled
build/output, and forgettingnpm run buildis the most common reason a client can't connect. - Use absolute paths in every client config. Clients start your server from their own working directory, not yours.
How to build an MCP server
1. Create the project, the config files and the seed data
Create the orders-db folder, write the project files, install every package once and seed a SQLite file with five orders. The seed script is the only code in this tutorial that ever writes to the database.
In a terminal, from the folder where you keep projects:
mkdir orders-db && cd orders-db
mkdir srcCreate package.json:
{
"name": "orders-db",
"version": "1.0.0",
"type": "module",
"private": true,
"scripts": {
"build": "tsc",
"seed": "node build/seed.js",
"start:stdio": "node build/stdio.js",
"start:http": "node --env-file=.env build/http.js"
},
"dependencies": {
"@modelcontextprotocol/express": "^2.0.1",
"@modelcontextprotocol/node": "^2.1.0",
"@modelcontextprotocol/server": "^2.2.0",
"better-sqlite3": "^13.0.3",
"express": "^5.2.1",
"zod": "^4.6.5"
},
"devDependencies": {
"@types/better-sqlite3": "latest",
"@types/express": "latest",
"@types/node": "^22",
"typescript": "^5"
}
}The versions are the latest majors on the npm registry as of 2026-10-01: @modelcontextprotocol/server 2.2.0, @modelcontextprotocol/express 2.0.1, @modelcontextprotocol/node 2.1.0, Express 5.2.1, Zod 4.6.5 and better-sqlite3 13.0.3. The v2 line implements the 2026-07-28 MCP specification, according to the TypeScript SDK README. v1.x keeps getting bug fixes and security updates for at least six months after v2's release, so code on @modelcontextprotocol/sdk has time, but new code should start on v2. The tutorial uses better-sqlite3 instead of Node's built-in node:sqlite, which is still experimental on Node 22 and 23.
Create tsconfig.json:
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"outDir": "build",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"types": ["node"]
},
"include": ["src/**/*"]
}Create .gitignore, so the build, the dependencies, the database and your local secrets stay out of git:
node_modules
build
.env
orders.dbCreate .env.example. Only the HTTP server reads it, from step 7 on, and the values stay empty here:
MCP_BEARER_TOKEN=
MCP_SERVER_URL=
AUTH_ISSUER_URL=
ALLOWED_HOSTS=
PORT=Create src/db.ts. For now it only says where the database file lives. Step 2 replaces it:
import { fileURLToPath } from "node:url";
export const DB_PATH = fileURLToPath(new URL("../orders.db", import.meta.url));Create src/seed.ts:
import Database from "better-sqlite3";
import { rmSync } from "node:fs";
import { DB_PATH } from "./db.js";
rmSync(DB_PATH, { force: true });
const db = new Database(DB_PATH);
db.exec(`
CREATE TABLE customers (id INTEGER PRIMARY KEY, name TEXT NOT NULL, email TEXT NOT NULL);
CREATE TABLE orders (
id INTEGER PRIMARY KEY,
customer_id INTEGER NOT NULL REFERENCES customers(id),
status TEXT NOT NULL CHECK (status IN ('pending','paid','shipped','cancelled')),
total_cents INTEGER NOT NULL,
currency TEXT NOT NULL,
created_at TEXT NOT NULL
);
`);
const addCustomer = db.prepare("INSERT INTO customers (id, name, email) VALUES (?, ?, ?)");
addCustomer.run(1, "Acme GmbH", "billing@acme.example");
addCustomer.run(2, "Nordlicht AB", "ap@nordlicht.example");
addCustomer.run(3, "Rivera Studio", "hello@rivera.example");
const addOrder = db.prepare(
"INSERT INTO orders (id, customer_id, status, total_cents, currency, created_at) VALUES (?, ?, ?, ?, ?, ?)"
);
addOrder.run(1040, 1, "shipped", 12500, "EUR", "2026-09-25");
addOrder.run(1041, 2, "paid", 4200, "EUR", "2026-09-27");
addOrder.run(1042, 1, "pending", 31000, "EUR", "2026-09-28");
addOrder.run(1050, 3, "cancelled", 1500, "EUR", "2026-09-29");
addOrder.run(1057, 2, "pending", 8950, "EUR", "2026-09-30");
db.close();
console.error(`Seeded ${DB_PATH}`);Two orders are pending (#1042 and #1057), which is what the sample output at the top uses. Now install, copy the env file, build and seed:
npm install
cp .env.example .env
npm run build
npm run seedYou should see something like:
Seeded /ABSOLUTE/PATH/TO/orders-db/orders.dbIf npm run seed can't find build/seed.js, the build didn't run. Run npm run build first.
2. Open the database read-only
Replace src/db.ts so the server opens the file in read-only mode, and writes fail whatever the model asks for. This makes "read-only" a property of the system instead of a sentence in a tool description.
import Database from "better-sqlite3";
import { fileURLToPath } from "node:url";
export const DB_PATH = fileURLToPath(new URL("../orders.db", import.meta.url));
export function openReadOnly(): Database.Database {
return new Database(DB_PATH, { readonly: true, fileMustExist: true });
}A tool description is a request, and the model is one of several things that can send arguments to your server. A prompt injection in a customer name, a buggy client or a tool you add next month can all produce a call you didn't intend. A tool that says "read-only" but runs db.exec(userInput) is read-write. The database refusing the write is the guarantee. The path is built from the module's own location, so it's absolute whatever directory a client starts the server from. With Postgres or MySQL, the equivalent is a dedicated role granted SELECT on the tables the server needs and nothing else.
Checkpoint, from orders-db/. This rebuilds, counts the orders, then tries a delete:
npm run build
node -e "import('./build/db.js').then(({ openReadOnly }) => { const db = openReadOnly(); console.log(db.prepare('SELECT COUNT(*) AS n FROM orders').get()); db.exec('DELETE FROM orders'); })"You should see something like this, trimmed:
{ n: 5 }
…/better-sqlite3/lib/methods/wrappers.js:9
SqliteError: attempt to write a readonly database
at Database.exec (…)
code: 'SQLITE_READONLY'
}The count prints, and the delete throws an uncaught SqliteError (exit code 1). That's the expected result.
3. Add the get_order tool and run it over stdio
A server is an McpServer with tools registered on it. Each tool has a name, a description, an input schema and a handler that returns content blocks. Create src/server.ts with get_order, and src/stdio.ts as the entry point a client launches.
What does Claude see? The name, description and schema. The handler never appears to the model, so the description is prompt text: write what the tool returns, what the ID looks like and when to use it. The registration shape (registerTool, a config object with description and a Zod inputSchema, an async handler returning { content: [{ type: "text", text }] }) is the one the official docs use. The SQL, the row formatting and the reply helper are tutorial code.
Create src/server.ts:
import type Database from "better-sqlite3";
import { McpServer } from "@modelcontextprotocol/server";
import { z } from "zod";
interface OrderRow {
id: number;
status: string;
total_cents: number;
currency: string;
created_at: string;
customer_name: string;
}
const SELECT_ORDERS = `
SELECT o.id, o.status, o.total_cents, o.currency, o.created_at, c.name AS customer_name
FROM orders o JOIN customers c ON c.id = o.customer_id
`;
function reply(text: string, isError = false) {
return { content: [{ type: "text" as const, text }], isError };
}
function formatOrder(r: OrderRow): string {
return `#${r.id} (${r.created_at.slice(0, 10)}, ${r.currency} ${(r.total_cents / 100).toFixed(2)}, ${r.status}, ${r.customer_name})`;
}
export function createServer(db: Database.Database): McpServer {
const server = new McpServer({ name: "orders-db", version: "1.0.0" });
server.registerTool(
"get_order",
{
description: "Get one order by its numeric ID, with customer name, status and total.",
inputSchema: z.object({ order_id: z.number().int().positive().describe("The order ID, for example 1042") })
},
async ({ order_id }) => {
try {
const row = db.prepare(`${SELECT_ORDERS} WHERE o.id = ?`).get(order_id) as OrderRow | undefined;
return reply(row ? formatOrder(row) : `No order with ID ${order_id}.`);
} catch (err) {
console.error(err);
return reply("The orders database could not be queried.", true);
}
}
);
return server;
}The handler returns failures as results the model can read. isError: true is part of the MCP tool result shape (the spec's tool error handling section), and an uncaught exception would give the model nothing to act on. A missing order isn't an error: "No order with ID 9999." is a normal answer to a valid question.
Create src/stdio.ts:
import { StdioServerTransport } from "@modelcontextprotocol/server/stdio";
import { openReadOnly } from "./db.js";
import { createServer } from "./server.js";
const server = createServer(openReadOnly());
await server.connect(new StdioServerTransport());
console.error("orders-db MCP server running on stdio");Build, then list the tools through the Inspector's scripted mode:
npm run build
npx @modelcontextprotocol/inspector --cli node build/stdio.js --method tools/listYou should see lines like these in the JSON output:
"name": "get_order",
"description": "Get one order by its numeric ID, with customer name, status and total.",For a form instead of JSON, run npx @modelcontextprotocol/inspector node build/stdio.js. The Inspector docs say it needs Node 22.19.0 or newer. It prints a URL with a one-time session token. Open it, connect, and call get_order with order_id 1042. The result is #1042 (2026-09-28, EUR 310.00, pending, Acme GmbH). Call it with 9999 and you get "No order with ID 9999."
To check from the terminal instead:
npx @modelcontextprotocol/inspector --cli node build/stdio.js --method tools/call --tool-name get_order --tool-arg order_id=1042The CLI sends 1042 as a number, and the result is the same order line. If the CLI reports a missing build/stdio.js, run npm run build.
4. Add the list_orders tool with input limits
Constrain the inputs in the schema so the model can't ask for everything. Make status an enum, give limit a ceiling and a default, and let the schema reject the rest before your handler runs.
In src/server.ts, paste these two constants between the imports and interface OrderRow:
const STATUSES = ["pending", "paid", "shipped", "cancelled"] as const;
const MAX_ROWS = 50;Then paste this registration above the return server; line, below the get_order registration:
server.registerTool(
"list_orders",
{
description: "List recent orders, newest first. Optionally filter by status. Returns at most 50 orders.",
inputSchema: z.object({
status: z.enum(STATUSES).optional().describe("Only return orders with this status"),
limit: z.number().int().min(1).max(MAX_ROWS).default(10).describe("How many orders to return, 1-50")
})
},
async ({ status, limit }) => {
try {
const rows = db
.prepare(`${SELECT_ORDERS} WHERE (? IS NULL OR o.status = ?) ORDER BY o.created_at DESC LIMIT ?`)
.all(status ?? null, status ?? null, limit + 1) as OrderRow[];
const shown = rows.slice(0, limit);
const more = rows.length > limit ? " More orders exist, so narrow the status filter." : "";
return reply(`${shown.length} orders: ${shown.map(formatOrder).join("; ")}.${more}`);
} catch (err) {
console.error(err);
return reply("The orders database could not be queried.", true);
}
}
);You don't ask a model to behave. You make the unwanted call impossible: four statuses, a hard max(50), and a default of 10 so a bare call stays small. Fetching limit + 1 rows is the cheap way to know whether more exist without a second COUNT query. Output size matters too: per Claude Code's MCP docs, it caps tool results at 25,000 tokens by default, and fifty one-line orders sit far under that.
Checkpoint, from orders-db/:
npm run build
npx @modelcontextprotocol/inspector --cli node build/stdio.js --method tools/call --tool-name list_orders --tool-arg status=pending --format jsonYou should see something like this text inside the JSON result:
2 orders: #1057 (2026-09-30, EUR 89.50, pending, Nordlicht AB); #1042 (2026-09-28, EUR 310.00, pending, Acme GmbH).Then try limit 500. The SDK rejects it before your handler runs and returns a tool result with isError: true and the text Input validation error: Invalid arguments for tool list_orders: limit: Too big: expected number to be <=50. To check from the terminal, run:
npx @modelcontextprotocol/inspector --cli node build/stdio.js --method tools/call --tool-name list_orders --tool-arg limit=500It prints that result and exits with code 5.
5. Connect Claude Code
Register the server with claude mcp add, put -- between Claude's options and your command, and use an absolute path. Run this from orders-db/, where $(pwd) expands to the absolute path:
claude mcp add orders-db -- node "$(pwd)/build/stdio.js"Check it with claude mcp list, or /mcp inside a session. Then start claude and ask: "which orders are still pending?"
You should see something like (illustrative):
> claude: which orders are still pending?
[tool call] list_orders {"status":"pending","limit":10}
[result] 2 orders: #1057 (2026-09-30, EUR 89.50, pending, Nordlicht AB); #1042 (2026-09-28, EUR 310.00, pending, Acme GmbH).If /mcp shows orders-db as failed, run npm run build again and check the path is absolute. Claude Code's docs say to verify you trust each server before connecting it, and that applies to your own servers too.
6. Connect Claude Desktop
Claude Desktop reads a JSON config file, and every path in it has to be absolute. Desktop launches your server as a child process from its own working directory, so ./build/stdio.js points at nothing.
The file lives at:
- macOS:
~/Library/Application Support/Claude/claude_desktop_config.json - Linux:
~/.config/Claude/claude_desktop_config.json - Windows:
$env:AppData\Claude\claude_desktop_config.json
Run pwd in orders-db/ to get your absolute path, then add the server to the file (create the file if it doesn't exist). Replace the placeholder path:
{
"mcpServers": {
"orders-db": {
"command": "node",
"args": ["/ABSOLUTE/PATH/TO/orders-db/build/stdio.js"]
}
}
}Restart Claude Desktop after saving, every time. Then ask the same question: "which orders are still pending?" You should see the same tool call and result as in step 5, shown in Desktop's tool-use UI. If the tools don't appear, check the path in the config, and that node is on the PATH Desktop sees.
7. Serve the server over Streamable HTTP
Stdio runs on one machine. To reach the server from elsewhere, serve it over Streamable HTTP. Create src/http.ts with one route, /mcp, on an Express app. Step 8 replaces this file with the authenticated version.
The v2 Express guide shows the shape: createMcpHandler takes a factory that builds a server, toNodeHandler adapts it for Express, and createMcpExpressApp gives you an app that validates Host and Origin by default. The factory runs once per incoming request, which is the stateless design.
import { createMcpExpressApp } from "@modelcontextprotocol/express";
import { toNodeHandler } from "@modelcontextprotocol/node";
import { createMcpHandler } from "@modelcontextprotocol/server";
import { openReadOnly } from "./db.js";
import { createServer } from "./server.js";
const db = openReadOnly();
const node = toNodeHandler(createMcpHandler(() => createServer(db)));
const app = createMcpExpressApp();
app.all("/mcp", (req, res) => void node(req, res, req.body));
app.listen(3000, "127.0.0.1", () => {
console.log("orders-db MCP server listening on port 3000");
});Binding to 127.0.0.1 matters: a server listening on every interface without an origin check can be reached from a web page open on the same machine. The old HTTP+SSE transport is deprecated, so don't adopt it because an older tutorial did. Under the 2026-07-28 revision there are no protocol sessions and no GET stream, per the Streamable HTTP spec page. Every POST carries the protocol version in a header and in the body's _meta, which the curl below does. The Reference section has the details.
Build and start the server in this terminal, and leave it running:
npm run build
npm run start:httpYou should see:
orders-db MCP server listening on port 3000If it says the address is in use, another process holds port 3000. Change both 3000s in src/http.ts to a free port such as 3001, rebuild, and use that port in the curl URLs. In a second terminal, from orders-db/, send a raw tool call:
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: get_order' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"get_order","arguments":{"order_id":1042},"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}}}'Example output, captured on SDK 2.2.0 and trimmed:
{"result":{"content":[{"type":"text","text":"#1042 (2026-09-28, EUR 310.00, pending, Acme GmbH)"}],"isError":false,"resultType":"complete","_meta":{"io.modelcontextprotocol/serverInfo":{"name":"orders-db","version":"1.0.0"}}},"jsonrpc":"2.0","id":1}This is raw protocol, so you won't normally write it. It shows there's no session handshake, nothing to hold open and nothing to clean up.
8. Add bearer-token auth as an OAuth resource server
Put an authentication check in front of /mcp, and make your server a resource server only: it validates tokens that someone else issued and never mints them. The authorization spec makes auth optional, but says HTTP transports should follow it and stdio should not (a stdio server takes credentials from its environment). So: no auth for stdio, auth the moment there's a URL.
The v2 authorization guide maps this onto SDK pieces. requireBearerAuth is the middleware, mcpAuthMetadataRouter serves the protected-resource metadata document, and your verifier returns { token, clientId, scopes, expiresAt }. Always populate expiresAt, because tokens without it get 401 invalid_token.
Replace src/http.ts with this file. Read the verifier carefully, because it's a stand-in. It throws OAuthError with OAuthErrorCode.InvalidToken for a bad token, because requireBearerAuth turns only that error into a 401 and any other exception into a 500:
import { timingSafeEqual } from "node:crypto";
import {
createMcpExpressApp,
getOAuthProtectedResourceMetadataUrl,
mcpAuthMetadataRouter,
requireBearerAuth
} from "@modelcontextprotocol/express";
import { toNodeHandler } from "@modelcontextprotocol/node";
import { createMcpHandler, OAuthError, OAuthErrorCode } from "@modelcontextprotocol/server";
import { openReadOnly } from "./db.js";
import { createServer } from "./server.js";
function requireEnv(name: string): string {
const value = process.env[name];
if (!value) throw new Error(`Missing environment variable ${name}`);
return value;
}
const port = Number(process.env.PORT || 3000);
const allowedHosts = process.env.ALLOWED_HOSTS ? process.env.ALLOWED_HOSTS.split(",") : undefined;
const expected = Buffer.from(requireEnv("MCP_BEARER_TOKEN"));
const mcpServerUrl = new URL(requireEnv("MCP_SERVER_URL"));
const issuer = requireEnv("AUTH_ISSUER_URL");
// Tutorial stand-in. A real deployment verifies a JWT from your identity provider
// and checks that its audience is this server.
const verifier = {
async verifyAccessToken(token: string) {
const given = Buffer.from(token);
if (given.length !== expected.length || !timingSafeEqual(given, expected)) {
throw new OAuthError(OAuthErrorCode.InvalidToken, "Invalid token");
}
return { token, clientId: "dev-client", scopes: ["mcp"], expiresAt: Math.floor(Date.now() / 1000) + 3600 };
}
};
const oauthMetadata = {
issuer,
authorization_endpoint: `${issuer}/authorize`,
token_endpoint: `${issuer}/token`,
response_types_supported: ["code"]
};
const db = openReadOnly();
const node = toNodeHandler(createMcpHandler(() => createServer(db)));
const app = allowedHosts
? createMcpExpressApp({ host: "0.0.0.0", allowedHosts })
: createMcpExpressApp();
const auth = requireBearerAuth({
verifier,
requiredScopes: ["mcp"],
resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(mcpServerUrl)
});
app.use(mcpAuthMetadataRouter({ oauthMetadata, resourceServerUrl: mcpServerUrl }));
app.all("/mcp", auth, (req, res) => void node(req, res, req.body));
app.listen(port, allowedHosts ? "0.0.0.0" : "127.0.0.1", () => {
console.log(`orders-db MCP server listening on port ${port}`);
});The SDK docs supply requireBearerAuth, mcpAuthMetadataRouter, getOAuthProtectedResourceMetadataUrl and the createMcpExpressApp({ host, allowedHosts }) form for public deployment. They don't supply the token check, the metadata object or the identity provider. Those are tutorial glue, and the static-token comparison is the weakest part: it proves the plumbing works, and it can't check an audience, because a random string doesn't have one. Replace it with real JWT verification before this faces anyone. Never forward the client's token to another API, either. The code only widens the bind to 0.0.0.0 when ALLOWED_HOSTS is set.
Edit .env (not .env.example) and fill in the three required values. The issuer is a placeholder, because this server never contacts it:
MCP_BEARER_TOKEN=dev-token
MCP_SERVER_URL=http://127.0.0.1:3000/mcp
AUTH_ISSUER_URL=https://auth.example.com
ALLOWED_HOSTS=
PORT=Stop the server with Ctrl+C in the first terminal, then rebuild and restart it. Blank ALLOWED_HOSTS and PORT count as unset:
npm run build
npm run start:httpIn the second terminal, send a request with no Authorization header:
curl -s -o /dev/null -w "%{http_code}\n" -X POST http://127.0.0.1:3000/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'You should see:
401Add -i and drop the -o /dev/null -w part to see the WWW-Authenticate header, which points a client at the resource metadata. If the server exits with "Missing environment variable", .env is missing a value.
9. Connect Claude Code to the remote server
Remote clients connect to a URL instead of starting a process, and they need credentials to get past your auth check. The Claude Code docs on remote transports list HTTP as the recommended one and SSE as deprecated.
In the second terminal, export the same token that's in .env and register the server as an HTTP transport:
export MCP_BEARER_TOKEN=dev-token
claude mcp add --transport http orders-remote http://127.0.0.1:3000/mcp \
--header "Authorization: Bearer $MCP_BEARER_TOKEN"That stores the expanded token in your local Claude Code config. Run claude mcp list and check that orders-remote appears. Which protocol revision your Claude Code build negotiates depends on the build, so check with the Inspector before assuming 2026-07-28:
npx @modelcontextprotocol/inspector --cli http://127.0.0.1:3000/mcp --header "Authorization: Bearer dev-token" --protocol-era modern --method tools/call --tool-name get_order --tool-arg order_id=1042 --format jsonAd-hoc HTTP targets default to the legacy 2025 era, so --protocol-era modern is what makes the Inspector speak 2026-07-28. If the entry shows as failed, the server isn't running or the token doesn't match .env.
Claude Desktop and claude.ai work differently: remote servers there are custom connectors, not entries in claude_desktop_config.json. The Reference section covers that.
10. Run the whole thing and check a bad token
With the server from step 8 still running, call it with the right token, then the wrong one. The first curl is the step 7 request plus the Authorization header:
curl -s -X POST http://127.0.0.1:3000/mcp \
-H 'Authorization: Bearer dev-token' \
-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: get_order' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"get_order","arguments":{"order_id":1042},"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}}}'You should see the same order as in step 7. Now the negative check, a token the server never issued. -i shows the status line and headers:
curl -s -i -X POST http://127.0.0.1:3000/mcp \
-H 'Authorization: Bearer wrong-token' \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'You should see a 401 and a challenge header (trimmed):
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer error="invalid_token", error_description="Invalid token", scope="mcp", resource_metadata="…"A valid token with the wrong scope would get a 403 instead. Finally, start claude and ask the end-to-end question through the remote server: "use orders-remote to list pending orders". You should see something like (illustrative):
> claude: use orders-remote to list pending orders
[tool call] orders-remote list_orders {"status":"pending","limit":10}
[result] 2 orders: #1057 (2026-09-30, EUR 89.50, pending, Nordlicht AB); #1042 (2026-09-28, EUR 310.00, pending, Acme GmbH).At this point your files match the Full code section.
Architecture
Two paths reach the same read-only database. Local clients start your server as a child process. Remote clients go through an authenticated HTTPS endpoint.
LOCAL (stdio)
Claude Code / Claude Desktop
| starts as a child process, JSON-RPC over stdin/stdout
v
node build/stdio.js (logs to stderr only)
| read-only connection
v
orders.db
REMOTE (Streamable HTTP)
Claude Code / connector
| HTTPS POST /mcp
| Authorization: Bearer <token issued by your identity provider>
| MCP-Protocol-Version, Mcp-Method, Mcp-Name headers and a `_meta` envelope in the body
v
your endpoint: Host/Origin check -> token check (401/403) -> tool handler
| read-only connection
v
orders.db
Secrets: MCP_BEARER_TOKEN and other values live in environment variables.
Never in a committed .mcp.json or in tool output.Clean up
Stop the HTTP server with Ctrl+C, then remove what the tutorial registered:
claude mcp remove orders-db
claude mcp remove orders-remote
unset MCP_BEARER_TOKENDelete the orders-db entry from claude_desktop_config.json and restart Claude Desktop. If you registered the Python server, run claude mcp remove orders-db-py too. The dev token only ever protected a local server, but if you reused a real token anywhere, revoke it. To delete the project, remove the orders-db folder.
Full code
This section is the whole project, complete as pasted. The TypeScript version has stdio, Streamable HTTP and bearer auth, and it matches the files the steps built. The Python version is a stdio server only. The HTTP and auth parts are shown in TypeScript, so a Python team should translate the design (resource server, audience check, 401 and 403 behaviour) rather than expect a Python HTTP example here. The Python SDK docs are the place to start.
TypeScript
Project layout:
orders-db/
package.json
tsconfig.json
.gitignore
.env.example
.env
src/
db.ts
seed.ts
server.ts
stdio.ts
http.tsInstall and run, after copying the files in (macOS or Linux shell):
npm install
cp .env.example .env # fill in the values for the HTTP server
npm run build
npm run seed
npm run start:stdio # stdio, for Claude Code and Claude Desktop
npm run start:http # Streamable HTTP with bearer auth on 127.0.0.1:3000.gitignore:
node_modules
build
.env
orders.db.env.example (only the HTTP server reads it):
MCP_BEARER_TOKEN=
MCP_SERVER_URL=
AUTH_ISSUER_URL=
ALLOWED_HOSTS=
PORT=MCP_SERVER_URL is the public URL of the /mcp endpoint, for example http://127.0.0.1:3000/mcp locally. Leave ALLOWED_HOSTS and PORT blank to stay on 127.0.0.1:3000. The code treats blank values as unset.
package.json:
{
"name": "orders-db",
"version": "1.0.0",
"type": "module",
"private": true,
"scripts": {
"build": "tsc",
"seed": "node build/seed.js",
"start:stdio": "node build/stdio.js",
"start:http": "node --env-file=.env build/http.js"
},
"dependencies": {
"@modelcontextprotocol/express": "^2.0.1",
"@modelcontextprotocol/node": "^2.1.0",
"@modelcontextprotocol/server": "^2.2.0",
"better-sqlite3": "^13.0.3",
"express": "^5.2.1",
"zod": "^4.6.5"
},
"devDependencies": {
"@types/better-sqlite3": "latest",
"@types/express": "latest",
"@types/node": "^22",
"typescript": "^5"
}
}tsconfig.json:
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"outDir": "build",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"types": ["node"]
},
"include": ["src/**/*"]
}src/db.ts:
import Database from "better-sqlite3";
import { fileURLToPath } from "node:url";
export const DB_PATH = fileURLToPath(new URL("../orders.db", import.meta.url));
export function openReadOnly(): Database.Database {
return new Database(DB_PATH, { readonly: true, fileMustExist: true });
}src/seed.ts:
import Database from "better-sqlite3";
import { rmSync } from "node:fs";
import { DB_PATH } from "./db.js";
rmSync(DB_PATH, { force: true });
const db = new Database(DB_PATH);
db.exec(`
CREATE TABLE customers (id INTEGER PRIMARY KEY, name TEXT NOT NULL, email TEXT NOT NULL);
CREATE TABLE orders (
id INTEGER PRIMARY KEY,
customer_id INTEGER NOT NULL REFERENCES customers(id),
status TEXT NOT NULL CHECK (status IN ('pending','paid','shipped','cancelled')),
total_cents INTEGER NOT NULL,
currency TEXT NOT NULL,
created_at TEXT NOT NULL
);
`);
const addCustomer = db.prepare("INSERT INTO customers (id, name, email) VALUES (?, ?, ?)");
addCustomer.run(1, "Acme GmbH", "billing@acme.example");
addCustomer.run(2, "Nordlicht AB", "ap@nordlicht.example");
addCustomer.run(3, "Rivera Studio", "hello@rivera.example");
const addOrder = db.prepare(
"INSERT INTO orders (id, customer_id, status, total_cents, currency, created_at) VALUES (?, ?, ?, ?, ?, ?)"
);
addOrder.run(1040, 1, "shipped", 12500, "EUR", "2026-09-25");
addOrder.run(1041, 2, "paid", 4200, "EUR", "2026-09-27");
addOrder.run(1042, 1, "pending", 31000, "EUR", "2026-09-28");
addOrder.run(1050, 3, "cancelled", 1500, "EUR", "2026-09-29");
addOrder.run(1057, 2, "pending", 8950, "EUR", "2026-09-30");
db.close();
console.error(`Seeded ${DB_PATH}`);src/server.ts:
import type Database from "better-sqlite3";
import { McpServer } from "@modelcontextprotocol/server";
import { z } from "zod";
const STATUSES = ["pending", "paid", "shipped", "cancelled"] as const;
const MAX_ROWS = 50;
interface OrderRow {
id: number;
status: string;
total_cents: number;
currency: string;
created_at: string;
customer_name: string;
}
const SELECT_ORDERS = `
SELECT o.id, o.status, o.total_cents, o.currency, o.created_at, c.name AS customer_name
FROM orders o JOIN customers c ON c.id = o.customer_id
`;
function reply(text: string, isError = false) {
return { content: [{ type: "text" as const, text }], isError };
}
function formatOrder(r: OrderRow): string {
return `#${r.id} (${r.created_at.slice(0, 10)}, ${r.currency} ${(r.total_cents / 100).toFixed(2)}, ${r.status}, ${r.customer_name})`;
}
export function createServer(db: Database.Database): McpServer {
const server = new McpServer({ name: "orders-db", version: "1.0.0" });
server.registerTool(
"get_order",
{
description: "Get one order by its numeric ID, with customer name, status and total.",
inputSchema: z.object({ order_id: z.number().int().positive().describe("The order ID, for example 1042") })
},
async ({ order_id }) => {
try {
const row = db.prepare(`${SELECT_ORDERS} WHERE o.id = ?`).get(order_id) as OrderRow | undefined;
return reply(row ? formatOrder(row) : `No order with ID ${order_id}.`);
} catch (err) {
console.error(err);
return reply("The orders database could not be queried.", true);
}
}
);
server.registerTool(
"list_orders",
{
description: "List recent orders, newest first. Optionally filter by status. Returns at most 50 orders.",
inputSchema: z.object({
status: z.enum(STATUSES).optional().describe("Only return orders with this status"),
limit: z.number().int().min(1).max(MAX_ROWS).default(10).describe("How many orders to return, 1-50")
})
},
async ({ status, limit }) => {
try {
const rows = db
.prepare(`${SELECT_ORDERS} WHERE (? IS NULL OR o.status = ?) ORDER BY o.created_at DESC LIMIT ?`)
.all(status ?? null, status ?? null, limit + 1) as OrderRow[];
const shown = rows.slice(0, limit);
const more = rows.length > limit ? " More orders exist, so narrow the status filter." : "";
return reply(`${shown.length} orders: ${shown.map(formatOrder).join("; ")}.${more}`);
} catch (err) {
console.error(err);
return reply("The orders database could not be queried.", true);
}
}
);
return server;
}src/stdio.ts:
import { StdioServerTransport } from "@modelcontextprotocol/server/stdio";
import { openReadOnly } from "./db.js";
import { createServer } from "./server.js";
const server = createServer(openReadOnly());
await server.connect(new StdioServerTransport());
console.error("orders-db MCP server running on stdio");src/http.ts:
import { timingSafeEqual } from "node:crypto";
import {
createMcpExpressApp,
getOAuthProtectedResourceMetadataUrl,
mcpAuthMetadataRouter,
requireBearerAuth
} from "@modelcontextprotocol/express";
import { toNodeHandler } from "@modelcontextprotocol/node";
import { createMcpHandler, OAuthError, OAuthErrorCode } from "@modelcontextprotocol/server";
import { openReadOnly } from "./db.js";
import { createServer } from "./server.js";
function requireEnv(name: string): string {
const value = process.env[name];
if (!value) throw new Error(`Missing environment variable ${name}`);
return value;
}
const port = Number(process.env.PORT || 3000);
const allowedHosts = process.env.ALLOWED_HOSTS ? process.env.ALLOWED_HOSTS.split(",") : undefined;
const expected = Buffer.from(requireEnv("MCP_BEARER_TOKEN"));
const mcpServerUrl = new URL(requireEnv("MCP_SERVER_URL"));
const issuer = requireEnv("AUTH_ISSUER_URL");
// Tutorial stand-in. A real deployment verifies a JWT from your identity provider
// and checks that its audience is this server.
const verifier = {
async verifyAccessToken(token: string) {
const given = Buffer.from(token);
if (given.length !== expected.length || !timingSafeEqual(given, expected)) {
throw new OAuthError(OAuthErrorCode.InvalidToken, "Invalid token");
}
return { token, clientId: "dev-client", scopes: ["mcp"], expiresAt: Math.floor(Date.now() / 1000) + 3600 };
}
};
const oauthMetadata = {
issuer,
authorization_endpoint: `${issuer}/authorize`,
token_endpoint: `${issuer}/token`,
response_types_supported: ["code"]
};
const db = openReadOnly();
const node = toNodeHandler(createMcpHandler(() => createServer(db)));
const app = allowedHosts
? createMcpExpressApp({ host: "0.0.0.0", allowedHosts })
: createMcpExpressApp();
const auth = requireBearerAuth({
verifier,
requiredScopes: ["mcp"],
resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(mcpServerUrl)
});
app.use(mcpAuthMetadataRouter({ oauthMetadata, resourceServerUrl: mcpServerUrl }));
app.all("/mcp", auth, (req, res) => void node(req, res, req.body));
app.listen(port, allowedHosts ? "0.0.0.0" : "127.0.0.1", () => {
console.log(`orders-db MCP server listening on port ${port}`);
});HTTP servers can log to stdout. The official guide says standard output logging is fine for HTTP-based servers, and the console.log above is the only place this project uses it.
Python (stdio only)
The Python server has the same two tools and the same data. Two differences from the TypeScript one: limit is clamped to 50 inside the function (status is typed as a Literal), so a call with limit 500 returns 50 rows instead of being rejected, and a failure is raised as ToolError, which the SDK turns into an isError result the model can read. The read-only guarantee comes from opening the file through a read-only URI, using only the standard library.
Project layout:
orders-db-py/
pyproject.toml
seed.py
server.pyInstall and run:
cd orders-db-py
uv sync
uv run python seed.py
uv run mcp dev server.py # opens the Inspector against it
uv run python server.py # plain stdio, for Claude Code and Claude DesktopThere's no .env.example for this version, because a stdio server needs no secrets here.
pyproject.toml:
[project]
name = "orders-db-py"
version = "1.0.0"
requires-python = ">=3.10"
dependencies = ["mcp[cli]>=2.2.0,<3"]seed.py:
import sqlite3
import sys
from pathlib import Path
DB_PATH = Path(__file__).parent / "orders.db"
DB_PATH.unlink(missing_ok=True)
db = sqlite3.connect(DB_PATH)
db.executescript(
"""
CREATE TABLE customers (id INTEGER PRIMARY KEY, name TEXT NOT NULL, email TEXT NOT NULL);
CREATE TABLE orders (
id INTEGER PRIMARY KEY,
customer_id INTEGER NOT NULL REFERENCES customers(id),
status TEXT NOT NULL CHECK (status IN ('pending','paid','shipped','cancelled')),
total_cents INTEGER NOT NULL,
currency TEXT NOT NULL,
created_at TEXT NOT NULL
);
"""
)
db.executemany(
"INSERT INTO customers (id, name, email) VALUES (?, ?, ?)",
[
(1, "Acme GmbH", "billing@acme.example"),
(2, "Nordlicht AB", "ap@nordlicht.example"),
(3, "Rivera Studio", "hello@rivera.example"),
],
)
db.executemany(
"INSERT INTO orders (id, customer_id, status, total_cents, currency, created_at) VALUES (?, ?, ?, ?, ?, ?)",
[
(1040, 1, "shipped", 12500, "EUR", "2026-09-25"),
(1041, 2, "paid", 4200, "EUR", "2026-09-27"),
(1042, 1, "pending", 31000, "EUR", "2026-09-28"),
(1050, 3, "cancelled", 1500, "EUR", "2026-09-29"),
(1057, 2, "pending", 8950, "EUR", "2026-09-30"),
],
)
db.commit()
db.close()
print(f"Seeded {DB_PATH}", file=sys.stderr)server.py:
import sqlite3
from contextlib import closing
from pathlib import Path
from typing import Literal
from mcp.server import MCPServer
from mcp.server.mcpserver.exceptions import ToolError
DB_PATH = Path(__file__).parent / "orders.db"
MAX_ROWS = 50
SELECT_ORDERS = """
SELECT o.id, o.status, o.total_cents, o.currency, o.created_at, c.name
FROM orders o JOIN customers c ON c.id = o.customer_id
"""
mcp = MCPServer("orders-db")
def connect() -> sqlite3.Connection:
return sqlite3.connect(f"{DB_PATH.as_uri()}?mode=ro", uri=True)
def format_order(row: tuple) -> str:
order_id, status, total_cents, currency, created_at, name = row
return f"#{order_id} ({created_at[:10]}, {currency} {total_cents / 100:.2f}, {status}, {name})"
@mcp.tool()
async def get_order(order_id: int) -> str:
"""Get one order by its numeric ID, with customer name, status and total.
Args:
order_id: The order ID, for example 1042.
"""
try:
with closing(connect()) as db:
row = db.execute(f"{SELECT_ORDERS} WHERE o.id = ?", (order_id,)).fetchone()
except sqlite3.Error as exc:
raise ToolError("The orders database could not be queried.") from exc
return format_order(row) if row else f"No order with ID {order_id}."
@mcp.tool()
async def list_orders(
status: Literal["pending", "paid", "shipped", "cancelled"] | None = None,
limit: int = 10,
) -> str:
"""List recent orders, newest first. Optionally filter by status. Returns at most 50 orders.
Args:
status: Only return orders with this status.
limit: How many orders to return, 1-50.
"""
limit = max(1, min(limit, MAX_ROWS))
try:
with closing(connect()) as db:
rows = db.execute(
f"{SELECT_ORDERS} WHERE (? IS NULL OR o.status = ?) ORDER BY o.created_at DESC LIMIT ?",
(status, status, limit + 1),
).fetchall()
except sqlite3.Error as exc:
raise ToolError("The orders database could not be queried.") from exc
shown = rows[:limit]
more = " More orders exist, so narrow the status filter." if len(rows) > limit else ""
return f"{len(shown)} orders: {'; '.join(format_order(r) for r in shown)}.{more}"
if __name__ == "__main__":
mcp.run(transport="stdio")Register the Python server with Claude Code, using an absolute path:
claude mcp add orders-db-py -- uv --directory /ABSOLUTE/PATH/TO/orders-db-py run server.pyFor Claude Desktop, use uv as the command in claude_desktop_config.json. The official guide notes you may need the full path to the uv executable in the command field, so run which uv and paste the result. Restart Claude Desktop after saving:
{
"mcpServers": {
"orders-db-py": {
"command": "uv",
"args": ["--directory", "/ABSOLUTE/PATH/TO/orders-db-py", "run", "server.py"]
}
}
}The Python equivalent of the Quickstart, in a separate scratch project. Run uv init quickstart && cd quickstart, uv venv, source .venv/bin/activate and uv add "mcp[cli]", then save quickstart.py:
from mcp.server import MCPServer
mcp = MCPServer("orders-db")
@mcp.tool()
async def echo(text: str) -> str:
"""Echo the text back."""
return text
if __name__ == "__main__":
mcp.run(transport="stdio")Run it with uv run mcp dev quickstart.py.
Reference
Transports
| Transport | Status | Use it for |
|---|---|---|
| stdio | Standard | A server the client starts as a local child process |
| Streamable HTTP | Standard | Anything remote, or shared between users |
| HTTP+SSE (2024-11-05) | Deprecated since 2025-03-26 | Nothing new. The spec says new implementations should not adopt it |
Streamable HTTP in the 2026-07-28 revision
Streamable HTTP has two shapes. The 2026-07-28 revision is stateless: no protocol sessions, no GET stream, and every request carries its own protocol version and client capabilities. The 2025 revisions (2025-03-26 to 2025-11-25) used an initialize handshake and an optional Mcp-Session-Id. The v2 SDK speaks both. Its default negotiated version is 2025-11-25, and 2026-07-28 is opt-in per request. The handler from step 7 serves both: 2026-07-28 requests as described here, and older clients statelessly, with no sessions. Pass legacy: 'reject' to createMcpHandler to refuse the older ones.
The spec describes one MCP endpoint that accepts POST. Each JSON-RPC message is its own POST. The client's Accept header lists application/json and text/event-stream, and the server answers with JSON or a request-scoped event stream. Revision 2026-07-28 removed the GET stream endpoint and protocol-level sessions (Mcp-Session-Id). A server should ignore that header, and GET or DELETE requests should get a 405.
A 2026-07-28 POST must carry MCP-Protocol-Version: 2026-07-28, Mcp-Method and, for tool calls, Mcp-Name. The body must carry the same version and the client's capabilities in params._meta: io.modelcontextprotocol/protocolVersion and io.modelcontextprotocol/clientCapabilities are required, and clientInfo is optional. If a header disagrees with the body you get 400 with -32020 HeaderMismatch. If a _meta key is missing you get 400 with -32602. The SDK docs' own tools/list curl sends neither the headers nor _meta. It still works because the handler accepts that as a 2025-era request, but it isn't a 2026-07-28 request, which is why the step 8 negative checks use it only for status codes.
The spec says local servers should bind to localhost rather than 0.0.0.0, and it requires servers to validate the Origin header, returning 403 when it's invalid.
Auth requirements and status codes
A server acts as an OAuth 2.1 resource server and must implement RFC 9728 Protected Resource Metadata, so a client can discover which authorization server to use. It expects Authorization: Bearer on every request, and tokens must never go in the query string. It must validate that the token was issued for this server (the audience), and answer 401 for a missing, invalid or expired token. Insufficient scope is a 403 with a WWW-Authenticate: Bearer error="insufficient_scope" header.
| Status | When |
|---|---|
| 401 | No token, an invalid token, or an expired one |
| 403 | Valid token, missing scope. Also an invalid Origin |
| 400 | Protocol headers that don't match the request body (-32020 HeaderMismatch), a missing _meta key (-32602), a missing capability (-32021) or an unsupported version (-32022) |
Claude Code output limits
Claude Code warns when a tool result passes 10,000 tokens and caps results at 25,000 tokens by default. You can raise the cap with export MAX_MCP_OUTPUT_TOKENS=50000, and a tool can declare _meta: {"anthropic/maxResultSizeChars": 200000} in its tool definition (the tools/list response), up to 500,000 characters. Don't reach for either. A server that needs a bigger limit usually needs a narrower tool.
Sharing a local server with a team
The default scope for claude mcp add is local. Add --scope project and Claude Code writes a .mcp.json you can commit, and --scope user makes the server available across all your projects. --env KEY=VALUE passes environment variables to the server, and claude mcp get orders-db shows one server.
claude mcp add --scope project orders-db -- node ./build/stdio.jsCommitted .mcp.json files are for command, args and non-secret settings. They support ${VAR} and ${VAR:-default} expansion, so a secret can stay in each developer's environment. Project-scoped servers need approval before they run in an interactive session. For a remote server, reference the token variable so each developer supplies their own:
{
"mcpServers": {
"orders-remote": {
"type": "http",
"url": "https://mcp.example.com/mcp",
"headers": { "Authorization": "Bearer ${MCP_BEARER_TOKEN}" }
}
}
}Remote servers in Claude Desktop and claude.ai
Anthropic's custom connectors guide says servers in claude_desktop_config.json are local and aren't available in Cowork or claude.ai, and that remote servers aren't configured through the file at all.
On Pro and Max plans, open Customize > Connectors, click "+" and choose "Add custom connector". On Team and Enterprise, an owner goes to Organization settings > Connectors, clicks "Add", then "Custom" and "Web". Free plans can add one custom connector. The server must be reachable over the public internet from Anthropic's IP ranges, so 127.0.0.1 and a laptop behind a VPN don't qualify. That page doesn't cover which transports or auth schemes the connector UI accepts, so read it before you pick an auth design. Once your server is public, set ALLOWED_HOSTS to its hostname (for example mcp.example.com) so Host validation passes, and put TLS in front of it.
Security checklist
Most of what breaks first in production is access, not code: a port open to the wrong network, a token that was never scoped, a secret that ended up in a tool result. MCP's security best practices page is the source for the rules below.
- Validate
Originon every HTTP request, and bind to127.0.0.1locally.createMcpExpressAppdoes the validation by default. Passinghost: "0.0.0.0"withoutallowedHostsremoves that protection, so the server in step 8 only widens the bind whenALLOWED_HOSTSis set. - Act as a resource server and nothing more. The spec says a server must not accept any token that wasn't explicitly issued for it. Don't forward the client's token to a downstream API ("token passthrough"). If your tool needs to call another service, give the server its own credential for that service.
- Check the audience on every token, not only the signature. A valid token for a different service is still the wrong token.
- Give the server the least privilege it needs. The database role has
SELECTand nothing else. Scopes are narrow: avoid wildcard scopes, and add per-tool scopes when a write tool arrives. - Keep secrets out of tool output. Whatever a tool returns goes into the model's context and into logs. Select the columns you need, not
SELECT *, and never return connection strings or tokens, even in an error message. - Don't treat a handle as authentication. Under 2026-07-28 there are no protocol sessions, and if you mint your own handle for state, the spec says possession of it must not count as authentication. Bind it to the authenticated user.
- Keep secrets in environment variables and out of any config you commit to git.
The policy question sits above all this: which tools may run without asking, and which must stop and confirm. For that layer, see read-only tools run free, write tools ask first, which covers how to set the rule once for a team.
Common mistakes
- Logging to stdout from a stdio server.
console.log()andprint()break the protocol stream. Useconsole.errororlogging. - Using relative paths in client configs. Claude Code and Claude Desktop start the server from their own working directory, so use absolute paths, and the full path to
uvif Desktop can't find it. - Forgetting
npm run build. The clients runbuild/stdio.js, so a stale or missing build is a server that won't connect. - Trusting a tool description for read-only. Open the database read-only, or give it a role that can only
SELECT. - Passing the client's token on to another API. The spec forbids accepting tokens not issued for your server, and forwarding one is the same mistake in the other direction.
- Binding to
0.0.0.0without a Host and Origin allowlist, or adopting the deprecated HTTP+SSE transport because an older tutorial did.
Next steps
- Add a resource or a prompt. Tools are only one of the things a server can offer, and the official MCP build-server guide is the place to start.
- Add per-tool scopes. The v2 guide shows
scopeChallenge: requireScopes('notes:write')on a tool registration, which is how a future write tool gets its own scope. - Replace the static-token verifier with real JWT validation and an audience check against your identity provider.
- Decide the policy before you add a write tool: which calls run free and which ask first. The security checklist in Reference links to that layer.
- Put the server on a public HTTPS host, then submit a remote MCP server as a connector through Anthropic's directory, or add it as a custom connector in Claude Desktop.
FAQ
What's the difference between stdio and Streamable HTTP?
Stdio runs the server as a child process on the same machine as the client, and talks over standard input and output. Streamable HTTP serves it at a URL, one POST per JSON-RPC message, so remote and shared servers can use it. Both are standard transports in the current spec.
Is SSE deprecated for MCP?
The old HTTP+SSE transport from the 2024-11-05 spec has been deprecated since 2025-03-26, and the spec says new implementations should not adopt it. Claude Code's docs also mark SSE as deprecated. Streamable HTTP can still use server-sent events for a response, scoped to a single request.
Should I use TypeScript or Python?
Use the one your team already runs in production. Both official SDKs are current (@modelcontextprotocol/server 2.2.0 and PyPI mcp 2.2.0 on 2026-10-01). This tutorial shows HTTP and auth in TypeScript, so if you need those today, the TypeScript SDK has documented pieces for them. Don't mix up mcp with the separate third-party fastmcp package.
Do I need auth for a local MCP server?
Not for stdio. The spec says stdio servers should not follow the OAuth flow and should take credentials from the environment. Once the server has a URL, authenticate every connection, validate Origin, and bind to 127.0.0.1 unless you have a reason not to.
How do I test a server without a client?
Use the MCP Inspector: npx @modelcontextprotocol/inspector node build/stdio.js for the web UI, or add --cli --method tools/list for scripted checks. It needs Node 22.19.0 or newer. For a Python server, uv run mcp dev server.py does the same job.
Does Claude Desktop support remote servers through the config file?
No. claude_desktop_config.json is for local servers, and Anthropic's guide says those servers aren't available in Cowork or claude.ai. Remote servers are added as custom connectors under Customize > Connectors (Pro and Max) or Organization settings > Connectors (Team and Enterprise), and the server must be reachable over the public internet from Anthropic's IP ranges.
