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:
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:
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:
{
"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:
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:
registerToolreceives a Tool name, a configuration object, and an asynchronous Handler.inputSchemarejects overlong or non-string input before the Handler runs.outputSchemadefines the structured result contract, andstructuredContentprovides the matching value.serveStdioowns 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:
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:
npm start
The readiness line appears on stderr:
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:
npx --yes @modelcontextprotocol/[email protected] npx tsx src/index.ts
In the Inspector:
- Select the modern protocol era and connect.
- Confirm
server/discoverreports2026-07-28. - Open Tools and verify that
text.statsexposes both input and output Schema. - Call it with
{"text":"hello MCP world"}. - Confirm
structuredContent.whitespaceWordsequals3. - Submit more than 20,000 JavaScript string units and confirm a Tool-level error.
- Confirm the Server remains available after the rejected input.
A successful Tool call has a final Result with:
{
"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:
{
"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/callparameters.
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:
{
"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:
- Handler: deterministic unit cases for empty, Unicode, whitespace, and maximum-length input.
- Protocol: discovery, list, valid call, invalid call, cancellation, and shutdown.
- 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.
Related Reading
- MCP Protocol Guide
- MCP Production Best Practices
- MCP Tool Design
- Enterprise OAuth for Remote MCP Servers
- MCP Server
- MCP Tool
- MCP Client
- MCP Host