TL;DR

This tutorial builds one local, read-only MCP Server with Node.js, TypeScript SDK v2, Zod, and stdio. The Server exposes text.stats, validates a bounded input, and returns both text and structured data. You will type-check it, call it with the MCP Inspector, and inspect the modern 2026-07-28 message contract without treating a local demo as a production service.

The important boundary is simple: the SDK handles MCP framing and schema conversion; your code still owns semantics, authorization, limits, errors, and side effects.

Know the Version Contract Before Installing

The current TypeScript SDK v2 is a different package line from the v1 examples that still dominate search results. Do not mix their imports or registration APIs.

Concern Reproducible profile in this tutorial Boundary
Runtime Node.js 22 SDK v2 supports Node.js 20+; Inspector 2.3.0 requires 22.19+
Server SDK @modelcontextprotocol/[email protected] Replaces the v1 aggregate package for Server code
Schema [email protected] via zod/v4 SDK v2 accepts Standard Schema libraries
Runner [email protected] Runs TypeScript directly for this local tutorial
Type check [email protected] tsc --noEmit checks the sample without producing build output
Protocol MCP 2026-07-28 Modern requests are self-describing and do not use initialize

These exact versions define a tested profile, not a permanent recommendation. Upgrade one dependency at a time, read its release notes, regenerate the lockfile, and rerun the same tests.

For the protocol architecture behind the code, read the MCP protocol guide. The MCP Server glossary defines what the Server owns and what remains application policy.

What You Will Build

The project deliberately exposes one deterministic capability:

text
local Host
    |
    | newline-delimited JSON-RPC over stdin/stdout
    v
MCP Server
    |
    v
text.stats(text) -> bounded structured counts

text.stats does not read files, call a network, execute commands, or mutate state. Its narrow contract makes transport mistakes, Schema mistakes, and result-shape mistakes visible before business risk is added.

The Tool returns:

  • UTF-16 code units, which match JavaScript String.length;
  • Unicode code points, which avoid counting a surrogate pair as two;
  • whitespace-delimited words, whose deliberately limited semantics are stated in the field name.

It does not claim to count user-perceived grapheme clusters or language-aware words.

1. Create a Pinned Project

Create a clean directory and install exact versions:

bash
mkdir mcp-text-stats
cd mcp-text-stats
npm init -y
npm pkg set type=module
npm pkg set scripts.start="tsx src/index.ts"
npm pkg set scripts.typecheck="tsc --noEmit"
npm install --save-exact @modelcontextprotocol/[email protected] [email protected]
npm install --save-dev --save-exact [email protected] [email protected] @types/[email protected]
mkdir src

Commit package-lock.json and use npm ci in CI. A tutorial that installs an unbounded latest dependency cannot prove that tomorrow's API still matches today's code.

Create tsconfig.json:

json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "strict": true,
    "noEmit": true,
    "skipLibCheck": true
  },
  "include": ["src/**/*.ts"]
}

SDK v2 publishes ECMAScript modules. "type": "module" and NodeNext keep TypeScript's resolver aligned with Node.js rather than mixing ESM source with CommonJS output.

2. Register One Bounded Tool

Create src/index.ts:

typescript
import { McpServer } from "@modelcontextprotocol/server";
import { serveStdio } from "@modelcontextprotocol/server/stdio";
import * as z from "zod/v4";

const textStatsSchema = z.object({
  text: z.string().max(20_000).describe("Text to measure"),
});

const textStatsResultSchema = z.object({
  utf16CodeUnits: z.number().int().nonnegative(),
  unicodeCodePoints: z.number().int().nonnegative(),
  whitespaceWords: z.number().int().nonnegative(),
});

function createServer(): McpServer {
  const server = new McpServer({
    name: "mcp-text-stats",
    version: "1.0.0",
  });

  server.registerTool(
    "text.stats",
    {
      title: "Text statistics",
      description:
        "Return bounded UTF-16, Unicode code-point, and whitespace-word counts.",
      inputSchema: textStatsSchema,
      outputSchema: textStatsResultSchema,
      annotations: {
        readOnlyHint: true,
        destructiveHint: false,
        idempotentHint: true,
        openWorldHint: false,
      },
    },
    async ({ text }) => {
      const trimmed = text.trim();
      const output = {
        utf16CodeUnits: text.length,
        unicodeCodePoints: [...text].length,
        whitespaceWords: trimmed === "" ? 0 : trimmed.split(/\s+/u).length,
      };

      return {
        content: [{ type: "text", text: JSON.stringify(output) }],
        structuredContent: output,
      };
    },
  );

  return server;
}

void serveStdio(createServer);
console.error("mcp-text-stats is ready on stdio");

The code uses four SDK v2 contracts:

  1. registerTool receives a Tool name, a configuration object, and an asynchronous Handler.
  2. inputSchema rejects overlong or non-string input before the Handler runs.
  3. outputSchema defines the structured result contract, and structuredContent provides the matching value.
  4. serveStdio owns the transport and creates the Server instance used for the connection.

The text block remains useful for Clients that primarily consume display content. structuredContent gives capable Clients typed data without parsing prose. Returning both is a compatibility pattern, not permission to omit output validation.

Tool Annotations are hints. A trusted Client may use them to shape UI or approval, but readOnlyHint: true does not prevent a dishonest or buggy Handler from writing data. The MCP Tool guide covers Schema, authorization, effects, and Tool Poisoning in detail.

3. Type-Check and Start the Server

Run the static check first:

bash
npm run typecheck

A successful command produces no TypeScript diagnostics. It proves that the pinned imports and types fit together; it does not prove protocol behavior.

Start the process:

bash
npm start

The readiness line appears on stderr:

text
mcp-text-stats is ready on stdio

The process then waits. That is expected. A stdio Server is normally launched and driven by a Host rather than used as an interactive terminal program.

Never replace console.error with console.log in this example. The 2026-07-28 stdio binding reserves stdout for one newline-delimited JSON-RPC message per line. One ordinary log line corrupts the protocol stream.

4. Verify the Server with MCP Inspector

Use the Inspector version from the tested profile:

bash
npx --yes @modelcontextprotocol/[email protected] npx tsx src/index.ts

In the Inspector:

  1. Select the modern protocol era and connect.
  2. Confirm server/discover reports 2026-07-28.
  3. Open Tools and verify that text.stats exposes both input and output Schema.
  4. Call it with {"text":"hello MCP world"}.
  5. Confirm structuredContent.whitespaceWords equals 3.
  6. Submit more than 20,000 JavaScript string units and confirm a Tool-level error.
  7. Confirm the Server remains available after the rejected input.

A successful Tool call has a final Result with:

json
{
  "resultType": "complete",
  "structuredContent": {
    "utf16CodeUnits": 15,
    "unicodeCodePoints": 15,
    "whitespaceWords": 3
  }
}

The SDK may also include the text content and Server metadata. Test the semantic fields you own instead of snapshotting an entire response whose optional metadata may evolve.

5. Understand the Modern Wire Contract

MCP 2026-07-28 has no negotiation handshake. Every modern request carries its protocol revision and relevant Client capabilities in _meta.

A simplified tools/call request looks like:

json
{
  "jsonrpc": "2.0",
  "id": 3,
  "method": "tools/call",
  "params": {
    "_meta": {
      "io.modelcontextprotocol/protocolVersion": "2026-07-28",
      "io.modelcontextprotocol/clientCapabilities": {},
      "io.modelcontextprotocol/clientInfo": {
        "name": "example-client",
        "version": "1.0.0"
      }
    },
    "name": "text.stats",
    "arguments": {
      "text": "hello MCP world"
    }
  }
}

Do not type raw messages into a production process. The example exists to make three facts inspectable:

  • request identity is the JSON-RPC id;
  • protocol version and capabilities arrive with the Request;
  • Tool name and Arguments remain ordinary tools/call parameters.

serveStdio can serve older Clients from the same factory by default. A Legacy Client may still open with initialize, but that compatibility behavior does not reintroduce a Protocol Session into modern requests. Keep the two eras explicit in tests and telemetry.

6. Distinguish Tool Errors from Protocol Errors

Tool failures and protocol failures have different meanings and recovery actions.

Failure Expected shape Client action
Input violates the Tool Schema final Tool Result with resultType: "complete" and isError: true correct Arguments; do not treat it as success
Tool reaches an expected domain failure Handler returns isError: true with bounded user-safe content change input or business state
Unsupported protocol revision JSON-RPC Error -32022 with supported revisions choose a mutual revision or stop
Malformed JSON-RPC JSON-RPC Error Response fix the Client implementation
Process exits or pipe closes transport failure; effect may be unknown for writes inspect evidence before retrying

resultType: "complete" means the request reached a final protocol Result. It does not mean the business operation succeeded; inspect isError.

The sample Tool has no side effect, so retrying the same input is harmless. A write Tool needs an idempotency key, an effect ledger, and an explicit unknown_effect outcome before automatic retry is safe.

7. Connect a Real Host Carefully

Many local Hosts accept a configuration with this general shape:

json
{
  "mcpServers": {
    "mcp-text-stats": {
      "command": "/absolute/path/to/node_modules/.bin/tsx",
      "args": ["/absolute/path/to/mcp-text-stats/src/index.ts"]
    }
  }
}

The location and field names are Host-specific. Use its official documentation, absolute paths, and a project directory the Host is allowed to access. A successful connection proves only that the process and protocol path work. It does not prove that a model selects the Tool correctly.

Test three layers separately:

  1. Handler: deterministic unit cases for empty, Unicode, whitespace, and maximum-length input.
  2. Protocol: discovery, list, valid call, invalid call, cancellation, and shutdown.
  3. Host workflow: Tool selection, displayed approval, result handling, and recovery after failure.

The MCP Client glossary explains why a Client is a protocol component rather than the user, model, or security Principal.

8. Respect the stdio Trust Boundary

stdio removes the network listener, not the security problem. The Host launches a local process that inherits some combination of arguments, environment, current directory, filesystem access, and operating-system identity.

Apply these controls before wrapping real data:

  • pass only required environment variables and never print them;
  • use allowlisted roots rather than accepting arbitrary paths;
  • reject symlink escapes and canonicalize paths before access;
  • set request, result, time, concurrency, and subprocess limits;
  • treat Tool descriptions, Arguments, Results, and linked Resources as untrusted;
  • authorize the exact object and action instead of trusting a Tool name;
  • stop work when a long-running Handler receives notifications/cancelled;
  • exit promptly when stdin reaches EOF;
  • log bounded identifiers to stderr, not raw Secrets or private Results.

Schema validation only proves shape. It does not prove identity, ownership, purpose, freshness, or permission.

9. Stop Before Turning It into HTTP

Do not replace stdio with a web framework and call the result production-ready. A remote MCP Server changes the trust boundary and needs a current Streamable HTTP implementation.

Before exposing a URL:

  • validate Origin, Content-Type, protocol headers, and Header/Body consistency;
  • bind only to intended interfaces and configure proxy buffering correctly;
  • implement OAuth Resource Server behavior when the endpoint is protected;
  • validate issuer, audience/resource, expiry, and least-privilege Scope;
  • authorize Tenant, Tool, object, Arguments, and side effects on every Request;
  • bound body size, Result size, queue time, execution time, and concurrent streams;
  • implement cancellation, idempotency, effect status, and audit-safe telemetry;
  • isolate Legacy Initialize, Session ID, GET/SSE, and resume behavior on an explicit compatibility route.

Use the MCP production guide for the full Request Admission and failure model. Use the enterprise OAuth guide for the protected remote flow.

Verification Matrix

A reproducible tutorial records more than “the Inspector connected.”

Test Evidence What it proves
npm ci lockfile resolves dependency graph is reproducible
npm run typecheck zero diagnostics imports, schemas, and Handler types agree
server/discover includes 2026-07-28 modern protocol support is visible
tools/list expected name and Schemas registration and discovery work
valid tools/call expected structured counts Handler and output contract work
over-limit call isError: true input boundary is enforced
unsupported version on a fresh process JSON-RPC -32022 revision mismatch is explicit
stdout capture JSON-RPC lines only diagnostics do not corrupt framing
EOF / cancellation work stops and process exits lifecycle behavior is bounded

Pin the command, runtime, dependency versions, operating system, and assertions with every recorded result. Re-run the matrix after any SDK, Schema, runtime, or transport change.

Conclusion

A useful first MCP Server is not the one with the most Tools. It is the smallest Server whose dependency versions, Schema, result semantics, protocol messages, and failure behavior you can explain and reproduce.

Start with one bounded read-only Tool. Keep stdout protocol-only, return typed data, inspect Tool and protocol errors separately, and test the exact Host path. Add Resources, Prompts, remote transport, credentials, and side effects only after each new trust boundary has an explicit contract.

Primary Sources