If you followed any MCP tutorial written before the summer, you learned an API that is now the legacy one. On July 27, 2026 the TypeScript SDK shipped v2: the single @modelcontextprotocol/sdk package split into @modelcontextprotocol/server and @modelcontextprotocol/client, the registration calls changed, and the old package moved to a maintenance line that implements the 2025-11-25 spec and gets bug fixes only. Most tutorials, snippets, and AI-generated starter code still teach v1. This guide builds a server the v2 way — and ends with a real server you can keep using, not a weather demo.

What you will build is a notes server: four tools (add-note, search-notes, list-notes, delete-note), one resource (notes://all), and one prompt (weekly-review), all backed by a plain JSON file on your disk. Notes are the right shape for a first server — the state is visible, the tools are genuinely useful, and every failure mode shows up in the open: bad input, missing records, a client that sends the wrong type. By the end you will have driven the server three ways: from a scripted client over stdio, from the MCP Inspector's web UI, and from Claude Code, where the model picks your tools on its own. The same factory then serves over Streamable HTTP, so the server works for remote clients too.

Everything below was run, not read off a README: the install, the server, the client calls, the validation errors, the Inspector launch, and the HTTP mount all executed on this machine against the exact versions pinned in Step 1. Where a step depends on a screen rather than a command — the Inspector's clicks, Claude Code's approval prompt — the article says so, and quotes the SDK's own host guide for the exact command.

What you'll need#

  • Node.js 20 or later. This guide ran against Node v24.22.1 and npm 10.9.4. No other runtime, no build step, no API key, no account — the whole server runs on your machine.
  • About 30 minutes. The server itself is one file; the verification is where the time goes, and it is the part that makes the difference between a server that works and one you only believe works.
  • Pinned versions, verified November 7, 2026: @modelcontextprotocol/server 2.4.0, @modelcontextprotocol/client 2.4.0 (for the test client), zod 4.6.7, tsx 4.21.0, @modelcontextprotocol/inspector 2.14.0, @modelcontextprotocol/node 2.1.2 (for the HTTP step only). If you install latest and get a newer minor version, the API below should still hold — v2 is the stable line — but re-check the docs if a call is not where this guide left it.
  • Optional, for Step 7: Claude Code installed and authenticated, or VS Code 1.99+ with the GitHub Copilot extension, or Cursor. Everything before Step 7 works without any host.

The moving parts, before any code:

PieceWhat it isWhat it does here
HostThe app with the model in it — Claude Code, VS Code, the InspectorLaunches your server, lists its tools, sends the model's tool calls
ServerYour program, src/index.tsRegisters tools, a resource, and a prompt; validates every call; reads and writes notes.json
Transportstdio first, Streamable HTTP in Step 8Carries JSON-RPC messages between host and server
Statenotes.json in the project rootPlain JSON array — visible, deletable, committable, no database to run

One rule governs the whole build: stdout belongs to the protocol. An stdio server speaks JSON-RPC on stdin and stdout, so a single console.log in your code corrupts the message stream and the host drops the connection. Log with console.error, which goes to stderr and never touches the protocol. You will see this rule twice more below — once when it saves you, once when it bites.

Step 1: Scaffold the project and install the v2 packages#

Create the project and install the server package, Zod, and tsx — the runner that executes TypeScript directly, so there is no build step:

mkdir notes-mcp && cd notes-mcp
npm init -y
npm pkg set type=module
npm install @modelcontextprotocol/[email protected] [email protected] [email protected]
mkdir src

Two details matter here. First, type=module: the SDK ships ES modules only, and without it Node will refuse the imports with an unhelpful error. Second, the package name: @modelcontextprotocol/server, not @modelcontextprotocol/sdk. The old name still installs — it is the v1 maintenance line, currently 1.32.2 — and if you copy it from an older tutorial you will get the old API with it: server.tool() instead of registerTool(), the manual transport wiring that only serves 2025-era clients, and no 2026-07-28 spec support. If a tutorial you are following imports from @modelcontextprotocol/sdk, it is teaching v1.

Confirm the install landed:

node -e "console.log(require('./package.json').dependencies)"

Expected output:

{ '@modelcontextprotocol/server': '^2.4.0', tsx: '^4.21.0', zod: '^4.6.7' }

Zod deserves a sentence, because v2 changed the deal. The server package depends on Zod 4 itself, and tool schemas use Standard Schema — you can bring Zod v4, Valibot, ArkType, or any compatible library. This guide uses Zod v4, imported as import * as z from 'zod/v4'. That /v4 path is load-bearing: plain zod still resolves to the v3-compatible entry in some setups, and the v4 import is what the SDK's own docs use. One schema is all you write per tool; the SDK derives the JSON Schema the model sees, validates arguments before your handler runs, and infers your handler's argument types from it.

Step 2: The server factory and your first tool#

The v2 shape is a factory: a function that builds and returns a fresh McpServer. The factory matters because the same function serves stdio in this step and HTTP in Step 8 — one server definition, two transports. Create src/notes-server.ts with the storage helpers and the first tool:

import { McpServer } from '@modelcontextprotocol/server';
import * as z from 'zod/v4';
import { readFile, writeFile, mkdir } from 'node:fs/promises';
import { randomUUID } from 'node:crypto';
import path from 'node:path';
import { fileURLToPath } from 'node:url';

const DATA_FILE = path.join(
  path.dirname(fileURLToPath(import.meta.url)),
  '..',
  'notes.json'
);

interface Note {
  id: string;
  title: string;
  body: string;
  tags: string[];
  createdAt: string;
}

async function loadNotes(): Promise<Note[]> {
  try {
    return JSON.parse(await readFile(DATA_FILE, 'utf8')) as Note[];
  } catch {
    return [];
  }
}

async function saveNotes(notes: Note[]): Promise<void> {
  await mkdir(path.dirname(DATA_FILE), { recursive: true });
  await writeFile(DATA_FILE, JSON.stringify(notes, null, 2), 'utf8');
}

function renderNote(n: Note): string {
  const tags = n.tags.length ? ` [${n.tags.join(', ')}]` : '';
  return `## ${n.title} (${n.id})${tags}\n${n.body}\n— saved ${n.createdAt}`;
}

function createServer(): McpServer {
  const server = new McpServer({ name: 'notes', version: '1.0.0' });

  server.registerTool(
    'add-note',
    {
      title: 'Add a note',
      description:
        'Save a short note with a title, body, and optional tags. Returns the note id.',
      inputSchema: z.object({
        title: z.string().min(1).describe('Short title for the note'),
        body: z.string().min(1).describe('The note content'),
        tags: z.array(z.string()).optional().describe('Optional tags, e.g. ["ideas", "mcp"]')
      }),
      outputSchema: z.object({ id: z.string(), title: z.string() })
    },
    async ({ title, body, tags = [] }) => {
      const notes = await loadNotes();
      const note: Note = {
        id: randomUUID(),
        title,
        body,
        tags,
        createdAt: new Date().toISOString()
      };
      notes.push(note);
      await saveNotes(notes);
      const output = { id: note.id, title: note.title };
      return {
        content: [{ type: 'text', text: `Saved note "${title}" with id ${note.id}.` }],
        structuredContent: output
      };
    }
  );

  return server;
}

export { createServer };

Three things to notice. First, registerTool takes a name, a config, and an async handler — and the config's description is not decoration. It is the text the model reads when deciding which tool answers a question, so write it as an instruction to a model, not a comment to a human: what the tool does, what it returns, when to use it. A vague description is how a model picks the wrong tool. Second, outputSchema with structuredContent is v2's typed output: the handler returns both a human-readable text block and a machine-readable object, and the SDK validates the object against the schema. A host that understands structured content can use the id directly instead of parsing it out of a sentence. Third, DATA_FILE resolves from the server file's own location, not the working directory — hosts launch servers from wherever they please, and a path relative to process.cwd() is the classic way to lose your data file.

Now the entry point, src/index.ts — four lines, and the whole stdio story:

import { serveStdio } from '@modelcontextprotocol/server/stdio';
import { createServer } from './notes-server.js';

void serveStdio(createServer);
console.error('notes MCP server running on stdio');

serveStdio owns the transport: it reads requests on stdin, writes responses to stdout, and calls your factory to build the instance that serves the connection. The banner goes to stderr, on purpose. Run it:

npx tsx src/index.ts

Expected output — one line on stderr, then silence:

notes MCP server running on stdio

Silence is correct. An stdio server waits on stdin for a client to start the conversation. Stop it with Ctrl+C. If you see anything else — a crash, a second line, an ESM error — fix it here, where you can read the error, before any host is involved. A server that does not print exactly this line and wait is a server no host can attach to.

Illustration of a laptop chat assistant connected through a small local server to a filing cabinet of notes
AI-generated illustration: the host, the server, and the notes file — the three pieces of this tutorial.

Step 3: The rest of the tools — search, list, delete#

Add the remaining three tools inside createServer, before return server. Search first — it is the tool you will actually call ten times a day:

  server.registerTool(
    'search-notes',
    {
      title: 'Search notes',
      description:
        'Search saved notes by keyword across titles, bodies, and tags. Returns matching notes.',
      inputSchema: z.object({
        query: z.string().min(1).describe('Keyword to search for')
      })
    },
    async ({ query }) => {
      const q = query.toLowerCase();
      const hits = (await loadNotes()).filter((n) =>
        [n.title, n.body, n.tags.join(' ')].join(' ').toLowerCase().includes(q)
      );
      if (hits.length === 0) {
        return { content: [{ type: 'text', text: `No notes match "${query}".` }] };
      }
      return {
        content: [{ type: 'text', text: hits.map(renderNote).join('\n\n') }]
      };
    }
  );

  server.registerTool(
    'list-notes',
    {
      title: 'List notes',
      description: 'List the most recently saved notes, newest first.',
      inputSchema: z.object({
        limit: z.number().int().min(1).max(50).default(10).describe('How many notes to return (1-50)')
      })
    },
    async ({ limit }) => {
      const notes = await loadNotes();
      const recent = [...notes].reverse().slice(0, limit);
      if (recent.length === 0) {
        return { content: [{ type: 'text', text: 'No notes saved yet. Use add-note first.' }] };
      }
      return {
        content: [{ type: 'text', text: recent.map(renderNote).join('\n\n') }]
      };
    }
  );

  server.registerTool(
    'delete-note',
    {
      title: 'Delete a note',
      description: 'Delete a saved note by its id.',
      inputSchema: z.object({
        id: z.string().describe('The id returned by add-note')
      })
    },
    async ({ id }) => {
      const notes = await loadNotes();
      const kept = notes.filter((n) => n.id !== id);
      if (kept.length === notes.length) {
        return {
          content: [{ type: 'text', text: `No note with id ${id}.` }],
          isError: true
        };
      }
      await saveNotes(kept);
      return { content: [{ type: 'text', text: `Deleted note ${id}.` }] };
    }
  );

list-notes shows two Zod behaviors worth knowing. .default(10) makes limit optional at the call site — a client can omit it entirely — while the handler always receives a number. And .min(1).max(50) is enforced by the SDK before your handler runs: call it with 99 and the model gets a readable rejection instead of your code deciding what to do with a nonsense value. You will see the exact rejection text in Step 5.

delete-note shows the error pattern: return isError: true with a plain-text explanation. Do not throw. A thrown exception becomes a protocol-level error the model cannot read and react to; an isError result is a normal result the model can see, understand, and recover from — ask for the id again, list the notes, pick the right one. Reserve exceptions for bugs. Missing records, bad ids, empty searches: those are results, not crashes.

Step 4: A resource and a prompt — the other two primitives#

Tools are the headliner, but MCP has two more primitives, and a server that only has tools is leaving the obvious uses on the table. Resources expose read-only data the client can surface without a model deciding to call anything — the host can read them, attach them to context, or show them to the user. Prompts are reusable templates that turn a server's data into a ready-made request. Add both inside createServer, after the tools:

  server.registerResource(
    'all-notes',
    'notes://all',
    {
      title: 'All notes',
      description: 'Every saved note, rendered as Markdown.',
      mimeType: 'text/markdown'
    },
    async (uri) => ({
      contents: [
        {
          uri: uri.href,
          text: (await loadNotes()).map(renderNote).join('\n\n') || 'No notes saved yet.'
        }
      ]
    })
  );

  server.registerPrompt(
    'weekly-review',
    {
      title: 'Weekly review',
      description: 'Ask the model to review your notes and pull out themes and open loops.',
      argsSchema: z.object({
        focus: z.string().optional().describe('Optional area to focus on, e.g. "project ideas"')
      })
    },
    async ({ focus }) => {
      const notes = await loadNotes();
      const focusLine = focus ? ` Focus on: ${focus}.` : '';
      return {
        messages: [
          {
            role: 'user',
            content: {
              type: 'text',
              text: `Review my notes below and pull out the main themes, anything that looks unfinished, and the three most useful next steps.${focusLine}\n\n${notes.map(renderNote).join('\n\n') || 'No notes saved yet.'}`
            }
          }
        ]
      };
    }
  );

The resource URI scheme is yours to choose — notes://all is a custom scheme, and MCP does not care what it is as long as it is a valid URI. The mimeType tells the client what it is getting; text/markdown is the honest label for rendered notes. The prompt's argsSchema works like a tool's inputSchema — same Zod, same validation — and the callback returns messages, not content. Note the deliberate choice in the prompt: the notes travel inside the message text. A prompt is a template; it assembles the request, and the model reads it like any user message. That is also the security boundary to respect: anything your prompt inlines becomes model-visible context, so never put secrets in a resource or a prompt template.

Your server is now complete: four tools, one resource, one prompt, one JSON file. Before any host touches it, prove it works — that is Step 5, and it is the step most tutorials skip.

Step 5: Prove it works with a real client#

A server you have only run and stared at is unverified. The rigorous check is a client that speaks the protocol: initialize, list, call, and deliberately call wrong. Install the client package and save this as verify-client.mjs in the project root:

npm install @modelcontextprotocol/[email protected]
import { Client } from '@modelcontextprotocol/client';
import { StdioClientTransport } from '@modelcontextprotocol/client/stdio';

const transport = new StdioClientTransport({
  command: 'npx',
  args: ['tsx', 'src/index.ts'],
  stderr: 'pipe'
});
transport.stderr?.on('data', (d) => process.stderr.write(`[server stderr] ${d}`));

const client = new Client({ name: 'verify-client', version: '1.0.0' }, { capabilities: {} });
await client.connect(transport);

const tools = await client.listTools();
console.log('TOOLS:', JSON.stringify(tools.tools.map((t) => t.name)));

const added = await client.callTool({
  name: 'add-note',
  arguments: { title: 'MCP v2 check', body: 'The TypeScript SDK split into server and client packages.', tags: ['mcp', 'verify'] }
});
console.log('ADD-NOTE:', JSON.stringify(added.content), 'structured:', JSON.stringify(added.structuredContent));
const id = added.structuredContent.id;

await client.callTool({
  name: 'add-note',
  arguments: { title: 'Second note', body: 'Search should find this by keyword zebra.' }
});

const listed = await client.callTool({ name: 'list-notes', arguments: {} });
console.log('LIST-NOTES:', listed.content[0].text.slice(0, 120));

const found = await client.callTool({ name: 'search-notes', arguments: { query: 'zebra' } });
console.log('SEARCH zebra:', found.content[0].text.slice(0, 80));

const badTitle = await client.callTool({ name: 'add-note', arguments: { title: '', body: 'x' } });
console.log('EMPTY-TITLE isError:', badTitle.isError, '| text:', JSON.stringify(badTitle.content));

const badLimit = await client.callTool({ name: 'list-notes', arguments: { limit: 99 } });
console.log('LIMIT-99 isError:', badLimit.isError, '| text:', JSON.stringify(badLimit.content));

const deleted = await client.callTool({ name: 'delete-note', arguments: { id } });
console.log('DELETE:', JSON.stringify(deleted.content[0].text), 'isError:', deleted.isError);

const resources = await client.listResources();
console.log('RESOURCES:', JSON.stringify(resources.resources.map((r) => r.uri)));
const res = await client.readResource({ uri: 'notes://all' });
console.log('READ notes://all:', res.contents[0].text.slice(0, 80));

const prompts = await client.listPrompts();
console.log('PROMPTS:', JSON.stringify(prompts.prompts.map((p) => p.name)));
const prompt = await client.getPrompt({ name: 'weekly-review', arguments: { focus: 'project ideas' } });
console.log('GET weekly-review:', prompt.messages[0].content.text.slice(0, 100));

await client.close();

Run it from a clean state — delete notes.json first so the outputs match — and this is what a working server produces. These are the real outputs from this guide's run (your UUIDs and timestamps will differ):

node verify-client.mjs
[server stderr] notes MCP server running on stdio
TOOLS: ["add-note","search-notes","list-notes","delete-note"]
ADD-NOTE: [{"type":"text","text":"Saved note \"MCP v2 check\" with id fcc71114-2d88-4fb9-bfce-d3da539bfde1."}] structured: {"id":"fcc71114-2d88-4fb9-bfce-d3da539bfde1","title":"MCP v2 check"}
LIST-NOTES: "## Second note (d4996867-9234-49b6-8b93-9802bfc04551)\nSearch should find this by keyword zebra.\n— saved 2026-11-07T23:22:37.990Z"
SEARCH zebra: "## Second note (d4996867-9234-49b6-8b93-9802bfc04551)\nSearch should find this by keyword zebra."
EMPTY-TITLE isError: true | text: [{"type":"text","text":"Input validation error: Invalid arguments for tool add-note: title: Too small: expected string to have >=1 characters"}]
LIMIT-99 isError: true | text: [{"type":"text","text":"Input validation error: Invalid arguments for tool list-notes: limit: Too big: expected number to be <=50"}]
DELETE: "Deleted note fcc71114-2d88-4fb9-bfce-d3da539bfde1." isError: undefined
RESOURCES: ["notes://all"]
READ notes://all: "## Second note (d4996867-9234-49b6-8b93-9802bfc04551)\nSearch should find this by keyword zebra."
PROMPTS: ["weekly-review"]
GET weekly-review: "Review my notes below and pull out the main themes, anything that looks unfinished, and the three most useful next steps. Focus on: project ideas."

Read the two error lines closely, because they are the whole point of schema validation. The SDK rejected the empty title and the out-of-range limit before your handler ran — no note was saved, no code of yours executed — and returned a message a model can act on: which field, which rule, which value. The client does not throw for these; it returns a result with isError: true, which is the correct MCP behavior — a validation failure is a result the model reads, not a crash. (A genuinely unknown tool is different: calling no-such-tool makes the client throw Tool no-such-tool not found.)

Two more things this run proves. First, the JSON Schema the model sees is derived from your Zod schema, not handwritten: listTools shows add-note's schema with minLength: 1 on title and body, required: ["title", "body"], and $schema: https://json-schema.org/draft/2020-12/schema. What you declare is what the model gets. Second, list-notes with no arguments returned the notes newest-first — the .default(10) fired, and the second note came first. Check notes.json on disk afterwards: it holds exactly the surviving note, formatted JSON, human-readable. If any line of this output differs on your machine, stop and fix the server — a host will only make the same failure harder to see.

Illustration of a developer inspecting a small server with a magnifying glass while green checkmarks float above it
AI-generated illustration: the verification step — a client that calls every tool, including the wrong calls, before any host is involved.

Step 6: Drive it by hand in the MCP Inspector#

The scripted client proves the protocol. The Inspector proves the server behaves for a human: it is a local web app that launches your server, connects over stdio, and lets you call tools from a form. No install step — npx fetches it:

npx @modelcontextprotocol/inspector npx tsx src/index.ts

It prints a URL with an auth token and opens a browser tab. On this machine it started as version 2.14.0 and served its UI at http://127.0.0.1:6274 — the exact token is yours, per run, and the startup log also warns that its secrets fall back to an unencrypted file when no OS keychain is available, which is expected on a headless machine and worth knowing on a shared one. In the tab, click Connect, open the Tools tab, select add-note, fill in a title and body, and run it. The text block in the result is the same content a model receives when it calls your tool — which is the point of the exercise: you are reading the tool's output the way the model will.

Try the failure cases here too, because the form makes them visible: call add-note with an empty title and watch the validation error come back as a result; call list-notes with limit 99 and watch the same Too big: expected number to be <=50 rejection. Then check the Resources tab — notes://all reads back every note as Markdown — and the Prompts tab, where weekly-review renders the assembled message with your notes inlined. If a tool misbehaves in the Inspector, the problem is in your server, not the host; fix it here, where the feedback loop is seconds.

Step 7: Wire it into Claude Code#

Now the payoff: the model calls your tools without you naming them. From the project root, register the server with Claude Code. The command below is from the SDK's own host guide — the server it attaches to is the one you just proved works, which is why the order matters:

claude mcp add notes -- npx tsx src/index.ts

Everything after -- is the launch command: the host spawns it as a child process and talks to it over stdin and stdout, exactly like your test client did. Verify inside a Claude Code session in that directory with /mcp: notes should show as connected, with add-note, search-notes, list-notes and delete-note listed under it. Then ask something the server — and only the server — can answer:

Save a note that the MCP v2 server tutorial is done, then search my notes for "zebra".

Claude Code stops to show the calls it wants to make and waits for your approval — approve, and the handler you wrote runs; the answer in the chat is written from the text block it returned. Nothing in the prompt named the tools. The model picked add-note and then search-notes from their names, their descriptions, and the JSON Schema the SDK derived from your inputSchema. That is the moment the description-writing advice from Step 2 stops being abstract: a vague description is a tool the model never picks, or picks for the wrong job.

To share the server with a team, register it at project scope instead — the command writes a .mcp.json at the repository root, which you commit:

claude mcp add --scope project notes -- npx tsx src/index.ts
{
  "mcpServers": {
    "notes": {
      "command": "npx",
      "args": ["tsx", "src/index.ts"]
    }
  }
}

Two scope notes. Project-scope servers ask each teammate for a one-time approval the first time their session loads them — a cloned repository cannot pre-approve its own servers — and a committed .mcp.json must never hold secrets: use ${VAR} references in command, args or env and keep the values out of git. The same launch command works in the other hosts, only the file changes: VS Code takes .vscode/mcp.json with a servers key and "type": "stdio", Cursor takes .cursor/mcp.json with the same mcpServers shape as above. If a host does not see your tools, run the launch command by hand first — npx tsx src/index.ts should print the one stderr line and wait. Anything else is the bug, and it has one of three causes: the process exits, something besides JSON-RPC reaches stdout, or the chat is not in agent mode.

Step 8: Serve the same factory over HTTP#

stdio is the right transport when one host launches one server on one machine. When many clients need one endpoint — a team server, a deployed service — serve the same factory over Streamable HTTP. The factory pattern from Step 2 pays off here: no server code changes, only a new entry point. Install the Node middleware package and create src/http.ts:

npm install @modelcontextprotocol/[email protected]
import { createMcpHandler } from '@modelcontextprotocol/server';
import {
  toNodeHandler,
  localhostHostValidation,
  localhostOriginValidation
} from '@modelcontextprotocol/node';
import { createServer as createHttpServer } from 'node:http';
import { createServer } from './notes-server.js';

const handler = createMcpHandler(createServer);
const nodeHandler = toNodeHandler(handler);
const validateHost = localhostHostValidation();
const validateOrigin = localhostOriginValidation();

const server = createHttpServer((req, res) => {
  if (!validateHost(req, res) || !validateOrigin(req, res)) return;
  void nodeHandler(req, res);
});

server.listen(3000, '127.0.0.1', () => {
  console.error('notes MCP server running on http://127.0.0.1:3000/mcp');
});

process.on('SIGINT', async () => {
  await handler.close();
  process.exit(0);
});

createMcpHandler takes your factory and returns a handler whose fetch is a web-standard (Request) => Promise<Response>. The factory runs once per HTTP request — a fresh instance serves every request, and no state lives on the instance between requests, which is what makes the endpoint stateless and horizontally scalable. The two validators in front of it are not optional decoration: the handler itself validates no Host header, no Origin header, and no token. On a localhost bind the Host check is what stops DNS rebinding — a malicious page resolving its own domain to 127.0.0.1 so the browser treats your local server as same-origin. The framework mounts (Express, Fastify, Hono) arm both checks by default; on plain node:http you compose them by hand, as above. Bind loopback explicitly — '127.0.0.1', never '0.0.0.0' — unless you have thought about who can reach the port.

Start it and drive it with a client over HTTP:

npx tsx src/http.ts
notes MCP server running on http://127.0.0.1:3000/mcp
import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client';

const transport = new StreamableHTTPClientTransport(new URL('http://127.0.0.1:3000/mcp'));
const client = new Client({ name: 'verify-http', version: '1.0.0' }, { capabilities: {} });
await client.connect(transport);
console.log('HTTP TOOLS:', JSON.stringify((await client.listTools()).tools.map((t) => t.name)));
const added = await client.callTool({ name: 'add-note', arguments: { title: 'HTTP note', body: 'Served over Streamable HTTP.' } });
console.log('HTTP ADD:', JSON.stringify(added.content), JSON.stringify(added.structuredContent));
await client.close();

Real output from this guide's run: the same four tools, the same structured result — HTTP ADD: [{"type":"text","text":"Saved note \"HTTP note\" with id c52c863d-de33-4b06-bcfe-73d6a6cfb378."}] {"id":"c52c863d-de33-4b06-bcfe-73d6a6cfb378","title":"HTTP note"} — over a plain HTTP POST to /mcp. (A GET to the endpoint answers 405: it is a POST-only endpoint, not a page.) A remote host registers this server with "type": "http" and the URL instead of a launch command — and that is where authentication enters the picture: the handler passes authInfo through to your factory, but it verifies no token itself, so a bearer check belongs in front of the handler before anything else. Do not expose an unauthenticated MCP endpoint to the internet; the tools it exposes run with whatever your handlers can do.

Which approach should you use?#

ApproachBest forCosts
stdio, v2 SDK (this guide)One host on your machine; local tools, files, and CLIs; the default for personal serversOne process per host; no remote access; the host owns the server's lifetime
Streamable HTTP, v2 SDKOne endpoint many clients share; a team server; a deployed serviceYou own auth, host/origin validation, and deployment; stateless per request, so shared state needs a store
v1 @modelcontextprotocol/sdkMaintaining a server you already shipped on v1Maintenance line: 2025-11-25 spec only, no 2026-07-28 support planned, bug fixes for at least six months after the v2 release (2026-07-27)
Python + FastMCPYour stack is Python; you want the fastest path to a server in that ecosystemA second SDK to learn; see this blog's FastMCP tutorial for the Python path
A ready-made serverThe tool already exists — filesystem, GitHub, Postgres, web searchNone — don't build what you can install; build when the capability is yours and no server exposes it

The decision that matters most is the last row. An MCP server is worth building when it exposes your system — your notes, your database, your internal API, your CLI — to a model that otherwise cannot reach it. It is not worth building as a wrapper around a public API that already has an official server, and it is not a substitute for a real API: if the underlying system has no stable interface, the server inherits that instability and hands it to the model. Build the server when the capability is yours, the interface is stable, and the model genuinely needs to call it rather than read about it.

Troubleshooting: the failures you will actually hit#

SymptomCauseFix
Server exits immediately when launchedImport error, wrong package name, or a crash in the factoryRun npx tsx src/index.ts by hand and read stderr — fix it there before any host is involved
Host connects, then drops the connectionSomething besides JSON-RPC reached stdoutFind the console.log and make it console.error; stdout belongs to the protocol
Cannot find module '@modelcontextprotocol/server'The v1 package name was installed insteadnpm install @modelcontextprotocol/server — the v1 package is @modelcontextprotocol/sdk
ERR_MODULE_NOT_FOUND or ESM complaintsMissing "type": "module"npm pkg set type=module; the SDK ships ES modules only
zod/v4 import failsZod too old — the server package depends on Zod ^4.2.0npm install zod@^4.6.7; import from 'zod/v4', not plain 'zod'
Host never lists your toolsConfig not reloaded, or the chat is not in agent modeRestart the host after changing config; check /mcp in Claude Code; confirm agent mode in Copilot Chat
Input validation error: ... on a tool callThe call really is invalid — that is the SDK workingFix the arguments, not the schema; the message names the field and the rule
Inspector warns about unencrypted secret storageNo OS keychain on a headless machineExpected; set MCP_INSPECTOR_SECRET_STORE or accept the file fallback on a machine only you use

Coming from v1: what changed#

If you have a v1 server, the migration is mechanical, and the SDK ships a migration guide and a codemod for it. The shape of the change:

  • Packages split. npm install @modelcontextprotocol/sdk zod becomes npm install @modelcontextprotocol/server zod for a server, plus @modelcontextprotocol/client for a client. The old package stays installable as the 1.x maintenance line.
  • Registration is explicit. The variadic server.tool(name, description, shape, handler) is gone; tools, resources, and prompts register with registerTool, registerResource, and registerPrompt, each taking a config object with a Zod schema (inputSchema, argsSchema) instead of a raw shape. The SDK derives the JSON Schema, validates, and infers types from that one schema.
  • Serving is a factory call. serveStdio(createServer) is the new entry point, and it serves both 2025-era and 2026-07-28 clients. The manual wiring still works in v2 — server.connect() is listed as an unchanged API, and StdioServerTransport still exists under @modelcontextprotocol/server/stdio — but a server connected that way serves 2025-era clients only. For HTTP, createMcpHandler(createServer) replaces the per-request StreamableHTTPServerTransport setup. The SDK ships a codemod for the mechanical parts: npx @modelcontextprotocol/codemod@latest v1-to-v2 .
  • Schemas are Standard Schema. Zod v4 is the documented path (import * as z from 'zod/v4'), but Valibot, ArkType, or any Standard Schema library works.
  • The spec moved. v1 implements the MCP spec up to 2025-11-25; v2 implements the 2026-07-28 spec, and support for the newer spec is not planned for v1. If your server uses sampling, elicitation, or tasks, read the migration guide before you move — those are the areas where the surface changed most.

None of this breaks a v1 server today. The maintenance line receives bug fixes and security updates for at least six months after the v2 release. But every new tutorial, example, and host integration is written against v2, and the gap only grows — build new servers on v2, and migrate old ones when you next touch them.

The takeaway#

You now have a server that is small enough to hold in your head and real enough to keep using: four tools, a resource, and a prompt, all verified against a real client before any host touched them. The habits that matter are the ones this guide forced: pin the versions, run the server by hand, drive it with a client that also calls it wrong, and read the validation errors as the feature they are. The SDK will keep moving — v2 is the stable line now, and the next spec revision will move something again — but the shape is stable: one factory, one schema per tool, stdout for the protocol and stderr for everything else. Build your next server the same way, with your own system behind it: the notes were the demo, the pattern is the point.

Sources#