How to Build an MCP Server: TypeScript Walkthrough
Key takeaways
- Define MCP tools once with the TypeScript SDK and Zod, then run the same handlers over stdio for local clients and streamable HTTP for remote ones.
- Test locally with MCP Inspector and never log to stdout over stdio, since it corrupts the protocol stream.
- Use stateless streamable HTTP mode when selling per-call tools, so 402 payment challenges and retries work without session affinity.
- Publish a static tool list and an llms.txt file, because directories read your repo rather than running your server.
- Keep tool input schemas limited to what the calling model can plausibly know, since schema mistakes surface as model errors.
To build an MCP server in TypeScript, define your tools with the MCP SDK, run it locally over stdio for Claude Code or Cursor, then expose the same tool handlers over streamable HTTP for remote clients, and publish static tool lists so directories can see what you sell. This walkthrough gives you runnable code at every stage, including the stdio-to-HTTP switch and the directory-visibility step that most guides skip.
What is an MCP server and what does it actually do?
An MCP server is a process that exposes tools, resources, and prompts over a standard protocol so any MCP client, like Claude Code or Cursor, can discover and call them. The client connects, sends a tools/list request, and gets back a static description of what your server can do before spending a single call.
You build one with the MCP TypeScript SDK, which gives you a McpServer class plus StdioServerTransport and StreamableHTTPServerTransport for local and remote connections. The same tool definitions work over either transport, which is why the walkthrough in this article defines a tool once and runs it both ways.
What do you need installed before building an MCP server?
You need four things: Node 20 or newer, TypeScript, the official MCP TypeScript SDK (@modelcontextprotocol/sdk), and a test client. For the client, Claude Code or MCP Inspector both work; Inspector is the fastest way to exercise tools/list and a single tool call without wiring up an agent. That is the whole stack for the stdio build in the next section.
You do not need an account with a facilitator or any wallet setup yet. For local development, plain Node and TypeScript are enough.
How do you define tools, resources, and prompts in an MCP server?
An MCP server exposes three primitives: tools (actions a client can invoke), resources (data a client can read), and prompts (reusable templates). Tools are where the work happens, so start there.
Registering a tool with a Zod schema
The TypeScript SDK's server.registerTool takes a name, a Zod schema for inputs, and a handler. Zod gives you runtime validation and generates the JSON Schema that clients read from tools/list, so the schema is your contract, not documentation.
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
const server = new McpServer({ name: "trust-check", version: "0.2.0" });
server.registerTool(
"check_provider",
{
title: "Provider trust check",
description: "Returns evidence signals for a data provider before an agent spends USDC on a call.",
inputSchema: { domain: z.string().url(), max_usdc: z.number().optional() },
},
async ({ domain, max_usdc }) => ({
content: [{ type: "text", text: JSON.stringify({ domain, verdict: "verified", max_usdc }) }],
})
);
The schema choice is the whole design decision. Every field you accept becomes a field the client's model must fill correctly, and every field you return becomes context the model reasons over mid-task. Keep inputs to what the model can plausibly know at call time, like a domain or a URL, and keep outputs flat and labeled so the model can quote them back. A tool whose inputs require knowledge the caller doesn't have will fail in ways that look like model errors but are really schema errors. The MCP specification on transports covers how these registrations travel over stdio and streamable HTTP; the registration code itself stays identical either way. The same pattern drives the paid per-call tools behind ScriptMasterLabs hosted MCP servers, where each tool wraps one x402 endpoint.
A concrete example makes the trade visible. Suppose you sell a provider-trust check to coding agents. If your tool requires an internal account ID, the calling model has no way to know it, so every call fails or the model hallucinates a value. If instead the tool accepts a plain domain and returns a verdict plus a confidence label, the model can supply the input from context and quote the output directly into its answer. The second design also prices cleanly: one input, one output, one 402 challenge per call.
When you review a schema before shipping, ask two questions. First, can the model actually produce each input field from what it already knows? Second, can it use each output field without a second call to interpret it? If either answer is no, reshape the tool rather than adding documentation and hoping the model reads it.
Resources and prompts in the same server
Resources expose read-only data with URIs, via server.registerResource, useful for publishing a static catalog or an llms.txt file clients can fetch without spending. Prompts, registered with server.registerPrompt, return reusable message templates for common workflows. For a paid-data server, register one tool per endpoint and skip prompts until a workflow repeats often enough to template.
How do you run and test an MCP server locally over stdio?
The stdio transport in 10 lines
Swap the HTTP wiring for stdio and your local test loop is done:
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
const transport = new StdioServerTransport();
await server.connect(transport);
Run it with npx tsx index.ts and the server speaks MCP over stdin/stdout. Nothing listens on a port, so you can test tools/list and tool calls from any client without a deploy step. This is the fastest way to confirm a tool definition actually works before you expose it remotely.
Adding it to Claude Code and Cursor
Both clients launch your server as a subprocess. In Claude Code, run claude mcp add my-server -- npx tsx /path/to/index.ts. In Cursor, add the same command to .cursor/mcp.json under mcpServers with a command and args array.
For a quicker loop, use MCP Inspector: npx @modelcontextprotocol/inspector npx tsx index.ts opens a UI where you can call tools/list and invoke each tool manually. Confirm the tool list is static and complete here before publishing, since directories read that list from your source rather than a running server.
How do you expose an MCP server over HTTP for remote clients?
Stdio only works when the client can spawn your process. Remote clients, pay-per-call APIs, and anything running on another machine need HTTP.
Streamable HTTP with Express
The streamable HTTP transport accepts POST requests at a single endpoint and returns either a JSON response or an SSE stream. Wrap your existing server handlers in Express:
import express from "express";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
const app = express();
app.use(express.json());
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined, // stateless mode
});
app.post("/mcp", async (req, res) => {
await transport.handleRequest(req, res, req.body);
});
app.listen(3000);
Wire the same server object you tested over stdio into this transport, and your tool definitions carry over unchanged. Point MCP Inspector at http://localhost:3000/mcp to verify before deploying.
Sessions and stateless mode
Pass a sessionIdGenerator to keep state across requests: the transport issues a session ID on initialize and expects it back on every call. For a machine-payable API sold per call, stateless mode is simpler. Each request stands alone, so a 402 challenge, payment, and retry work without session affinity.
What are common MCP server examples worth modeling after?
Data APIs are the most common pattern: a thin MCP wrapper over a paid HTTP endpoint. Tavily, Exa, Firecrawl, and Jina Reader each expose search, crawl, or extraction as a single tool with a small schema, so the agent does one call per task step.
ScriptMasterLabs sells the same shape: machine-payable MCP servers where each tool maps to one x402-priced call in USDC on Base, with provider-trust and decision-evidence checks as tools. The honest comparison:
| Server | Model | What to copy | Weak spot |
|---|---|---|---|
| Tavily | Search API wrapper | Narrow, single-purpose tool schemas | Generic results without trust context |
| Exa | Search API wrapper | Clean input/output contracts | Same |
| Firecrawl | Crawl API wrapper | Chunked returns for long pages | Same |
| Jina Reader | Extraction wrapper | Zero-config URL reads | Same |
| ScriptMasterLabs | Pay-per-call trust/data tools | Static tool lists, per-call pricing | Small catalog; new services need a release |
The trade ScriptMasterLabs accepts deliberately: shipping tools in a release instead of fetching them at runtime means a buyer's agent knows exactly what it installs before the first 402 challenge.
How do you make your tools visible to directories and registries?
Why runtime-built tool lists fail discovery
Directories rarely run your server. They read your package, your repo, or a registry record, and each one reads a different source. Our first MCP server built the tool list at runtime by fetching /offers and turning each into a tool. Directories that index published sources saw nothing to read, so the server was invisible until we shipped a static tool list. The trade-off: a new tool now needs a release instead of appearing automatically. That is the right trade, since a buyer's agent should know what it is installing before it connects.
Static tool lists and llms.txt
Ship the tool list as a static file in the repo, and keep it in sync with the code on every release. Add an llms.txt at the repo root describing each tool, its inputs, and its x402 price so agents and indexers can parse it without running anything. For client-side discovery, the same static metadata is what a client reads when you register the server in its config file, following the client's own MCP setup documentation.
How do you debug and validate an MCP server before shipping it?
MCP Inspector and common errors
Run npx @modelcontextprotocol/inspector against your server and you get a UI for tools/list, tools/call, and raw requests. Most failures are schema errors: a Zod field marked optional in code but required in the JSON Schema, or a return value that isn't a content array. Fix the schema, not the client.
Over stdio, never console.log to stdout. It corrupts the protocol stream and your client sees garbage. Log to stderr instead. Over HTTP, check that the 402 challenge response shape matches what your facilitator expects before blaming the client.
Pre-ship checklist
tools/listreturns a static tool list baked into the release, not one fetched at startup.- Every tool schema validates against your OpenAPI contract.
- Stdout is clean; logs go to stderr.
- Inspector passes
tools/callfor each tool with real arguments. - A fresh clone runs with one
npm install && npm run build.
FAQ
Can I build an MCP server in Python instead of TypeScript?
Yes. The official MCP project ships a Python SDK alongside the TypeScript one, and the protocol is language-agnostic, so a Python server connects to the same clients like Claude Code and Cursor. The trade-off is ecosystem fit: this walkthrough leans on Zod and tsx, which need Python equivalents like Pydantic and uv. Transports and tool registration concepts transfer directly.
How much does it cost to run an MCP server?
A stdio server costs nothing to run because the client spawns it as a subprocess on the user's own machine. An HTTP server needs hosting, which is a plain Node process on any small container or serverless platform. The variable cost is upstream: if your tools wrap paid endpoints, each call costs whatever the provider charges, which is why per-call pricing in USDC maps cleanly onto the model.
Do MCP servers need API keys or accounts to be called by agents?
Not at the protocol level. A client connects, lists tools, and calls them with no authentication step unless you add one. Authentication matters when the tool wraps a paid or gated upstream service: then you either hold credentials server-side and bill the caller, or use a payment layer like x402 so each request carries its own payment instead of a long-lived credential.
Can one MCP server expose both free and paid (x402) tools?
Yes. Tool registration is per-tool, so a single server can register a free resource reader or catalog tool next to paid tools that wrap x402-priced endpoints. The paid tools return a 402 challenge when called without payment, while the free ones respond normally. Keep the split visible in the static tool list so a buyer's agent knows which calls cost money before making them.
What's the difference between stdio and HTTP transport in practice?
Stdio means the client launches your server as a local subprocess and talks over stdin/stdout, so there is no port, no deploy, and no auth, but only clients on that machine can use it. Streamable HTTP exposes a POST endpoint that remote clients, hosted agents, and pay-per-call traffic can hit. The same tool definitions and handlers run over either transport unchanged; only the wiring differs.