Research sample · Node.js 26

Secure TypeScript MCP server

Research question

How do I build a secure production TypeScript MCP server for AI coding agents in 2026? Audit this implementation for current MCP protocol and SDK compatibility, OAuth 2.1 resource-server security, token audience validation, tool authorization, prompt-injection boundaries, tenant isolation, rate limiting, Streamable HTTP and SSE behavior, cancellation, retries, graceful shutdown, and OpenTelemetry. Identify exact defects and modernization opportunities, then provide corrected examples and authoritative sources.

Topics covered: TypeScript MCP server security, OAuth 2.1, Streamable HTTP, graceful shutdown

Hat Trick research

Node.js 26 · 8,470 characters

Research complete

Question

How do I build a secure production TypeScript MCP server for AI coding agents in 2026? Audit this implementation for current MCP protocol and SDK compatibility, OAuth 2.1 resource-server security, token audience validation, tool authorization, prompt-injection boundaries, tenant isolation, rate limiting, Streamable HTTP and SSE behavior, cancellation, retries, graceful shutdown, and OpenTelemetry. Identify exact defects and modernization opportunities, then provide corrected examples and authoritative sources.

Submitted code · secure-mcp-server.tsTypeScript
import { createServer } from "node:http";
import { createHash, randomUUID, timingSafeEqual } from "node:crypto";

import { context, trace, SpanStatusCode } from "@opentelemetry/api";
import { createMcpHandler, McpServer } from "@modelcontextprotocol/server";
import { toNodeHandler } from "@modelcontextprotocol/node";
import { jwtVerify, createRemoteJWKSet, type JWTPayload } from "jose";
import * as z from "zod/v4";

type Claims = JWTPayload & {
  sub: string;
  workspace_id: string;
  scope?: string;
};

type CodeHit = {
  path: string;
  startLine: number;
  endLine: number;
  excerpt: string;
  score: number;
};

const config = {
  issuer: mustEnv("OAUTH_ISSUER"),
  audience: mustEnv("MCP_AUDIENCE"),
  jwksUrl: new URL(mustEnv("OAUTH_JWKS_URL")),
  port: Number(process.env.PORT ?? 8787),
};

const jwks = createRemoteJWKSet(config.jwksUrl);
const tracer = trace.getTracer("secure-code-search-mcp");
const limiter = new SlidingWindowLimiter(60_000, 30);
View all 279 lines
import { createServer } from "node:http";
import { createHash, randomUUID, timingSafeEqual } from "node:crypto";

import { context, trace, SpanStatusCode } from "@opentelemetry/api";
import { createMcpHandler, McpServer } from "@modelcontextprotocol/server";
import { toNodeHandler } from "@modelcontextprotocol/node";
import { jwtVerify, createRemoteJWKSet, type JWTPayload } from "jose";
import * as z from "zod/v4";

type Claims = JWTPayload & {
  sub: string;
  workspace_id: string;
  scope?: string;
};

type CodeHit = {
  path: string;
  startLine: number;
  endLine: number;
  excerpt: string;
  score: number;
};

const config = {
  issuer: mustEnv("OAUTH_ISSUER"),
  audience: mustEnv("MCP_AUDIENCE"),
  jwksUrl: new URL(mustEnv("OAUTH_JWKS_URL")),
  port: Number(process.env.PORT ?? 8787),
};

const jwks = createRemoteJWKSet(config.jwksUrl);
const tracer = trace.getTracer("secure-code-search-mcp");
const limiter = new SlidingWindowLimiter(60_000, 30);

function mustEnv(name: string): string {
  const value = process.env[name]?.trim();
  if (!value) throw new Error(`${name} is required`);
  return value;
}

function scopes(claims: Claims): Set<string> {
  return new Set((claims.scope ?? "").split(/\s+/).filter(Boolean));
}

async function authenticate(request: Request): Promise<Claims> {
  const header = request.headers.get("authorization") ?? "";
  const match = /^Bearer\s+(.+)$/i.exec(header);
  if (!match) throw new HttpError(401, "Bearer token required");

  const { payload } = await jwtVerify(match[1], jwks, {
    issuer: config.issuer,
    audience: config.audience,
    algorithms: ["RS256", "ES256"],
    clockTolerance: 5,
  });
  if (!payload.sub || typeof payload.workspace_id !== "string") {
    throw new HttpError(403, "Token lacks tenant identity");
  }
  return payload as Claims;
}

function requireScope(claims: Claims, required: string): void {
  if (!scopes(claims).has(required)) {
    throw new HttpError(403, `Missing scope: ${required}`);
  }
}

class HttpError extends Error {
  constructor(readonly status: number, message: string) {
    super(message);
  }
}

class SlidingWindowLimiter {
  private readonly requests = new Map<string, number[]>();

  constructor(
    private readonly windowMs: number,
    private readonly maximum: number,
  ) {}

  accept(key: string, now = Date.now()): boolean {
    const floor = now - this.windowMs;
    const recent = (this.requests.get(key) ?? []).filter((time) => time > floor);
    if (recent.length >= this.maximum) return false;
    recent.push(now);
    this.requests.set(key, recent);
    return true;
  }
}

class CodeIndex {
  async search(input: {
    workspaceId: string;
    query: string;
    limit: number;
    signal: AbortSignal;
  }): Promise<CodeHit[]> {
    input.signal.throwIfAborted();
    // Production implementation executes a tenant-scoped hybrid query.
    return [
      {
        path: "src/jobs/claim.ts",
        startLine: 41,
        endLine: 67,
        excerpt: "const job = await claimNextJob(workspaceId);",
        score: 0.91,
      },
    ].filter(() => input.workspaceId.length > 0).slice(0, input.limit);
  }
}

const codeIndex = new CodeIndex();

function stableRequestKey(claims: Claims, query: string): string {
  return createHash("sha256")
    .update(`${claims.workspace_id}\0${claims.sub}\0${query}`)
    .digest("hex");
}

function equalKey(left: string, right: string): boolean {
  const a = Buffer.from(left);
  const b = Buffer.from(right);
  return a.length === b.length && timingSafeEqual(a, b);
}

function buildMcpServer(claims: Claims): McpServer {
  const server = new McpServer({
    name: "secure-code-search",
    version: "2.0.0",
  });

  server.registerTool(
    "search_code",
    {
      title: "Search repository code",
      description:
        "Search code visible to the authenticated workspace. Never accepts a tenant ID from tool input.",
      inputSchema: z.object({
        query: z.string().trim().min(3).max(500),
        limit: z.number().int().min(1).max(20).default(8),
        requestKey: z.string().regex(/^[a-f0-9]{64}$/).optional(),
      }),
    },
    async ({ query, limit, requestKey }, extra) => {
      requireScope(claims, "code:read");
      const expectedKey = stableRequestKey(claims, query);
      if (requestKey && !equalKey(requestKey, expectedKey)) {
        throw new Error("Request key does not match authenticated input");
      }

      const rateKey = `${claims.workspace_id}:${claims.sub}`;
      if (!limiter.accept(rateKey)) throw new Error("Rate limit exceeded");

      return tracer.startActiveSpan("mcp.search_code", async (span) => {
        span.setAttributes({
          "enduser.id": claims.sub,
          "hattrick.workspace_id": claims.workspace_id,
          "mcp.tool.name": "search_code",
          "mcp.request_id": randomUUID(),
        });
        try {
          const deadline = AbortSignal.timeout(8_000);
          const signal = AbortSignal.any([deadline, extra.signal]);
          const hits = await codeIndex.search({
            workspaceId: claims.workspace_id,
            query,
            limit,
            signal,
          });
          span.setAttribute("mcp.result_count", hits.length);
          return {
            structuredContent: { hits },
            content: [{ type: "text", text: JSON.stringify({ hits }) }],
          };
        } catch (error) {
          span.recordException(error as Error);
          span.setStatus({ code: SpanStatusCode.ERROR });
          throw error;
        } finally {
          span.end();
        }
      });
    },
  );

  server.registerTool(
    "explain_access",
    {
      description: "Explain the caller's effective MCP permissions.",
      inputSchema: z.object({}),
    },
    async () => ({
      structuredContent: {
        subject: claims.sub,
        workspaceId: claims.workspace_id,
        scopes: [...scopes(claims)].sort(),
      },
      content: [{ type: "text", text: "Access is derived from the bearer token." }],
    }),
  );
  return server;
}

const mcp = createMcpHandler(({ authInfo }) => {
  if (!authInfo) throw new Error("Authenticated request context missing");
  return buildMcpServer(authInfo.extra?.claims as Claims);
}, {
  responseMode: "sse",
  legacy: "stateless",
});

async function route(request: Request): Promise<Response> {
  const requestId = request.headers.get("x-request-id") ?? randomUUID();
  if (new URL(request.url).pathname === "/healthz") {
    return Response.json({ ok: true, requestId });
  }
  if (new URL(request.url).pathname !== "/mcp") {
    return new Response("Not found", { status: 404 });
  }

  try {
    const claims = await authenticate(request);
    const headers = new Headers(request.headers);
    headers.set("x-auth-subject", claims.sub);
    const authenticated = new Request(request, { headers });
    return await context.with(trace.setSpan(context.active(), undefined), () =>
      mcp.fetch(authenticated, {
        authInfo: {
          token: "verified",
          clientId: claims.sub,
          scopes: [...scopes(claims)],
          extra: { claims },
        },
      })
    );
  } catch (error) {
    const status = error instanceof HttpError ? error.status : 500;
    const message = status === 500 ? "Internal server error" : error.message;
    return Response.json(
      { error: message, requestId },
      {
        status,
        headers: status === 401
          ? { "WWW-Authenticate": `Bearer resource_metadata="${config.issuer}/.well-known/oauth-protected-resource"` }
          : undefined,
      },
    );
  }
}

const nodeHandler = toNodeHandler(route);
const httpServer = createServer(nodeHandler);
httpServer.requestTimeout = 15_000;
httpServer.headersTimeout = 5_000;
httpServer.listen(config.port, "127.0.0.1");

async function shutdown(signal: string): Promise<void> {
  console.info(JSON.stringify({ event: "shutdown", signal }));
  httpServer.closeIdleConnections();
  await Promise.race([
    new Promise<void>((resolve, reject) =>
      httpServer.close((error) => error ? reject(error) : resolve())
    ),
    new Promise<never>((_, reject) =>
      setTimeout(() => reject(new Error("Shutdown deadline exceeded")), 10_000)
    ),
  ]);
  await mcp.close();
}

for (const signal of ["SIGINT", "SIGTERM"] as const) {
  process.once(signal, () => {
    shutdown(signal).then(() => process.exit(0)).catch((error) => {
      console.error(error);
      process.exit(1);
    });
  });
}

Result

TypeScript3 priority findings5 displayed sources

The adapter boundary fails before the server is secure

The server uses current MCP v2 concepts, but passes the wrong handler shape to the Node adapter. Its OAuth metadata target, Origin and Host validation, tool authorization, quotas, and shutdown ordering also need correction before production use.

Priority findings

03
  1. 01

    Pass the MCP handler to the Node adapter

    Fix now

    The adapter expects the object returned by createMcpHandler, including its fetch and close behavior. Passing the custom route function creates a type defect and can fail at runtime.

    secure-mcp-server.ts:252

    const nodeHandler = toNodeHandler(route);
    MCP-HTTP · MCP-SDK
  2. 02

    Advertise metadata for the protected resource

    Fix now

    The challenge constructs protected-resource metadata from the authorization-server issuer. The metadata URL must describe the MCP resource itself.

    secure-mcp-server.ts:245

    resource_metadata="${config.issuer}/.well-known/oauth-protected-resource"
    MCP-AUTH · RFC-9728
  3. 03

    Authorize explain_access explicitly

    Fix now

    The tool can return workspace identity and all scopes without a tool-specific scope check. Require a dedicated capability or remove the diagnostic tool.

    secure-mcp-server.ts:188

    "explain_access",
    MCP-AUTH

Parse verified claims at runtime

Verify the token cryptographically, then validate authorization claims instead of asserting a TypeScript type.

Modernized techniqueTypeScript
const ClaimsSchema = z.object({
  sub: z.string().min(1),
  workspace_id: z.string().min(1),
  exp: z.number().int().positive(),
  scope: z.string().optional(),
}).passthrough()

const { payload } = await jwtVerify(token, jwks, {
  issuer: config.issuer,
  audience: config.audience,
  algorithms: ["RS256", "ES256"],
})
const claims = ClaimsSchema.parse(payload)

Authoritative sources

What this TypeScript MCP server audit covers

This Node.js example combines a current MCP handler, JWT verification, OAuth protected-resource behavior, tool scopes, tenant context, rate limiting, Streamable HTTP, SSE, cancellation, and graceful shutdown. The surface is realistic enough to expose defects that a compile-only review would miss.

Hattrick checks the exact SDK mounting boundary, maps authorization behavior to the MCP specification and RFC 9728, and follows active operations through disconnect and process shutdown. Findings quote the submitted source and distinguish current APIs from unsafe composition around them.

Engineering questions answered

  • Does the Node adapter receive the handler shape returned by createMcpHandler?
  • Is OAuth protected-resource metadata advertised from the correct resource URL?
  • Do Origin checks, tool scopes, cancellation, and shutdown form a safe production boundary?

How Hattrick approaches the question

  1. 01

    Exact SDK verification

    Checks handler exports, transport options, and Node mounting against the current TypeScript SDK source and serving guides.

  2. 02

    Protocol security review

    Connects OAuth metadata, challenges, Origin validation, JWT claims, and per-tool authorization to primary specifications.

  3. 03

    Lifecycle analysis

    Traces cancellation, active SSE exchanges, admission control, HTTP connections, and telemetry through shutdown.

Practical takeaway

Correct primitives can still fail at their boundaries

A production MCP server needs more than valid JWTs and current SDK calls. The adapter, protected-resource identity, request guards, tool policy, quota state, and shutdown coordinator must agree on one enforceable boundary.