This guide builds a small remote MCP server for a notes service. The example uses the stable v2 TypeScript SDK, which implements MCP 2026-07-28. The server accepts Streamable HTTP requests, verifies OAuth access tokens as a resource server, exposes read and delete tools, validates arguments with Zod, and checks permission next to the protected operation. The code is an executable example, but this article does not claim that it has been run in your environment.
The protocol version is important. The v2 package is split into @modelcontextprotocol/server, @modelcontextprotocol/client, @modelcontextprotocol/core, and runtime adapters. Do not copy older examples that import @modelcontextprotocol/sdk/server/mcp.js when starting a new v2 service. The MCP 2026-07-28 migration guide explains the wire and package changes in more detail.
Start with the modern wire model
Streamable HTTP has one MCP endpoint, normally /mcp, and every client JSON-RPC request is a separate HTTP POST. The response is either one JSON object or an SSE stream scoped to that request. Notifications are acknowledged with 202 and no body. The client advertises both application/json and text/event-stream in Accept, and sends JSON with Content-Type: application/json.
The 2026-07-28 revision removes the old GET stream, protocol-level session identifiers, and resumable Last-Event-ID history. It also removes independent server-to-client JSON-RPC requests. A tool that needs confirmation or another value returns an input_required result. The client answers the embedded request and retries the original call. Long-lived list-change notifications use a subscriptions/listen response stream, not a general-purpose GET connection.
Every modern POST carries MCP-Protocol-Version, and its value must match _meta.io.modelcontextprotocol/protocolVersion in the JSON body. Mcp-Method mirrors the JSON-RPC method for every request. Mcp-Name mirrors params.name or params.uri for tools/call, resources/read, and prompts/get. A gateway may use those headers for routing, but the application must still reject a header and body that disagree. Treat the body as the source of truth.
A minimal modern call has this shape:
POST /mcp HTTP/1.1
Accept: application/json, text/event-stream
Content-Type: application/json
MCP-Protocol-Version: 2026-07-28
Mcp-Method: tools/call
Mcp-Name: notes.search
{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"notes.search","arguments":{"query":"gateway"},"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientInfo":{"name":"notes-cli","version":"1.0.0"},"io.modelcontextprotocol/clientCapabilities":{}}}}
Install the v2 SDK
Create an ESM Node project with Node.js 20 or newer, TypeScript 6, and the SDK packages. Pin versions in the application lockfile. The current stable server, node adapter, and Express adapter are 2.0.0 when this article was checked. The server package requires Zod 4 when Zod schemas are used.
npm install @modelcontextprotocol/[email protected] @modelcontextprotocol/[email protected] @modelcontextprotocol/[email protected] express@5 zod@4
npm install --save-dev typescript@6 @types/node @types/express tsx
With TypeScript 6, include Node types explicitly if your configuration does not already do so:
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"types": ["node"]
}
}
Build the server and the resource-server gate
The SDK's createMcpHandler receives a factory and constructs a fresh McpServer for each HTTP request. toNodeHandler adapts its web-standard fetch face to Node's request and response objects. The Express adapter provides JSON parsing, host and origin protections for an explicitly configured host, and the OAuth middleware.
An MCP server is an OAuth resource server. It verifies tokens issued by an authorization server and does not issue tokens itself. The verifier below uses RFC 7662 introspection. It deliberately checks active, sub, and exp, maps the space-separated scope claim, and returns the SDK's AuthInfo. Replace the introspection call with local JWT validation only when the issuer, audience, signature keys, clock policy, and key rotation are implemented and tested in your service.
import {
createMcpExpressApp,
getOAuthProtectedResourceMetadataUrl,
mcpAuthMetadataRouter,
requireBearerAuth,
type OAuthTokenVerifier
} from "@modelcontextprotocol/express";
import { toNodeHandler } from "@modelcontextprotocol/node";
import {
createMcpHandler,
McpServer,
OAuthError,
OAuthErrorCode,
type AuthInfo,
type OAuthMetadata
} from "@modelcontextprotocol/server";
import type { Request, Response } from "express";
import * as z from "zod/v4";
function required(name: string): string {
const value = process.env[name];
if (!value) throw new Error(`Missing ${name}`);
return value;
}
const host = process.env.HOST ?? "127.0.0.1";
const mcpUrl = new URL(required("MCP_URL"));
const scopes = ["mcp", "notes:read", "notes:write"];
const oauthMetadata: OAuthMetadata = {
issuer: required("OAUTH_ISSUER"),
authorization_endpoint: required("OAUTH_AUTHORIZATION_ENDPOINT"),
token_endpoint: required("OAUTH_TOKEN_ENDPOINT"),
response_types_supported: ["code"],
scopes_supported: scopes,
code_challenge_methods_supported: ["S256"],
authorization_response_iss_parameter_supported: true
};
const notes = [
{ id: "1", text: "Rotate the signing key after the release." },
{ id: "2", text: "Review the MCP gateway rate limit." }
];
async function verifyAccessToken(token: string): Promise<AuthInfo> {
const credentials = Buffer.from(`${required("OAUTH_CLIENT_ID")}:${required("OAUTH_CLIENT_SECRET")}`).toString("base64");
const response = await fetch(required("OAUTH_INTROSPECTION_URL"), {
method: "POST",
headers: {
authorization: `Basic ${credentials}`,
"content-type": "application/x-www-form-urlencoded"
},
body: new URLSearchParams({ token }).toString()
});
if (!response.ok) throw new OAuthError(OAuthErrorCode.InvalidToken, "Token introspection failed");
const payload = await response.json() as {
active?: unknown;
sub?: unknown;
client_id?: unknown;
scope?: unknown;
exp?: unknown;
};
if (payload.active !== true || typeof payload.sub !== "string" || typeof payload.exp !== "number") {
throw new OAuthError(OAuthErrorCode.InvalidToken, "Token is inactive or incomplete");
}
const tokenScopes = typeof payload.scope === "string" ? payload.scope.split(/\s+/).filter(Boolean) : [];
return {
token,
clientId: typeof payload.client_id === "string" ? payload.client_id : payload.sub,
scopes: tokenScopes,
expiresAt: payload.exp
};
}
const verifier: OAuthTokenVerifier = { verifyAccessToken };
function buildServer(): McpServer {
const server = new McpServer({ name: "notes", version: "1.0.0" });
server.registerTool(
"notes.search",
{
title: "Search notes",
description: "Find notes containing a phrase.",
inputSchema: z.object({
query: z.string().trim().min(1).max(200),
limit: z.number().int().min(1).max(20).default(10)
})
},
async ({ query, limit }, ctx) => {
if (!ctx.http?.authInfo?.scopes.includes("notes:read")) {
return { content: [{ type: "text", text: "insufficient_scope: notes:read is required" }], isError: true };
}
const needle = query.toLocaleLowerCase();
const matches = notes.filter(note => note.text.toLocaleLowerCase().includes(needle)).slice(0, limit);
return { content: [{ type: "text", text: JSON.stringify(matches) }] };
}
);
server.registerTool(
"notes.delete",
{
title: "Delete a note",
description: "Delete one note by id.",
inputSchema: z.object({ id: z.string().regex(/^\d+$/) })
},
async ({ id }, ctx) => {
if (!ctx.http?.authInfo?.scopes.includes("notes:write")) {
return { content: [{ type: "text", text: "insufficient_scope: notes:write is required" }], isError: true };
}
const index = notes.findIndex(note => note.id === id);
if (index === -1) return { content: [{ type: "text", text: "note not found" }], isError: true };
const [removed] = notes.splice(index, 1);
return { content: [{ type: "text", text: `deleted ${removed.id}` }] };
}
);
return server;
}
const handler = createMcpHandler(buildServer);
const resourceMetadataUrl = getOAuthProtectedResourceMetadataUrl(mcpUrl);
const app = createMcpExpressApp({ host, allowedHosts: [mcpUrl.hostname], jsonLimit: "64kb" });
app.use(mcpAuthMetadataRouter({ oauthMetadata, resourceServerUrl: mcpUrl, scopesSupported: scopes, resourceName: "Notes MCP" }));
const auth = requireBearerAuth({ verifier, requiredScopes: ["mcp"], resourceMetadataUrl });
const node = toNodeHandler(handler);
app.all(mcpUrl.pathname, auth, (req: Request, res: Response) => {
void node(req, res, req.body);
});
const port = mcpUrl.port ? Number(mcpUrl.port) : Number(process.env.PORT ?? 3000);
app.listen(port, host);
The mcp scope is an endpoint-level gate. The two tool checks are finer-grained. A caller with only mcp can discover the endpoint but receives an ordinary tool result with isError: true when it calls a protected operation. This keeps the refusal inside the model-visible tool result. If an entire endpoint needs a scope, put it in requiredScopes. The middleware returns 403 with insufficient_scope instead.
The metadata router publishes RFC 9728 Protected Resource Metadata and mirrors the supplied RFC 8414 authorization-server metadata. A client can follow the resource_metadata URL from the WWW-Authenticate challenge, discover endpoints, obtain a token, and retry. The TypeScript SDK does not implement your identity provider. Use a maintained provider or OAuth server. The v1 authorization-server helpers in @modelcontextprotocol/server-legacy/auth are frozen for migration, not preferred for new work.
The 2026-07-28 authorization rules also harden the client side. Authorization servers should return iss in authorization responses. Clients validate it against the issuer recorded during discovery before sending a code to the token endpoint. Client credentials and tokens must be partitioned by issuer. Client ID Metadata Documents are preferred over Dynamic Client Registration, which remains only for compatibility. These rules prevent an authorization-server mix-up from turning one server's code or token into another server's credential.
Make statelessness explicit
The factory creates a new server for each request, so never put the authenticated caller, a workflow phase, or a permission decision in a server instance and expect it to survive the next POST. Modern requests can reach any replica behind a normal round-robin load balancer. Store durable business state in a database or queue and pass an explicit, short-lived handle as a validated tool argument.
For a confirmation flow, return an input_required result and protect its state. The SDK exports inputRequired, acceptedContent, and createRequestStateCodec for this pattern:
import { acceptedContent, createRequestStateCodec, inputRequired, McpServer } from "@modelcontextprotocol/server";
import * as z from "zod/v4";
function buildServerWithConfirmation(): McpServer {
const key = process.env.REQUEST_STATE_KEY;
if (!key) throw new Error("Missing REQUEST_STATE_KEY");
type DeleteState = { noteId: string; step: "confirm" };
const stateCodec = createRequestStateCodec<DeleteState>({
key,
ttlSeconds: 300,
bind: ctx => `${ctx.mcpReq.method}\u0000${ctx.http?.authInfo?.clientId ?? ""}`
});
const server = new McpServer(
{ name: "notes-confirmation", version: "1.0.0" },
{ requestState: { verify: stateCodec.verify } }
);
const notes = [{ id: "1", text: "Rotate the signing key after the release." }];
const confirmationSchema = z.object({ confirm: z.boolean() });
server.registerTool(
"notes.delete",
{ inputSchema: z.object({ id: z.string().regex(/^\d+$/) }) },
async ({ id }, ctx) => {
const confirmed = acceptedContent(ctx.mcpReq.inputResponses, "confirm", confirmationSchema);
if (!confirmed) {
return inputRequired({
inputRequests: {
confirm: inputRequired.elicit({
message: "Delete this note?",
requestedSchema: confirmationSchema
})
},
requestState: await stateCodec.mint({ noteId: id, step: "confirm" }, ctx)
});
}
if (!confirmed.confirm) return { content: [{ type: "text", text: "deletion declined" }], isError: true };
const index = notes.findIndex(note => note.id === id);
if (index === -1) return { content: [{ type: "text", text: "note not found" }], isError: true };
notes.splice(index, 1);
return { content: [{ type: "text", text: `deleted ${id}` }] };
}
);
return server;
}
Attach stateCodec.verify to the requestState.verify option passed to McpServer. The state is signed, not encrypted, and the SDK treats echoed state as untrusted input until that hook verifies it. Bind it to the principal and operation, set an expiry, and keep secrets out of the payload. The client receives only the final public result after it fulfills the embedded request. A shared HMAC key is required when more than one replica can receive the retry.
Validate, limit, and deploy
Zod validation is the first application boundary, not the whole security model. Bound strings, arrays, numbers, IDs, URLs, paths, and result sizes. Validate again at the downstream API because a schema proves shape, not authorization or safe side effects. Do not let a model choose arbitrary methods, hosts, paths, SQL fragments, or shell arguments. Use allowlists and parameterized APIs.
Rate limiting should use the authenticated clientId or tenant as the primary key, with a separate limit for unauthenticated failures, discovery calls, expensive tools, and concurrent in-flight work. Return 429 with Retry-After at the edge or gateway. A process-local counter is acceptable for a single development process, but it is not a cluster-wide limit and an unbounded map can become memory pressure. Use a shared limiter such as Redis or the provider's gateway policy for multiple replicas. Include method and tool name in metrics using the validated headers, while never logging the bearer token.
Terminate TLS before the public endpoint or use end-to-end TLS, preserve the SSE response stream, and disable proxy buffering with X-Accel-Buffering: no where your proxy supports it. Set body, header, idle, and upstream timeouts deliberately. Bind a local server to 127.0.0.1, not 0.0.0.0, unless a configured allowlist and authentication protect it. The Express factory enables host and origin protections only for the configured localhost class. For a public bind, provide allowedHosts and allowedOrigins deliberately.
Modern protocol traffic needs no sticky sessions. A subscriptions/listen stream is different: if change notifications must work across replicas, provide a shared ServerEventBus backed by your pub/sub system. Keep database pools and caches at module scope, but keep caller-specific decisions inside the request context. Graceful shutdown should call handler.close() so in-flight modern exchanges stop before the process exits.
Test the wire, not only the function
The v2 SDK documents an in-process test path that calls createMcpHandler through StreamableHTTPClientTransport with a custom fetch function. Use the real request and response path so tests cover version negotiation, required headers, JSON versus SSE responses, authentication pass-through, schema failures, cancellation, and input_required retries. Add separate tests for 401 without a token, 401 for an inactive token, 403 for a missing endpoint scope, and an in-band isError refusal for a missing tool scope.
Use mode: { pin: "2026-07-28" } when a test is specifically for the modern wire. Use mode: "auto" in a compatibility test and assert the legacy fallback against a deliberately legacy fixture. Test two requests with different callers and verify that tool visibility and authorization do not leak between them. For a multi-node test, send the retry to a different process and verify that the shared state codec and durable store preserve the workflow.
Troubleshoot by the first failing layer
An HTTP 400 with HeaderMismatch means the protocol header and _meta claim differ, or a required routing header is absent. Fix the client or gateway and do not silently fall back to an older protocol when the body is a recognized modern error. An HTTP 415 means the POST media type is not application/json. A substring such as text/plain; a=application/json is not valid. A 404 for GET is expected on the modern endpoint. A 404 for POST can mean an unknown method or a route that never reached the MCP handler.
A 401 should expose a WWW-Authenticate challenge. Check the metadata URL, token issuer, audience or resource, signature or introspection status, and the numeric expiration claim. The SDK's bearer gate rejects an AuthInfo without expiresAt. A 403 insufficient_scope indicates an endpoint-level scope challenge and may start a client's step-up flow. A tool-level refusal with isError: true is an application result and will not automatically authorize a broader token.
If a client hangs, inspect the URL, reverse-proxy SSE buffering, idle timeout, and response for the same JSON-RPC ID. If a modern client reports a legacy server, check versionNegotiation, SDK versions, and whether a gateway stripped _meta or required headers. If confirmation repeats, verify the response key, validate inputResponses, cap rounds, and inspect signed state. For migration context, read the MCP 2026-07-28 migration guide. For injection and tool poisoning, read prompt injection and MCP security. For service boundaries and failure isolation, use production AI agent architecture.
Sources
- MCP Streamable HTTP specification, 2026-07-28
- MCP authorization specification, 2026-07-28
- MCP TypeScript SDK v2 package reference
createMcpHandlerAPI referencerequireBearerAuthAPI reference- MCP TypeScript SDK guide for 2026-07-28
- RFC 7662 OAuth 2.0 Token Introspection
- RFC 9207 OAuth authorization-server issuer identification
- RFC 9728 OAuth 2.0 Protected Resource Metadata