What You Will Build

This is a deliberately small local project:

text
stdio client
    |
    v
MCP Server
    |
    v
one read-only Tool: text.stats

The Tool accepts text and returns bounded counts. It does not read files, access a network, execute code, or mutate state. That boundary makes the first protocol test easier to reason about.

The tutorial covers the build-and-inspect loop. It does not claim that every client, SDK version, or platform uses the same configuration file.

Prerequisites

Check the installed runtime:

bash
node --version
npm --version

Use a Node.js release supported by the SDK version you select. Do not copy a version claim from this article into a production support policy; record the runtime and dependency versions in your own repository.

1. Create the Project

bash
mkdir mcp-text-stats
cd mcp-text-stats
npm init -y
npm install @modelcontextprotocol/sdk zod
npm install --save-dev typescript @types/node
mkdir src

These commands are a setup example, not a recommendation of unpinned “latest” versions. Resolve versions against the current SDK documentation, then commit the lockfile and record the tested runtime.

Pin the resolved SDK and Zod versions in package-lock.json. For a reproducible tutorial run, commit the lockfile and use npm ci.

Update package.json with ESM and build scripts:

json
{
  "type": "module",
  "scripts": {
    "build": "tsc",
    "start": "node dist/index.js"
  }
}

Create tsconfig.json:

json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "Node16",
    "moduleResolution": "Node16",
    "rootDir": "src",
    "outDir": "dist",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true
  },
  "include": ["src/**/*.ts"]
}

The exact SDK import path can change between major releases. If the compiler rejects an import, consult the pinned SDK release notes and update the example as a versioned change rather than silently mixing APIs.

2. Implement One Bounded Tool

Create src/index.ts:

typescript
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";

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

server.tool(
  "text.stats",
  "Return bounded character and word counts for caller-provided text. This tool does not read files or modify external state.",
  {
    text: z.string().max(20_000),
  },
  async ({ text }) => {
    const trimmed = text.trim();
    const words = trimmed === "" ? 0 : trimmed.split(/\s+/u).length;

    return {
      content: [
        {
          type: "text",
          text: JSON.stringify({
            characters: text.length,
            words,
          }),
        },
      ],
    };
  },
);

const transport = new StdioServerTransport();
await server.connect(transport);
console.error("mcp-text-stats is ready on stdio");

The example has four intentional properties:

  • Zod rejects inputs over the local result budget before the handler runs;
  • the operation is deterministic and read-only;
  • the description says what the Tool does not do;
  • diagnostics use console.error, because stdout belongs to the protocol stream.

Schema validation is not authorization. If a later Tool accepts an invoice ID, file path, tenant, or external destination, the server must derive identity from trusted context and authorize the exact object and side effect.

3. Build and Run a Static Check

Compile before connecting a client:

bash
npm run build

The expected artifact is dist/index.js. TypeScript compilation checks imports and local types; it does not prove protocol compatibility, authorization, or business correctness.

Run the process manually:

bash
npm start

It should remain alive waiting for stdio input. Do not type arbitrary text into the terminal and assume it is a valid MCP request. Stop it with Ctrl-C.

4. Inspect the Server

Use the MCP Inspector version documented for the SDK release:

bash
npx @modelcontextprotocol/inspector node dist/index.js

In the Inspector, verify:

  1. initialization and capability negotiation complete;
  2. text.stats is listed;
  3. a normal input returns JSON text;
  4. an input longer than 20,000 characters is rejected;
  5. no diagnostic line appears in the protocol response;
  6. the server stays alive after a failed request.

Save the Node.js, SDK, Inspector, and operating-system versions alongside the test result. “Inspector passed” without versions is weak reproduction evidence.

5. Connect a Client Carefully

Many desktop clients accept a configuration shaped like this, but the file path, key name, restart behavior, and supported command vary by client:

json
{
  "mcpServers": {
    "mcp-text-stats": {
      "command": "/absolute/path/to/node",
      "args": ["/absolute/path/to/mcp-text-stats/dist/index.js"]
    }
  }
}

Use absolute paths when the client launches a process. Do not put secrets in this file. After changing configuration, follow the client’s documented reload or restart procedure and inspect its logs.

The client may show a Tool in its UI, but that does not prove that a model will choose it correctly or that a future Tool is authorized. Test the actual client workflow with harmless input before adding side effects.

Protocol Primitives

This project exposes one Tool. MCP also defines Resources and Prompts, but they should be added only with a clear contract:

Primitive Use Boundary to define
Tool request work or a side effect identity, object, purpose, idempotency, timeout
Resource provide data or context access, freshness, size, sensitivity, deletion
Prompt reusable interaction template caller, untrusted content, approval, output handling

Do not expose an environment-variable Resource such as env://{name} without an allowlist. Arbitrary environment reads can disclose tokens and connection strings.

Common Failure Modes

The process exits immediately

Check that server.connect(transport) is awaited, the compiled entrypoint exists, and the Node command points to the same runtime used for the build.

The client shows no Tool

Run the compiled file directly, inspect stderr, confirm the Tool is registered before connect, and verify that the client configuration points to the correct file. A client restart may be required, but it is not universal.

The protocol stream is corrupted

Search the source and dependencies for writes to stdout. Use stderr for diagnostics and keep third-party logging configured accordingly.

A Tool returns an unexpected result

Test the handler outside the model first. Validate schema boundaries, result size, error classification, and the behavior of empty, Unicode, and adversarial inputs.

A handler throws

Do not catch every exception and report success. Classify expected validation, authorization, dependency, timeout, cancellation, and unexpected errors. Return an error that does not leak secrets or internal paths, and record bounded diagnostic evidence.

Before Remote or Stateful Deployment

The local stdio example intentionally omits remote concerns. Before using HTTP or sharing a Server:

  • choose and pin a current transport profile; do not assume legacy SSE;
  • authenticate the caller where the topology requires it;
  • validate issuer, audience/resource, scopes, expiry, and key rotation;
  • bind sessions to principal, tenant, and transport when sessions exist;
  • authorize the exact object and side effect on every call;
  • cap request and result bytes, execution time, concurrency, and cost;
  • implement cancellation, retry classification, and idempotency;
  • redact tokens, raw arguments, and sensitive results from telemetry;
  • test cross-tenant access, Tool Result Injection, reconnect, duplicate delivery, and rollback.

Authentication identifies a caller. It does not grant access to every object or Tool.

Production Readiness Checklist

  • [ ] Pin Node.js, SDK, Zod, and Inspector versions.
  • [ ] Keep the lockfile and record the build command.
  • [ ] Keep stdout protocol-only and stderr diagnostic.
  • [ ] Define narrow schemas and bounded results.
  • [ ] Treat schemas, descriptions, annotations, prompts, and results as untrusted data.
  • [ ] Derive identity, tenant, ownership, price, and role from trusted application state.
  • [ ] Add authorization, timeout, cancellation, idempotency, and quota before side effects.
  • [ ] Test the exact client and transport profile you will support.
  • [ ] Plan credential rotation, revocation, deletion, rollback, and incident response.

Conclusion

A useful first MCP Server is small enough to compile, inspect, and explain. Start with one bounded Tool, keep the stdio stream clean, pin the versions, and test the actual client path. Add Resources, Prompts, remote transport, authentication, and side effects only when each new boundary has an explicit contract and a corresponding test.

Primary Sources