Skip to content
isokron

notes

Shipping a production MCP server on the 2026-07-28 spec: auth, rate limits, and observability

Joshua Bryanmcp · typescript · auth · observability

For developers who have a working MCP server, have read the stateless migration guides, and now need a second person to be able to call it safely.

The 2026-07-28 revision of the Model Context Protocol removed the initialize handshake and the Mcp-Session-Id header. Every request now carries its protocol version, client info and capabilities in params._meta, and any process may answer it. The migration guides cover that well and mostly stop once the server works for its author. They skip the three things that change most when sessions go away: who is calling, how much they may do, and how you find out what happened.

This article covers those in the order you will hit them. The code targets the v2 TypeScript SDK (@modelcontextprotocol/server 2.4.0, @modelcontextprotocol/node 2.1.2, zod 4) and type-checks under strict. A smoke test drives the listings through a real Node HTTP server and the v2 client, with a stub standing in for the introspection call; "verified" below means that test checked it.

When you don't need this#

If your server runs on one machine, is launched by one MCP client, and touches data that belongs to the person at that machine, stay on stdio. The authorization spec says stdio implementations "SHOULD NOT follow this specification, and instead retrieve credentials from the environment." The OS process model is already your access control, and OAuth on a local tool makes it harder to install without making it safer.

TypeScript
import { serveStdio } from '@modelcontextprotocol/server/stdio';
import { createNotesServer } from './server.js';

// One process per client; the same factory serves 2026-07-28 and 2025 clients.
serveStdio(createNotesServer, { onerror: (err) => process.stderr.write(`stdio: ${err.message}\n`) });

There is an honest middle ground. kronicle-lite, a local-first document store I maintain, has an HTTP mode with one bearer token, loopback binding and a constant-time compare. Its threat model states the limit: "Holding the token is holding the whole store." For one person reaching their own box over a tunnel, that is enough. This article starts where it stops being enough: when a second user shows up.

What 2026-07-28 changed for an HTTP server#

Three changes matter for production.

No sessions. The server mints no session id, answers GET and DELETE with 405, and must not let tools/list "vary per-connection." Whatever you hung off the session (identity, rate-limit bucket, trace) now comes from the token and the request, every time.

Request metadata in headers. Clients mirror the method and tool name into Mcp-Method and Mcp-Name, and the server "MUST reject requests where the values specified in the headers do not match" with 400 and -32020. The SDK does it (verified). The payoff: your gateway can route or rate-limit per tool without parsing JSON.

Origin checks. Servers "MUST validate the Origin header on all incoming connections to prevent DNS rebinding attacks," answer 403 to a bad one, and SHOULD bind to localhost when local.

Here is the HTTP entry point. The order of the checks is the design:

TypeScript
import {
  createMcpHandler, getOAuthProtectedResourceMetadataUrl, hostHeaderValidationResponse,
  oauthMetadataResponse, originValidationResponse, requireBearerAuth,
  type AuthMetadataOptions, type McpServerFactory, type OAuthTokenVerifier,
} from '@modelcontextprotocol/server';

export interface AppOptions {
  factory: McpServerFactory;
  verifier: OAuthTokenVerifier;
  resource: URL; // canonical server URI, e.g. https://mcp.example.com/mcp
  metadata: Omit<AuthMetadataOptions, 'resourceServerUrl'>;
  allowedHosts: string[];
  allowedOrigins: string[];
}

export function createApp(o: AppOptions) {
  const mcp = createMcpHandler(o.factory, { legacy: 'reject' }); // 2026-07-28 only; no sessions
  const metadata: AuthMetadataOptions = { ...o.metadata, resourceServerUrl: o.resource };
  const gate = requireBearerAuth({
    verifier: o.verifier,
    expectedResource: o.resource, // RFC 8707 audience check
    resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(o.resource), // advertised on 401/403
  });

  async function fetch(request: Request): Promise<Response> {
    const badHost = hostHeaderValidationResponse(request, o.allowedHosts); // DNS rebinding
    if (badHost) return badHost;
    const doc = oauthMetadataResponse(request, metadata); // /.well-known/* is public
    if (doc) return doc;
    if (new URL(request.url).pathname !== o.resource.pathname) return new Response(null, { status: 404 });
    const badOrigin = originValidationResponse(request, o.allowedOrigins); // 403 for a hostile page
    if (badOrigin) return badOrigin;
    const auth = await gate(request); // AuthInfo, or a ready 401/403 with WWW-Authenticate
    if (auth instanceof Response) return auth;
    return mcp.fetch(request, { authInfo: auth }); // surfaces as ctx.http.authInfo
  }

  return { fetch, close: () => mcp.close() };
}

On Node, wrap it once: createServer(toNodeHandler(app)).listen(3000, '127.0.0.1'), with toNodeHandler from @modelcontextprotocol/node and TLS at your proxy. The composition is framework-free, so it ports to fetch-native runtimes; the verifier's Buffer is the only Node-ism. With legacy: 'reject', a 2025 client's initialize gets 400 and -32022 naming the supported revision (verified); the default, 'stateless', serves those clients without sessions.

Auth: be a resource server, not a token issuer#

A protected MCP server "acts as an OAuth 2.1 resource server." You validate bearer tokens and tell clients where they come from; you do not mint them. Discovery is what lets a generic client connect with no custom configuration:

  1. A request without a token gets 401 and WWW-Authenticate: Bearer ... resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource/mcp". The gate above produces exactly that (verified).
  2. The client fetches that document (RFC 9728), which names your authorization server and scopes. "MCP servers MUST implement OAuth 2.0 Protected Resource Metadata." oauthMetadataResponse serves it.
  3. The client reads the authorization server's metadata and gets a client id. In 2026-07-28, Client ID Metadata Documents are the preferred mechanism and Dynamic Client Registration is deprecated, kept "for backwards compatibility." Check this when you choose an authorization server.
  4. The client runs the authorization code flow with PKCE and the RFC 8707 resource parameter, then sends the token on every request.

Your code is the verifier. Here is one against RFC 7662 introspection:

TypeScript
import { OAuthError, OAuthErrorCode, type OAuthTokenVerifier } from '@modelcontextprotocol/server';

const AUTH_SERVER = new URL('https://auth.example.com');
const INTROSPECT_BASIC = Buffer.from(`${process.env['INTROSPECT_ID']}:${process.env['INTROSPECT_SECRET']}`).toString('base64');

// RFC 7662 introspection. Your job: validate what your authorization server issued.
export const verifier: OAuthTokenVerifier = {
  async verifyAccessToken(token) {
    const r = await fetch(new URL('/oauth/introspect', AUTH_SERVER), {
      method: 'POST',
      headers: { Authorization: `Basic ${INTROSPECT_BASIC}`, 'Content-Type': 'application/x-www-form-urlencoded' },
      body: new URLSearchParams({ token }),
      signal: AbortSignal.timeout(2_000),
    });
    if (!r.ok) throw new OAuthError(OAuthErrorCode.ServerError, `introspection ${r.status}`);
    const t = (await r.json()) as { active: boolean; client_id?: string; sub?: string; scope?: string; exp?: number; aud?: string | string[] };
    if (!t.active || !t.client_id || !t.exp) throw new OAuthError(OAuthErrorCode.InvalidToken, 'inactive token');
    const aud = Array.isArray(t.aud) ? t.aud[0] : t.aud;
    return {
      token,
      clientId: t.client_id,
      scopes: t.scope?.split(' ') ?? [],
      expiresAt: t.exp, // the SDK refuses tokens without an expiry
      ...(aud ? { resource: new URL(aud) } : {}), // compared against expectedResource
      extra: { sub: t.sub },
    };
  },
};

Two fields carry the security. resource is what expectedResource compares against: the spec says servers "MUST validate that access tokens were issued specifically for them as the intended audience," and a token minted for another server gets 401 with "Token was not issued for this resource" (verified). expiresAt is not optional either: a token without one gets "Token has no expiration time" (verified). Token passthrough stays forbidden: servers "MUST NOT accept or transit any other tokens."

Scopes per tool. With scopeChallenge: requireScopes('notes:write') on a write tool, a read-only token gets 403 and WWW-Authenticate: Bearer error="insufficient_scope", scope="notes:write", resource_metadata=... before the handler runs (verified): what a client needs to start step-up authorization. Put every scope an operation needs into one challenge.

Why not your own token format. Every hand-rolled scheme I saw in escalations failed in the same three places: no audience, so a token for service A worked at service B; no expiry, so a leak lasted forever; and a comparison that was not constant-time. OAuth 2.1 solved these, and the SDK implements the resource-server half. You write verifyAccessToken against introspection or a JWKS, and that is the extent of your cryptography.

The client is not the user. AuthInfo.clientId identifies the application, and with Client ID Metadata Documents it is a URL every install of that app shares. Key per-user state on clientId alone and all of that app's users share one bucket. Carry the subject through (extra.sub above) and key on both. kronicle-lite's stdio actor_id "is a label the caller supplies... it is not authentication"; over HTTP the actor must come from the verified token, never a tool argument.

Tools: annotations, output schemas, and a catalog that gets cached#

registerTool takes an annotations object with four hints. The defaults assume the dangerous case:

HintDefaultMeaning
readOnlyHintfalseThe tool does not modify its environment.
destructiveHinttrueIf not read-only, the tool may perform destructive updates.
idempotentHintfalseRepeating the call with the same arguments has no additional effect.
openWorldHinttrueThe tool reaches outside a closed system.

Clients use them to decide whether to ask a human and whether a retry is safe. Clients "MUST consider tool annotations to be untrusted unless they come from trusted servers," so they never replace the scope check. Be precise, not reassuring: a kronicle-lite tombstone is reversible but changes every later search, so it is destructive; get_system_state is read-only but not idempotent, because each call issues a new cursor.

TypeScript
import { McpServer, requireScopes, type McpServerFactory } from '@modelcontextprotocol/server';
import { z } from 'zod';
import { limited } from './limits.js';
import { observed } from './observe.js';
import { notes, search } from './store.js';

const Hit = z.object({ id: z.string(), title: z.string(), snippet: z.string() });

export const createNotesServer: McpServerFactory = () => {
  const server = new McpServer(
    { name: 'notes', version: '1.0.0' },
    {
      // 2026-07-28 cacheable results: the catalog is the same for every caller,
      // note contents are not.
      cacheHints: {
        'tools/list': { ttlMs: 3_600_000, cacheScope: 'public' },
        'server/discover': { ttlMs: 3_600_000, cacheScope: 'public' },
        'resources/read': { ttlMs: 0, cacheScope: 'private' },
      },
    },
  );

  server.registerTool(
    'search_notes',
    {
      title: 'Search notes',
      description: 'Full-text search over live notes. Returns at most top_k hits.',
      inputSchema: z.object({ query: z.string().min(1).max(500), top_k: z.number().int().min(1).max(50).default(5) }),
      outputSchema: z.object({ hits: z.array(Hit), truncated: z.boolean() }),
      annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: false },
    },
    observed('search_notes', limited(async ({ query, top_k }) => {
      const all = search(query);
      const result = { hits: all.slice(0, top_k), truncated: all.length > top_k };
      return { content: [{ type: 'text', text: JSON.stringify(result) }], structuredContent: result };
    })),
  );

  server.registerTool(
    'retire_note',
    {
      title: 'Retire a note',
      description: 'Marks one note as retired with a reason. It stays in history; search stops returning it.',
      inputSchema: z.object({ id: z.string(), reason: z.string().min(3).max(500) }),
      annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
      scopeChallenge: requireScopes('notes:write'), // 403 insufficient_scope before the handler runs
    },
    observed('retire_note', limited(async ({ id, reason }) => {
      const note = notes.get(id);
      if (!note) return { isError: true, content: [{ type: 'text', text: JSON.stringify({ error: 'not_found', id }) }] };
      note.retired = reason;
      return { content: [{ type: 'text', text: JSON.stringify({ id, retired: true }) }] };
    })),
  );

  return server;
};

outputSchema plus structuredContent gives clients typed results, validated server-side; keep the JSON in a text block for text-only clients.

Look twice at the cache hints. tools/list now carries ttlMs and cacheScope, and servers SHOULD order tools deterministically because it "improves LLM prompt cache hit rates." Your catalog is bytes that clients cache and models pay for. public means a cache may serve it to a different token holder, so if you filter tools by scope, mark it private. And a reworded description or a schema-converter upgrade changes those bytes for every client; kronicle-lite pins its catalog fingerprint in CI for that reason.

Rate limits and bounded work#

The tools page is direct: servers "MUST... rate limit tool invocations." Agents retry, loop, and call one tool forty times with slightly different arguments. You need three different bounds.

Per-user buckets bound how often. Return a tool execution error (isError: true), not an exception, so the model can read the reason, with a retry_after_ms it can act on.

Timeouts bound how long. On Streamable HTTP, closing the response stream is the cancellation and arrives as ctx.mcpReq.signal; pass it into your database and upstream calls, and add your own ceiling.

Bounded output is the one people miss: a large result spends a context budget you do not control. kronicle-lite's get_system_state takes budget_tokens (default 3000, max 64,000), folds the delta into count-plus-sample rollups until it fits, reports what it rolled up, and returns a cursor for the rest. The agent decides whether the remainder is worth another call.

TypeScript
import type { CallToolResult, ServerContext } from '@modelcontextprotocol/server';

export type Handler<Args> = (args: Args, ctx: ServerContext) => Promise<CallToolResult>;

// Who is calling. Over HTTP: the user behind the token, not just the OAuth client.
// With Client ID Metadata Documents every user of one app shares a client_id URL.
export function actorOf(ctx: ServerContext): string {
  const auth = ctx.http?.authInfo;
  if (!auth) return process.env['MCP_ACTOR'] ?? 'local'; // stdio: a label, not a principal
  const sub = typeof auth.extra?.['sub'] === 'string' ? auth.extra['sub'] : 'unknown';
  return `${auth.clientId}|${sub}`;
}

class Bucket {
  private tokens: number;
  private last = Date.now();
  constructor(private readonly capacity: number, private readonly perSecond: number) {
    this.tokens = capacity;
  }
  take(): boolean {
    const now = Date.now();
    this.tokens = Math.min(this.capacity, this.tokens + ((now - this.last) / 1000) * this.perSecond);
    this.last = now;
    if (this.tokens < 1) return false;
    this.tokens -= 1;
    return true;
  }
}

const buckets = new Map<string, Bucket>();
const fail = (body: object): CallToolResult => ({ isError: true, content: [{ type: 'text', text: JSON.stringify(body) }] });

export function limited<Args>(
  cb: Handler<Args>,
  opts = { capacity: 30, perSecond: 1, timeoutMs: 20_000 },
): Handler<Args> {
  return async (args, ctx) => {
    const actor = actorOf(ctx);
    let bucket = buckets.get(actor);
    if (!bucket) buckets.set(actor, (bucket = new Bucket(opts.capacity, opts.perSecond)));
    if (!bucket.take()) return fail({ error: 'rate_limited', retry_after_ms: Math.ceil(1000 / opts.perSecond) });

    let timer: NodeJS.Timeout | undefined;
    const timeout = new Promise<CallToolResult>((resolve) => {
      timer = setTimeout(() => resolve(fail({ error: 'timeout', after_ms: opts.timeoutMs })), opts.timeoutMs);
    });
    try {
      return await Promise.race([cb(args, ctx), timeout]);
    } finally {
      clearTimeout(timer);
    }
  };
}

In the smoke test, one user's 31st call in a burst returns {"error":"rate_limited","retry_after_ms":1000} while another user of the same client id is unaffected. In-memory buckets are correct for one process only, and stateless requests land on any replica, so behind a load balancer move them to Redis or the gateway. A gateway limiting on Mcp-Name SHOULD reject requests from protocol versions that predate header validation rather than trust the header.

Observability: what you will wish you had logged#

Ten years of support engineering taught me that the incident question is never "is it down." It is "what exactly happened to this user at 14:32." Design for that question.

stderr, never stdout, on stdio. The server "MUST NOT write anything to its stdout that is not a valid MCP message." One stray console.log and the client disconnects with a parse error.

Protocol logging is deprecated. 2026-07-28 deprecates the Logging feature ("log to stderr (stdio) or use OpenTelemetry"), and the SDK marks ctx.mcpReq.log deprecated. Do not build your incident trail on it.

Continue the agent's trace. The revision reserves unprefixed traceparent, tracestate and baggage in params._meta, and the OpenTelemetry MCP conventions (development status) say the server SHOULD parent its span on that context. The tool call then appears inside the agent's trace instead of as an orphan.

Pick a join key that survives statelessness. There is no session id, and the JSON-RPC id is per client: in the smoke test, three users' first calls all logged request_id: 0. Log trace id, actor and client on every line.

TypeScript
import { context, propagation, trace, SpanKind, SpanStatusCode, type TextMapGetter } from '@opentelemetry/api';
import type { ServerContext } from '@modelcontextprotocol/server';
import { actorOf, type Handler } from './limits.js';

const tracer = trace.getTracer('notes-mcp');
const log = (o: Record<string, unknown>) => process.stderr.write(JSON.stringify({ ts: new Date().toISOString(), ...o }) + '\n');

// 2026-07-28 reserves unprefixed traceparent / tracestate / baggage in params._meta.
const metaGetter: TextMapGetter<Record<string, unknown>> = {
  keys: (carrier) => Object.keys(carrier),
  get: (carrier, key) => (typeof carrier[key] === 'string' ? (carrier[key] as string) : undefined),
};

export function observed<Args>(tool: string, cb: Handler<Args>): Handler<Args> {
  return (args, ctx: ServerContext) => {
    const parent = propagation.extract(context.active(), (ctx.mcpReq._meta ?? {}) as Record<string, unknown>, metaGetter);
    // The 2.4 typings flatten the envelope to {}; read it as a plain record.
    const env = (ctx.mcpReq.envelope ?? {}) as Record<string, unknown>;
    const clientInfo = env['io.modelcontextprotocol/clientInfo'] as { name?: string } | undefined;
    const attrs = {
      'mcp.method.name': 'tools/call',
      'gen_ai.tool.name': tool,
      'gen_ai.operation.name': 'execute_tool',
      'jsonrpc.request.id': String(ctx.mcpReq.id),
      'mcp.protocol.version': String(env['io.modelcontextprotocol/protocolVersion'] ?? 'unknown'),
    };
    return tracer.startActiveSpan(`tools/call ${tool}`, { kind: SpanKind.SERVER, attributes: attrs }, parent, async (span) => {
      const t0 = performance.now();
      const line = {
        tool,
        actor: actorOf(ctx),
        request_id: ctx.mcpReq.id,
        client: clientInfo?.name,
        trace_id: span.spanContext().traceId,
      };
      try {
        const result = await cb(args, ctx);
        if (result.isError) span.setStatus({ code: SpanStatusCode.ERROR });
        log({ ...line, ms: Math.round(performance.now() - t0), is_error: result.isError ?? false });
        return result;
      } catch (err) {
        span.recordException(err as Error);
        span.setStatus({ code: SpanStatusCode.ERROR });
        log({ ...line, ms: Math.round(performance.now() - t0), threw: String(err) });
        throw err;
      } finally {
        span.end();
      }
    });
  };
}

A call sent with traceparent: 00-4bf92f35...-00f067aa0ba902b7-01 produced a tools/call search_notes span with that trace id and parent (verified). Compose as observed('x', limited(handler)) so a rate-limited call is still logged and traced.

Alert on, in order: p95 latency per tool (the conventions define mcp.server.operation.duration; agents time out and retry, which compounds), error rate per tool (a jump in isError usually means a schema or upstream change, not an outage), and calls per actor (the early sign of a runaway loop).

Logs record what the server did, not what the agent believed, and whoever runs the box can edit them. kronicle-lite returns a signed receipt with every search hit, bound to the chain head, that the agent can later have checked: VERIFIED, SUPERSEDED, TOMBSTONED or UNVERIFIED. It turns "the agent said X" into "the agent was told X at seq 4,812, and here is whether X is still true."

Checklist#

  • One user, one machine? Stay on stdio (serveStdio).
  • No session state; Host and Origin validated; loopback unless public; TLS at the proxy.
  • Protected Resource Metadata served; 401 carries resource_metadata.
  • Tokens checked for audience, expiry and scopes; no passthrough; AS supports Client ID Metadata Documents.
  • Write tools carry a scopeChallenge.
  • Per-user state keyed on client id plus subject, never a tool argument.
  • Every tool annotated; outputSchema where results are structured.
  • Cache hints set; tools/list private if it varies by scope; catalog changes reviewed.
  • Rate limit returns isError with retry_after_ms; shared store behind a load balancer.
  • Timeout per call; ctx.mcpReq.signal passed downstream; large results budgeted and resumable.
  • stderr on stdio; traceparent extracted; logs carry trace id, actor, client.
  • A record you could hand to someone else, not just a log you can edit.

The listings, the toy store behind them and the smoke test that checks the behaviors marked "verified" are at github.com/rafe-walker/mcp-production-notes.

Sources#