By the Notion Tech team · Published [publish date] · Last verified 12 November 2026
The Model Context Protocol (MCP) changed shape on 28 July 2026. The 2026-07-28 revision dropped protocol sessions and the initialize handshake, so every request now carries its own protocol version and client capabilities. Any instance behind a plain load balancer can answer any request. The official TypeScript SDK v2 shipped alongside it as split packages (@modelcontextprotocol/server, @modelcontextprotocol/client and thin runtime adapters).
The short answer: to build a stateless MCP server in TypeScript today, install @modelcontextprotocol/server (v2), register your tools inside a factory function, and serve that factory with createMcpHandler. The SDK then builds a fresh server instance for every HTTP request. Put authentication, Host/Origin checks and rate limiting in front of the handler, because the handler checks none of them. When a tool needs a person’s sign-off, return an input_required result rather than holding a connection open.
This guide builds a small invoice assistant with one read-only tool and one tool that sends a payment reminder only after a person confirms. We ran every code sample on 12 November 2026 against SDK 2.4.0, Node.js 24.22.1 and TypeScript 7.0.2. Along the way we found two things the quick-start pages don’t put up front: the v2 client still connects in 2025 mode unless you opt in, and an approval prompt on its own is not a security control.
In short –
2026-07-28makes MCP request/response and stateless: noMcp-Session-Id, noinitialize, a newserver/discovercall, and routing headers (Mcp-Method,Mcp-Name). – In SDK v2,createMcpHandler(factory)runs your factory once per request. Keep shared resources at module scope. – Server-to-client requests (elicitation, sampling, roots) are replaced by Multi Round-Trip Requests: the server returnsinput_required, and the client answers and retries. – The v2Clientdefaults toversionNegotiation: 'legacy'. Pin2026-07-28(or use'auto') to talk the new protocol. – A client can sendinputResponsesthat no person ever saw. Bind approvals to signedrequestState, and use an out-of-band approval for anything high-stakes.
What changed in MCP 2026-07-28
The changelog is long, but four changes matter most to server authors. All of them come from the official 2026-07-28 changelog and the release announcement.
| Concern | 2025-11-25 and earlier | 2026-07-28 |
|---|---|---|
| Connection set-up | initialize / notifications/initialized handshake, then a session |
No handshake. Each request carries protocol version and client capabilities in _meta. Optional server/discover (servers MUST implement it) |
| Sessions | Mcp-Session-Id header on Streamable HTTP |
Removed. Cross-call state uses explicit handles passed as tool arguments |
| Asking the client for input | Server sends elicitation/create, sampling/createMessage or roots/list over an open stream |
Server returns resultType: "input_required" with inputRequests. The client retries the original call with inputResponses (SEP-2322) |
| Routing | Gateways had to parse JSON bodies | Mcp-Method and Mcp-Name HTTP headers on every request |
| Change notifications | HTTP GET stream, resources/subscribe |
One opt-in subscriptions/listen POST stream |
| Stream resumability | Last-Event-ID redelivery |
Removed. A broken stream means the client re-issues the request |
| Long-running work | Experimental tasks in core | Moved to the official io.modelcontextprotocol/tasks extension |
Authorisation was hardened too: RFC 9207 issuer validation, and a formal move away from Dynamic Client Registration towards Client ID Metadata Documents (CIMD). The release also introduced a formal deprecation policy with a minimum twelve-month window. The TypeScript SDK docs mark sampling and roots as deprecated from this revision (SEP-2577), so reach for elicitation first.
Why it matters in production: under 2025-era Streamable HTTP, a session pinned a client to whichever instance held its state. That meant sticky sessions, shared session stores, or both. With a stateless core, an MCP server scales like any other JSON API. It can run on serverless and edge runtimes, and gateways can authorise on headers without parsing bodies.
What we’re building

A small invoice assistant that an AI agent can use:
find_overdue_invoices: read-only, returns structured output.send_payment_reminder: has a side effect, so it is only advertised to tokens with thereminders:sendscope, and it asks a person to confirm before it acts.
The request path looks like this:
MCP host (agent + UI)
│ POST /mcp Authorization: Bearer … Mcp-Method: tools/call Mcp-Name: send_payment_reminder
▼
Host/Origin checks → bearer-token gate (audience + scopes) → createMcpHandler(factory)
│ new McpServer per request
▼
tools → your database / accounting API
The invoice data is an in-memory stand-in for a database or accounting API such as Xero. Swap it for your real data layer.
Set up the project
You need Node.js 20 or later (the SDK’s stated minimum). We used Node.js 24.22.1.
mkdir invoice-mcp && cd invoice-mcp
npm init -y && npm pkg set type=module
npm install @modelcontextprotocol/server @modelcontextprotocol/client @modelcontextprotocol/node zod
npm install -D typescript tsx @types/node
The server package README notes that TypeScript 6.0 and later no longer auto-include @types/*, so add "types": ["node"] yourself:
{
"compilerOptions": {
"target": "ES2023",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"types": ["node"],
"noEmit": true,
"skipLibCheck": true
},
"include": ["src"]
}
Versions installed on 12 November 2026: @modelcontextprotocol/server 2.4.0, @modelcontextprotocol/client 2.4.0, @modelcontextprotocol/node 2.1.2, zod 4.6.7, typescript 7.0.2 (the new Go-based compiler), tsx 4.23.15.
Step 1: Module scope and the per-request factory
createMcpHandler calls your factory once per HTTP request and keeps nothing between requests. The SDK’s serving guide is explicit: register tools inside the factory, keep it cheap and side-effect free, and create connection pools and caches once at module scope.
The first block of src/server.ts holds everything shared: data access, an idempotency record, and a signing codec we’ll use in Step 2.
// src/server.ts
import {
McpServer,
createMcpHandler,
requireBearerAuth,
inputRequired,
acceptedContent,
inputResponse,
createRequestStateCodec,
OAuthError,
OAuthErrorCode,
type AuthInfo,
type CallToolResult,
type InputRequiredResult,
} from '@modelcontextprotocol/server';
import * as z from 'zod/v4';
export const RESOURCE = new URL(process.env.MCP_RESOURCE ?? 'http://127.0.0.1:3000/mcp');
// --- Module scope: shared resources live here, never on the per-request server ---
type Invoice = { id: string; customer: string; email: string; amountCents: number; dueDate: string };
// Stand-in for your database or accounting API (Xero, QuickBooks, etc.).
const invoices: Invoice[] = [
{ id: 'INV-1042', customer: 'Harbour Physio', email: 'accounts@harbourphysio.example', amountCents: 184_000, dueDate: '2026-10-20' },
{ id: 'INV-1049', customer: 'Parramatta Plumbing', email: 'admin@parraplumbing.example', amountCents: 52_500, dueDate: '2026-11-01' },
{ id: 'INV-1051', customer: 'Glebe Florist', email: 'hello@glebeflorist.example', amountCents: 23_900, dueDate: '2026-11-20' },
];
const remindersSent = new Set<string>(); // use a unique DB constraint in production
const daysOverdue = (dueDate: string, today = new Date()) =>
Math.floor((today.getTime() - new Date(`${dueDate}T00:00:00Z`).getTime()) / 86_400_000);
// Signed (HMAC-SHA256), short-lived state that proves *this server* asked the question.
// Share one key across every instance behind the load balancer.
type ApprovalState = { invoiceId: string; clientId: string; askedAt: number };
const approvalState = createRequestStateCodec<ApprovalState>({
key: process.env.MCP_STATE_KEY
? Buffer.from(process.env.MCP_STATE_KEY, 'base64')
: crypto.getRandomValues(new Uint8Array(32)), // dev only: random per process
ttlSeconds: 300,
});
const confirmSchema = z.object({
confirm: z.boolean().meta({ title: 'Send this reminder now?' }),
});
Next, the factory and the read-only tool. A single Zod schema gives you the JSON Schema the model sees, argument validation before your handler runs, and inferred types. outputSchema plus structuredContent gives clients a machine-readable result that the SDK validates before it leaves the server.
// --- Factory: runs once per HTTP request, so keep it cheap and side-effect free ---
export function buildServer({ authInfo }: { authInfo?: AuthInfo }) {
const server = new McpServer(
{ name: 'invoice-assistant', version: '1.0.0' },
{ maxToolInputElements: 1_000, requestState: { verify: approvalState.verify } }
);
const scopes = new Set(authInfo?.scopes ?? []);
server.registerTool(
'find_overdue_invoices',
{
title: 'Find overdue invoices',
description: 'List unpaid invoices that are at least `minDaysOverdue` days past their due date.',
inputSchema: z.object({
minDaysOverdue: z.number().int().min(0).max(365).describe('Only include invoices at least this many days overdue'),
}),
outputSchema: z.object({
invoices: z.array(z.object({ id: z.string(), customer: z.string(), amountCents: z.number(), daysOverdue: z.number() })),
}),
annotations: { readOnlyHint: true, openWorldHint: false },
},
async ({ minDaysOverdue }) => {
const rows = invoices
.map(inv => ({ id: inv.id, customer: inv.customer, amountCents: inv.amountCents, daysOverdue: daysOverdue(inv.dueDate) }))
.filter(row => row.daysOverdue >= minDaysOverdue);
const output = { invoices: rows };
return { content: [{ type: 'text', text: JSON.stringify(output) }], structuredContent: output };
}
);
Two details worth copying:
- Bounds on every argument.
.max(365)isn’t decoration. Out-of-range input comes back as anisError: truetool result the model can read and correct, and your handler never runs. maxToolInputElements. It is off by default and caps how many array elements and object members a single call’s arguments can contain, on top of the transport’s 4 MiB body limit.
Step 2: Ask a person before acting, with input_required

In 2025-era MCP, a tool handler would await an elicitation while the server held an open stream back to the client. That doesn’t work on a stateless server, so 2026-07-28 replaces it with Multi Round-Trip Requests (MRTR):
- The handler returns
inputRequired({ inputRequests: {...} })instead of a result. - The client shows the request to the person, then retries the same
tools/callwithinputResponses. - The handler runs again from the top, finds the answer, and completes.
So the handler must be written to run more than once. Do the cheap checks first, read any answers, and only then perform the side effect.
// Only advertise the write tool to callers whose token carries the scope.
if (scopes.has('reminders:send')) {
server.registerTool(
'send_payment_reminder',
{
title: 'Send payment reminder',
description: 'Email a polite payment reminder for one overdue invoice. A person must confirm before it is sent.',
inputSchema: z.object({ invoiceId: z.string().regex(/^INV-\d{4}$/) }),
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
},
async ({ invoiceId }, ctx): Promise<CallToolResult | InputRequiredResult> => {
const invoice = invoices.find(inv => inv.id === invoiceId);
if (!invoice) return { content: [{ type: 'text', text: `No invoice ${invoiceId}` }], isError: true };
if (daysOverdue(invoice.dueDate) <= 0) {
return { content: [{ type: 'text', text: `${invoiceId} is not overdue yet` }], isError: true };
}
// Check idempotency before bothering a person.
const key = `${invoiceId}:${new Date().toISOString().slice(0, 10)}`;
if (remindersSent.has(key)) {
return { content: [{ type: 'text', text: `A reminder for ${invoiceId} was already sent today.` }] };
}
// Round 2+: did the person decline or dismiss the prompt?
const answer = inputResponse(ctx.mcpReq.inputResponses, 'confirm');
if (answer.kind === 'elicit' && answer.action !== 'accept') {
return { content: [{ type: 'text', text: `Reminder for ${invoiceId} not sent: the user chose ${answer.action}.` }] };
}
// Accept a "yes" only if it answers a question this server asked about this invoice.
const state = ctx.mcpReq.requestState<ApprovalState>();
const confirmed = acceptedContent(ctx.mcpReq.inputResponses, 'confirm', confirmSchema);
if (confirmed?.confirm !== true || state?.invoiceId !== invoiceId || state.clientId !== authInfo?.clientId) {
const amount = (invoice.amountCents / 100).toLocaleString('en-AU', { style: 'currency', currency: 'AUD' });
return inputRequired({
inputRequests: {
confirm: inputRequired.elicit({
message: `Send a reminder to ${invoice.customer} (${invoice.email}) for ${invoiceId}, ${amount}?`,
requestedSchema: confirmSchema,
}),
},
requestState: await approvalState.mint({ invoiceId, clientId: authInfo?.clientId ?? 'anonymous', askedAt: Date.now() }),
});
}
// Confirmed: perform the side effect once, then log it.
remindersSent.add(key);
console.log(JSON.stringify({ event: 'reminder_sent', invoiceId, by: authInfo?.clientId, at: new Date().toISOString() }));
return { content: [{ type: 'text', text: `Reminder sent to ${invoice.email} for ${invoiceId}.` }] };
}
);
}
return server;
}
What actually travels on the wire
We logged the HTTP exchange from an SDK client pinned to 2026-07-28. Trimmed for readability:
POST /mcp
Mcp-Method: tools/call
Mcp-Name: send_payment_reminder
{"method":"tools/call","params":{"name":"send_payment_reminder","arguments":{"invoiceId":"INV-1049"},
"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28",
"io.modelcontextprotocol/clientCapabilities":{"elicitation":{"form":{}}}}}}
← 200 {"result":{"resultType":"input_required",
"inputRequests":{"confirm":{"method":"elicitation/create","params":{"mode":"form",
"message":"Send a reminder to Parramatta Plumbing (admin@parraplumbing.example) for INV-1049, $525.00?",
"requestedSchema":{"type":"object","properties":{"confirm":{"type":"boolean"}},"required":["confirm"]}}}},
"requestState":"v1.eyJwIjp7Imludm9pY2VJZCI6…"}}
POST /mcp (same method, name and arguments, new request id)
{"params":{…,"inputResponses":{"confirm":{"action":"accept","content":{"confirm":true}}},
"requestState":"v1.eyJwIjp7Imludm9pY2VJZCI6…"}}
← 200 {"result":{"resultType":"complete","content":[{"type":"text",
"text":"Reminder sent to admin@parraplumbing.example for INV-1049."}]}}
Each round trip is an ordinary request and response, which is what lets it work across instances.
Why the prompt alone is not a security control
Our first version checked only acceptedContent(...)?.confirm === true. We then sent a single request whose inputResponses already said “accept”, without ever receiving the question. The server sent the reminder. That is consistent with the SDK docs, which describe inputResponses as untrusted client input, but it is easy to miss. Any client holding a valid token can pre-fill an approval.
The fix in the code above is to accept a “yes” only alongside a requestState that this server minted when it asked, bound to the same invoice and the same OAuth client. createRequestStateCodec signs that state with HMAC-SHA256 and enforces a TTL, and passing its verify to the McpServer options means tampered state never reaches your handler. We tested three cases in-process against the local mock:
| Attempt | Result |
|---|---|
Pre-filled “accept” with no requestState |
Server asks again (input_required) |
| Valid state for INV-1049 replayed on INV-1042 | Server asks again |
| State with an altered signature | Protocol error -32602 Invalid or expired requestState |
| Genuine second round | Reminder sent once and logged |
Even this only proves the server asked. It cannot prove a person answered. The client that renders the prompt can still auto-click “yes”. The spec’s tools page says there “SHOULD always be a human in the loop with the ability to deny tool invocations”, and it puts that duty on the host application. Match the control to the stakes:
| Stakes | Approval pattern |
|---|---|
| Low: reversible, internal | Form elicitation via input_required plus signed requestState (this guide) |
| Medium: customer-facing messages, record changes | As above, plus per-client scopes, idempotency keys and an audit log someone actually reviews |
| High: payments, deletions, clinical or legal actions | Out-of-band approval: inputRequired.elicitUrl to your own authenticated approval page, or an approval queue, recorded server-side before the tool will act |
Also remember that the spec says clients MUST treat tool annotations such as destructiveHint as untrusted unless they come from trusted servers. Annotations help a host decide what to show. They don’t enforce anything.
Step 3: Authentication and scopes in front of the handler

The serving guide is blunt: the handler “validates no Host header, no Origin header, and no token”. You verify the bearer token first and pass the result through as authInfo:
export const mcpHandler = createMcpHandler(buildServer, { responseMode: 'json' });
// --- Auth gate in front of the handler: the handler itself verifies nothing ---
const demoTokens: Record<string, Omit<AuthInfo, 'token'>> = {
// Replace with JWT validation or RFC 7662 introspection against your authorisation server.
'dev-read-only': { clientId: 'reporting-agent', scopes: ['invoices:read'], resource: RESOURCE },
'dev-ops': { clientId: 'ops-agent', scopes: ['invoices:read', 'reminders:send'], resource: RESOURCE },
};
const gate = requireBearerAuth({
expectedResource: RESOURCE, // reject tokens minted for some other API (no token passthrough)
requiredScopes: ['invoices:read'],
verifier: {
async verifyAccessToken(token) {
const info = demoTokens[token];
if (!info) throw new OAuthError(OAuthErrorCode.InvalidToken, 'Unknown token');
return { token, ...info, expiresAt: Math.floor(Date.now() / 1000) + 3600 };
},
},
});
// A web-standard fetch handler: export it directly on Workers, Deno or Bun.
export async function fetchHandler(request: Request): Promise<Response> {
const auth = await gate(request);
if (auth instanceof Response) return auth; // 401/403 with a WWW-Authenticate challenge
return mcpHandler.fetch(request, { authInfo: auth });
}
Points that matter in production:
expectedResourcemakes the gate reject tokens whose audience is some other API. That is the MCP authorisation spec’s rule against token passthrough: never accept, or forward, a token issued for a different service. If your tools call an upstream API, get a separate token for it.- The verifier must set
expiresAt. The SDK rejects tokens without an expiry. - Scope-based tool lists. Because the factory runs per request with
authInfo, a read-only token never even seessend_payment_reminderintools/list. - The demo token table is for local testing only. In production, validate JWTs or use RFC 7662 introspection against your authorisation server, and follow the 2026-07-28 authorization spec for discovery and client registration.
Step 4: Mount it on Node.js (or any fetch runtime)
On Cloudflare Workers, Deno or Bun, fetchHandler is already a web-standard (Request) => Promise<Response>. On Node.js, wrap it once with toNodeHandler and put the localhost guards in front. The Streamable HTTP spec says servers MUST validate the Origin header, and on a loopback bind the Host check is what stops DNS rebinding.
// src/main.ts: Node.js entry point
import { createServer } from 'node:http';
import { toNodeHandler, localhostHostValidation, localhostOriginValidation } from '@modelcontextprotocol/node';
import { fetchHandler, mcpHandler } from './server.js';
const nodeHandler = toNodeHandler({ fetch: fetchHandler });
const validateHost = localhostHostValidation(); // blocks DNS rebinding on a loopback bind
const validateOrigin = localhostOriginValidation();
const httpServer = createServer((req, res) => {
if (req.url !== '/mcp') { res.writeHead(404).end(); return; }
if (!validateHost(req, res) || !validateOrigin(req, res)) return;
void nodeHandler(req, res);
}).listen(3000, '127.0.0.1', () => console.log('MCP server on http://127.0.0.1:3000/mcp'));
process.on('SIGINT', async () => {
await mcpHandler.close();
httpServer.close(() => process.exit(0));
});
For a public deployment, use hostHeaderValidation and originValidation with your real hostnames instead of the localhost variants, and terminate TLS in front.
We set responseMode: 'json' because these tools emit no progress notifications. The SDK logs a reminder at start-up that JSON mode drops mid-call notifications. Leave the default if your tools stream progress.
Step 5: Test it with a 2026-07-28 client
This is the step most likely to trip you up. The v2 Client defaults to versionNegotiation: { mode: 'legacy' }, which runs the 2025 handshake. Our first test run connected in legacy mode, and the confirmation step failed with “per-request legacy serving cannot receive server-to-client requests”. Pin the revision, or use 'auto' to probe with server/discover and fall back:
// src/client.ts: a test client standing in for an MCP host
import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client';
async function connect(token: string, approve: boolean) {
const client = new Client(
{ name: 'test-host', version: '1.0.0' },
{
capabilities: { elicitation: { form: {} } },
versionNegotiation: { mode: { pin: '2026-07-28' } }, // default is 'legacy'
}
);
// A real host renders this as a confirmation dialog for the person.
client.setRequestHandler('elicitation/create', async request => {
console.log('[host] asks the person:', request.params.message);
return approve ? { action: 'accept', content: { confirm: true } } : { action: 'decline' };
});
const transport = new StreamableHTTPClientTransport(new URL('http://127.0.0.1:3000/mcp'), {
requestInit: { headers: { Authorization: `Bearer ${token}` } },
});
await client.connect(transport);
return client;
}
const ops = await connect('dev-ops', true);
console.log('tools:', (await ops.listTools()).tools.map(t => t.name));
const overdue = await ops.callTool({ name: 'find_overdue_invoices', arguments: { minDaysOverdue: 7 } });
console.log('overdue:', JSON.stringify(overdue.structuredContent));
console.log('send:', JSON.stringify((await ops.callTool({ name: 'send_payment_reminder', arguments: { invoiceId: 'INV-1042' } })).content));
console.log('again:', JSON.stringify((await ops.callTool({ name: 'send_payment_reminder', arguments: { invoiceId: 'INV-1042' } })).content));
console.log('bad args:', JSON.stringify((await ops.callTool({ name: 'find_overdue_invoices', arguments: { minDaysOverdue: 999 } })).content));
await ops.close();
const declined = await connect('dev-ops', false);
console.log('declined:', JSON.stringify((await declined.callTool({ name: 'send_payment_reminder', arguments: { invoiceId: 'INV-1049' } })).content));
await declined.close();
const reader = await connect('dev-read-only', true);
console.log('read-only tools:', (await reader.listTools()).tools.map(t => t.name));
await reader.close();
const res = await fetch('http://127.0.0.1:3000/mcp', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' });
console.log('no token ->', res.status, res.headers.get('www-authenticate'));
Run it with npx tsx src/main.ts in one terminal and npx tsx src/client.ts in another. Our output on 12 November 2026:
tools: [ 'find_overdue_invoices', 'send_payment_reminder' ]
overdue: {"invoices":[{"id":"INV-1042","customer":"Harbour Physio","amountCents":184000,"daysOverdue":23},{"id":"INV-1049","customer":"Parramatta Plumbing","amountCents":52500,"daysOverdue":11}]}
[host] asks the person: Send a reminder to Harbour Physio (accounts@harbourphysio.example) for INV-1042, $1,840.00?
send: [{"type":"text","text":"Reminder sent to accounts@harbourphysio.example for INV-1042."}]
again: [{"type":"text","text":"A reminder for INV-1042 was already sent today."}]
bad args: [{"type":"text","text":"Input validation error: Invalid arguments for tool find_overdue_invoices: minDaysOverdue: Too big: expected number to be <=365"}]
[host] asks the person: Send a reminder to Parramatta Plumbing (admin@parraplumbing.example) for INV-1049, $525.00?
declined: [{"type":"text","text":"Reminder for INV-1049 not sent: the user chose decline."}]
read-only tools: [ 'find_overdue_invoices' ]
no token -> 401 Bearer error="invalid_token", error_description="Missing Authorization header", scope="invoices:read"
Two behaviours to note. The second reminder call returns early without bothering the person, because the idempotency check runs before the prompt. And the read-only token’s tool list omits the write tool entirely.
Supporting 2025-era clients
By default createMcpHandler also serves 2025-era clients statelessly from the same factory, and a “legacy shim” can turn input_required into real server-to-client requests on a session-based connection. On the per-request stateless path we used, legacy clients have no channel for those requests. In our test, the confirmation tool returned an error to a legacy client while read-only tools worked fine.
That leaves three options:
- Require 2026-07-28 clients for tools that need confirmation, and tell users which hosts qualify.
- Keep a session transport for legacy clients and route to it with
isLegacyRequest, as the v1-to-v2 upgrade guide describes. - Pass
{ legacy: 'reject' }to fail clearly instead of half-working.
We haven’t verified which commercial MCP hosts speak 2026-07-28 as of this writing. Check your host’s release notes before you remove legacy support.
Production checklist
| Area | What to do |
|---|---|
| State | Nothing survives between requests or rounds except requestState. Move idempotency records to your database with a unique constraint, because an in-memory Set breaks the moment you run two instances |
| Signing key | Load one requestState key (32+ bytes) from your secrets manager and share it across the fleet. Rotate it deliberately |
| Secrets in state | The codec signs but does not encrypt. Never put personal or secret data in requestState |
| Auth | Audience-bound tokens, required scopes, short expiry. No token passthrough to upstream APIs |
| Abuse | Rate-limit per clientId at the gateway. The Mcp-Method and Mcp-Name headers let you limit write tools more tightly without parsing bodies |
| Prompt injection | Treat tool arguments, and any text your tools return from emails or documents, as untrusted. Keep write tools narrow (one invoice, one action), not “send any email” |
| Observability | Log every side effect with client ID and arguments. The 2026-07-28 spec documents W3C traceparent/tracestate keys in _meta for OpenTelemetry propagation |
| Privacy | If a tool makes or substantially shapes decisions that significantly affect people, Australia’s new APP 1 transparency duty for automated decisions applies from 10 December 2026 (see our Privacy Act guide) |
| Upgrades | v1.x (@modelcontextprotocol/sdk) gets fixes for at least six months after 27 July 2026 but will not implement 2026-07-28. Plan the move now |
When a stateless MCP server is the wrong tool
- Your agent and tools live in one codebase. Plain function calling in your LLM SDK is simpler. MCP earns its keep when tools are shared across several hosts or teams.
- You need agent-to-agent delegation. MCP connects an agent to tools and data. The A2A protocol (v1.0, March 2026) covers agents delegating to other agents.
- The operation runs for minutes or hours. Look at the official Tasks extension instead of stretching a single
tools/call. - You can’t stand up proper OAuth yet. Run the server over stdio for local, single-user use. Don’t expose an unauthenticated HTTP endpoint.
The takeaway
The 2026-07-28 revision makes an MCP server look like any other stateless JSON API, so the usual engineering applies: a cheap per-request factory, shared resources at module scope, authentication and origin checks in front, idempotent side effects, and logs. The genuinely new piece is input_required. Write handlers that can safely run more than once, accept an approval only with a signed requestState from your own server, and send anything high-stakes to an approval step the client cannot fake.
If you’re planning agents that act in your business systems with people in charge, that is what our AI agents and automation team builds, along with the integrations and APIs underneath them.
Code tested on 12 November 2026 with Node.js 24.22.1, TypeScript 7.0.2, @modelcontextprotocol/server 2.4.0, @modelcontextprotocol/client 2.4.0 and @modelcontextprotocol/node 2.1.2, against a local server with mock data. MCP and its SDKs move quickly, so check the linked sources for changes since this date.