• AI APIs
  • Node.js
  • Backend
  • Analytics

MCP server in Node.js: build one with the v2 TypeScript SDK

Build an MCP server in Node.js with the v2 TypeScript SDK: expose read-only extension stats to Claude, validate inputs, secure it with tokens and test it.

Author
Extenify Team
Published
Reading time
14 min read
Close-up of black audio cables plugged into the front of a red audio interface on a wooden desk

An MCP server in Node.js is the most direct way to let Claude, and other assistants that speak the Model Context Protocol, read your product's data. Instead of exporting a CSV and pasting it into a chat, you expose a few well-defined tools, and the assistant calls them when a question needs live numbers: "How did my extension's weekly users change since version 2.4 shipped?"

The timing is good. Version 2 of the official TypeScript SDK is now its stable release line, implementing the 2026-07-28 MCP specification, and @modelcontextprotocol/server 2.4.0 was published on October 23, 2026. Most tutorials still show the v1 package. This guide builds a small, read-only server with v2 that exposes browser extension stats from MongoDB, protects it with per-user tokens, and connects it to Claude. Every code sample here ran against the published packages.

Key takeaways

  • v2 splits the SDK into @modelcontextprotocol/server, @modelcontextprotocol/client and adapters such as @modelcontextprotocol/node. It needs Node.js 20 or newer and Zod 4.
  • createMcpHandler() builds a fresh server per request, so the endpoint is stateless and scales horizontally. It serves both the new 2026-07-28 protocol and 2025-era clients from the same code.
  • Define each tool with a Zod schema. The SDK turns it into JSON Schema for the client and rejects bad arguments before your handler runs.
  • Mark data tools readOnlyHint: true, scope every query to the authenticated user, and validate the Host and Origin headers.
  • Keep results small: claude.ai and Claude Desktop accept about 150,000 characters per tool result and wait up to 240 seconds per call.

What changed in 2026: a stateless protocol and the v2 SDK

The 2026-07-28 revision is the biggest change to MCP since remote servers arrived. Its changelog lists, among others:

  • Protocol-level sessions and the Mcp-Session-Id header are removed from Streamable HTTP.
  • The initialize handshake is removed. Every request carries its protocol version and client capabilities in _meta, and servers must implement a new server/discover method.
  • A single subscriptions/listen stream replaces the HTTP GET endpoint and resource subscriptions.
  • Roots, Sampling and Logging are deprecated, the old HTTP+SSE transport is formally deprecated, and Dynamic Client Registration is deprecated in favor of Client ID Metadata Documents for OAuth.

The v2 SDK follows that design. According to the v1 to v2 migration guide, the single @modelcontextprotocol/sdk package is split into client, server and framework adapter packages, Zod 3 is no longer supported, and server.tool() is gone in favor of registerTool() with a config object. The same guide notes an important detail: upgrading the package does not by itself adopt the new revision. A server that still calls server.connect(new StdioServerTransport()) "serves 2025-era clients only", while createMcpHandler() or serveStdio() serve both.

That matters because clients move more slowly than specs. When we connected to our test server with the v2 client and logged its requests, it still opened with an initialize request for protocol version 2025-11-25, and the v1 client (1.32.2) did the same. Claude's connector docs list the 2025-03-26, 2025-06-18 and 2025-11-25 authorization specs. Build with the handler that speaks both, and you do not have to care which one a client picks.

Decide what the server exposes

An MCP server for a SaaS product should start small and read-only. For extension analytics, three tools answer most questions:

Tool Input Returns
list_tracked_extensions none The extensions this account tracks, with store IDs
get_extension_stats extensionId Latest users, rating, rating count and version, with 7-day and 28-day user change
get_daily_history extensionId, days (1 to 90) Daily rows, oldest first

A few rules keep tools useful to a model:

  • Return computed answers, not raw dumps. A model reasons better over "users changed +1.5% in 7 days" than over 90 rows it has to subtract itself. Keep the history tool for when the user asks for a chart or a trend.
  • Respect the client's limits. Claude's guide to building an MCP server gives a maximum tool result of about 150,000 characters and a 240 second timeout per tool call on claude.ai and Desktop, and 25,000 tokens by default in Claude Code. The 90-day cap on days keeps the history tool far below that.
  • Describe tools for the model, not for humans. "Call this first when the user names an extension" in a description saves a failed call with a guessed ID.
  • Keep registration order stable. The 2026-07-28 spec says servers should return tools/list in a deterministic order to help client caching and LLM prompt cache hit rates. Registering tools in code gives you that for free.

Step 1: Install the packages

npm install @modelcontextprotocol/server @modelcontextprotocol/node zod mongodb

We used @modelcontextprotocol/server 2.4.0, @modelcontextprotocol/node 2.1.2, Zod 4.6.7 and the MongoDB Node.js driver 7.7.0 on Node.js 22. The files below use the .mjs extension so Node.js treats them as ES modules.

Step 2: Write the data layer

Keep database access out of the MCP code. These four functions are all the server needs. The collections match a typical daily snapshot design, one document per extension per day; the MongoDB time series guide for extension stats covers how to store that history efficiently.

// stats.mjs: the data layer the MCP tools call.
import { createHash } from 'node:crypto';
import { MongoClient } from 'mongodb';

const db = new MongoClient(process.env.MONGODB_URI).db();
const sha256 = (value) => createHash('sha256').update(value).digest('hex');

export async function findUserByToken(token) {
  const row = await db.collection('api_tokens').findOne({ tokenHash: sha256(token), revokedAt: null });
  return row ? { id: row.userId } : null;
}

export async function trackedExtensions(userId) {
  const rows = await db.collection('tracked').find({ userId }).sort({ name: 1 }).toArray();
  return rows.map((row) => ({ id: row.extensionId, name: row.name, store: row.store }));
}

export async function isTracked(userId, extensionId) {
  return (await db.collection('tracked').countDocuments({ userId, extensionId }, { limit: 1 })) > 0;
}

// The last `days` daily snapshots, oldest first.
export async function dailyHistory(extensionId, days) {
  const rows = await db
    .collection('snapshots')
    .find({ extensionId }, { projection: { _id: 0, day: 1, users: 1, rating: 1, ratingCount: 1, version: 1 } })
    .sort({ day: -1 })
    .limit(days)
    .toArray();
  return rows.reverse();
}

Tokens are stored as SHA-256 hashes, so a database leak does not leak working credentials. We ran these functions against a throwaway MongoDB 7.0 instance with seeded data to confirm the queries and the ordering.

Step 3: Write the MCP server

Here is the complete server. It uses createMcpHandler() for stateless serving and toNodeHandler() to plug it into a plain node:http server:

// server.mjs: a read-only MCP server for your extension stats.
import { createServer } from 'node:http';
import { createMcpHandler, McpServer } from '@modelcontextprotocol/server';
import { hostHeaderValidation, originValidation, toNodeHandler } from '@modelcontextprotocol/node';
import * as z from 'zod/v4';
import { dailyHistory, findUserByToken, isTracked, trackedExtensions } from './stats.mjs';

const extensionId = z
  .string()
  .regex(/^[a-p]{32}$/, 'A Chrome extension ID is 32 letters from a to p')
  .describe('Chrome Web Store extension ID: the 32-letter string at the end of the listing URL');

const READ_ONLY = { readOnlyHint: true, openWorldHint: false };

// Return the same data as text for the model and as structuredContent for clients.
const result = (data) => ({
  content: [{ type: 'text', text: JSON.stringify(data) }],
  structuredContent: data,
});
const failure = (message) => ({ content: [{ type: 'text', text: message }], isError: true });

function percentChange(now, before) {
  if (!before) return null;
  return Math.round(((now - before) / before) * 1000) / 10;
}

function buildServer({ authInfo }) {
  const userId = authInfo?.extra?.userId;
  const server = new McpServer({ name: 'extension-stats', version: '1.0.0' });

  server.registerTool(
    'list_tracked_extensions',
    {
      title: 'List tracked extensions',
      description: 'List the browser extensions this account tracks, with their store IDs. Call this first when the user names an extension.',
      annotations: READ_ONLY,
      outputSchema: z.object({
        extensions: z.array(z.object({ id: z.string(), name: z.string(), store: z.string() })),
      }),
    },
    async () => result({ extensions: await trackedExtensions(userId) }),
  );

  server.registerTool(
    'get_extension_stats',
    {
      title: 'Get extension stats',
      description: 'Latest users, rating and version for one tracked extension, with the change over 7 and 28 days.',
      inputSchema: z.object({ extensionId }),
      annotations: READ_ONLY,
      outputSchema: z.object({
        day: z.string(),
        users: z.number(),
        usersChange7dPercent: z.number().nullable(),
        usersChange28dPercent: z.number().nullable(),
        rating: z.number(),
        ratingCount: z.number(),
        version: z.string(),
        versionSince: z.string(),
      }),
    },
    async ({ extensionId }) => {
      if (!(await isTracked(userId, extensionId))) return failure('This account does not track that extension. Call list_tracked_extensions to see valid IDs.');
      const days = await dailyHistory(extensionId, 29); // oldest first
      const latest = days.at(-1);
      if (!latest) return failure('No data yet. The first snapshot is taken within 24 hours of adding an extension.');
      const firstOfVersion = days.find((d) => d.version === latest.version);
      return result({
        day: latest.day,
        users: latest.users,
        usersChange7dPercent: percentChange(latest.users, days.at(-8)?.users),
        usersChange28dPercent: percentChange(latest.users, days.at(-29)?.users),
        rating: latest.rating,
        ratingCount: latest.ratingCount,
        version: latest.version,
        versionSince: firstOfVersion.day,
      });
    },
  );

  server.registerTool(
    'get_daily_history',
    {
      title: 'Get daily history',
      description: 'Daily users, rating, rating count and version for one tracked extension, oldest first.',
      inputSchema: z.object({ extensionId, days: z.number().int().min(1).max(90).default(30) }),
      annotations: READ_ONLY,
    },
    async ({ extensionId, days }) => {
      if (!(await isTracked(userId, extensionId))) return failure('This account does not track that extension.');
      return result({ history: await dailyHistory(extensionId, days) });
    },
  );

  return server;
}

const mcp = toNodeHandler(createMcpHandler(buildServer));
const hostnames = (process.env.MCP_HOSTNAMES ?? 'localhost,127.0.0.1').split(',');
const validateHost = hostHeaderValidation(hostnames);
const validateOrigin = originValidation(hostnames);

createServer(async (req, res) => {
  if (!validateHost(req, res) || !validateOrigin(req, res)) return;
  if (new URL(req.url ?? '/', 'http://localhost').pathname !== '/mcp') {
    res.writeHead(404).end();
    return;
  }
  const token = req.headers.authorization?.match(/^Bearer (\S+)$/)?.[1];
  const user = token ? await findUserByToken(token) : null;
  if (!user) {
    res.writeHead(401, { 'WWW-Authenticate': 'Bearer' }).end();
    return;
  }
  req.auth = { token, clientId: 'personal-access-token', scopes: ['stats:read'], extra: { userId: user.id } };
  await mcp(req, res);
}).listen(Number(process.env.PORT ?? 3100), process.env.HOST ?? '127.0.0.1');

What each part does:

  • createMcpHandler(buildServer) calls the factory once per HTTP request. The SDK's HTTP serving guide puts it plainly: "the handler holds nothing between requests." Keep the factory cheap and create database clients once at module scope, as stats.mjs does.
  • req.auth is how you pass the verified caller in. toNodeHandler() forwards it as authInfo to the factory. The SDK never verifies tokens itself, so verification must happen before this line.
  • The Zod schema becomes the JSON Schema the client sees. The extensionId input was advertised as {"type":"string","pattern":"^[a-p]{32}$","description":"Chrome Web Store extension ID: ..."}, so the model knows the format before it calls. The Chrome extension ID guide explains why that pattern is exact. Edge Add-ons IDs use the same format, but Firefox IDs do not, so if you track Firefox add-ons too, take a store argument and validate the ID per store.
  • outputSchema plus structuredContent gives clients typed data while content carries the same JSON as text for the model.
  • isError: true results tell the model what went wrong in words it can act on, instead of throwing a protocol error.

Step 4: Secure the server

An MCP server is an API that a model drives, so it needs every protection a normal API has, plus a few more.

Validate Host and Origin. The Streamable HTTP section of the 2026-07-28 spec says servers "MUST validate the Origin header on all incoming connections to prevent DNS rebinding attacks" and, when running locally, "SHOULD bind only to localhost." The @modelcontextprotocol/node guards do this: originValidation() lets requests without an Origin through, because non-browser clients do not send one, and rejects a present but unknown origin with 403. In our test, a request with Host: evil.example got 403, and so did one with Origin: https://evil.example. In production, bind to 127.0.0.1 behind your reverse proxy and set MCP_HOSTNAMES to your public hostname.

Scope every query to the caller. Each tool checks isTracked(userId, extensionId) before reading anything. Never let a tool accept a user ID as an argument; the model can be talked into passing someone else's.

Pick the right authentication for the client. A personal access token in an Authorization header is simple and works in Claude Code and in your own scripts. For claude.ai, Claude's authentication guide for connectors lists OAuth 2.0 (each user signs in), a static credential entered once by an organization Owner (in beta, for a limited set of organizations, and shared by everyone in that organization) or no authentication. If each user should see only their own data in claude.ai, you need OAuth. Claude starts sign-in only when your server answers with a 401 and a WWW-Authenticate header; the code above already returns 401 in the right place, and with OAuth that header also has to point to your protected resource metadata.

Treat tool output as untrusted input to the model. Numbers are safe. If you later add a tool that returns review text or extension descriptions, remember that anyone can write a review, and text like "ignore previous instructions" will reach the model verbatim. The guide to app review analysis with an LLM covers handling review text as untrusted input. Keep such tools read-only, return only the fields needed, and do not combine them with tools that can change data.

Log every call. Record user, tool, arguments and duration. The LLM observability with OpenTelemetry guide shows how to trace AI traffic in a Node.js backend, and the 2026-07-28 spec documents traceparent and tracestate keys in _meta for propagating trace context.

Step 5: Test it before connecting Claude

We tested the server end to end with both SDK clients. Here is the core of the check, using the v2 client:

import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client';

const client = new Client({ name: 'smoke-test', version: '1.0.0' });
await client.connect(
  new StreamableHTTPClientTransport(new URL('http://127.0.0.1:3100/mcp'), {
    requestInit: { headers: { Authorization: `Bearer ${process.env.STATS_TOKEN}` } },
  }),
);
console.log((await client.listTools()).tools.map((t) => t.name));
const stats = await client.callTool({ name: 'get_extension_stats', arguments: { extensionId: 'aaaabbbbccccddddeeeeffffgggghhhh' } });
console.log(stats.structuredContent);
await client.close();

Results from our run with seeded data:

  • A request without a token got 401 with WWW-Authenticate: Bearer.
  • Both the v2 client and the v1 client listed the three tools with their readOnlyHint annotations and received the same structuredContent, for example {"users":12000,"usersChange7dPercent":1.5,"usersChange28dPercent":6.2,"version":"2.4.0","versionSince":"2026-10-21", ...}.
  • An invalid ID never reached the handler. The SDK returned isError: true with the text "Input validation error: Invalid arguments for tool get_extension_stats: extensionId: A Chrome extension ID is 32 letters from a to p".
  • A valid ID that the account does not track returned the isError message from our ownership check.

For interactive testing, the MCP Inspector lets you connect to the URL, add the header, browse tools and call them by hand.

Connect the server to Claude

In Claude Code, add a remote server with a header, as shown in the Claude Code MCP docs:

claude mcp add --transport http extension-stats https://mcp.example.com/mcp \
  --header "Authorization: Bearer $STATS_TOKEN"

Then ask a question that needs the data, such as "Which of my extensions lost users this week, and did any of them ship a new version?" Claude calls list_tracked_extensions, then get_extension_stats for each one.

In claude.ai and Claude Desktop, a user or organization Owner adds the server as a custom connector by entering its URL, using one of the authentication types above. OpenAI documents a similar path for ChatGPT and Codex in its guide to building MCP servers, with OAuth for authenticated servers.

Run it locally over stdio instead

For a personal tool that only you use, stdio avoids hosting entirely. The v2 SDK's serveStdio() takes the same factory:

Move buildServer and its imports from server.mjs into tools.mjs and export it, then add a second entry point:

// stdio.mjs
import { serveStdio } from '@modelcontextprotocol/server/stdio';
import { buildServer } from './tools.mjs';

serveStdio(() => buildServer({ authInfo: { extra: { userId: process.env.STATS_USER_ID } } }));

Register it with claude mcp add --env STATS_USER_ID=... --env MONGODB_URI=... --transport stdio extension-stats -- node stdio.mjs. We ran this entry point with the v2 stdio client: it listed the same three tools and returned the same structuredContent as the HTTP server.

Production checklist for an MCP server in Node.js

  • Tools are read-only unless there is a strong reason, and annotated as such.
  • Every query is scoped to the authenticated caller; no tool accepts a user ID.
  • Tokens are hashed at rest, revocable, and never accepted in the URL.
  • Host and Origin are validated; the process binds to localhost behind a proxy.
  • Results stay well under the client's size limit; long lists are capped.
  • Tool descriptions say when to call the tool and what the arguments look like.
  • Calls are logged with user, tool, arguments and duration.
  • You use createMcpHandler() or serveStdio(), so both 2025-era and 2026-07-28 clients work.

The tools in this guide read from your own database. If you would rather not build the collection side, Extenify already records daily users, ratings, reviews and versions for your extensions and competitors across the Chrome Web Store, Edge Add-ons and Firefox Add-ons, which is the data an assistant needs to answer questions about Chrome extension analytics. Plans start with a 7-day free trial, no credit card required; see pricing.

Frequently asked questions

Should I use @modelcontextprotocol/sdk or @modelcontextprotocol/server?

For new servers, use the v2 packages: @modelcontextprotocol/server plus an adapter such as @modelcontextprotocol/node. The v1 package is still published (1.32.2 in October 2026), and the migration guide offers a codemod, npx @modelcontextprotocol/codemod@latest v1-to-v2 ., for existing projects.

Do I need sessions for a remote MCP server?

No. The 2026-07-28 spec removes protocol-level sessions, and createMcpHandler() serves each request with a fresh server. If a tool needs state across calls, the spec's answer is an explicit handle that your server creates and the client passes back as an ordinary tool argument.

Should I use Streamable HTTP or SSE?

Streamable HTTP. The HTTP+SSE transport has been deprecated since the 2025-03-26 revision and is formally marked Deprecated in 2026-07-28. v2 of the SDK removed SSEServerTransport and keeps only a frozen copy in a legacy package.

What is the difference between stdio and HTTP MCP servers?

A stdio server runs as a child process of the client on the user's machine, which suits personal tools and local files. An HTTP server runs on your infrastructure and serves many users, which suits a SaaS product. Both can share the same tool definitions.

How do I make an MCP server work in claude.ai for each of my users?

Use OAuth 2.0 so each user signs in with their own account. Claude's connector documentation describes the discovery metadata, client registration and redirect URL it expects. A static header credential is shared across an organization, so it cannot tell users apart.

How much data can an MCP tool return to Claude?

About 150,000 characters per result on claude.ai and Claude Desktop, and 25,000 tokens by default in Claude Code, configurable with MAX_MCP_OUTPUT_TOKENS. Summarize on the server and cap list sizes rather than relying on the limit.

Summary

Building an MCP server in Node.js is now a short job with the v2 TypeScript SDK. Define a handful of read-only tools with Zod schemas, serve them with createMcpHandler() so both current and 2026-07-28 clients work, and pass the verified caller to the factory so every query is scoped to one account. Validate Host and Origin, keep results small, and test with the SDK client before you connect Claude. Start with the questions your users already ask about their data, and expose just enough to answer those.

Image credits

Put every store in one dashboard tonight.

Sign in with Google, paste one store link, and your unified view is live in under a minute. Then 7 days of Pro free, no credit card required, and you never touch your app's code.

Start free trial