TL;DR: To build an MCP server: scaffold a project, install the SDK, register a tool with a schema for its inputs, run it over stdio, then point Claude Desktop at it with a JSON config entry. Verified against the live spec (revision 2026-07-28) and current quickstart, fetched Sept 5, 2026, with one correction: roots is deprecated and was never a sandbox boundary.
What are you actually building, and what do you need first?
An MCP server is a small program that speaks a defined protocol, JSON-RPC 2.0 messages, to expose capabilities to an AI client. You'll need Node.js 20 or higher and npm for the TypeScript path used here; verify with node --version. The official quickstart also documents Python, Java, Kotlin, C#, and Ruby, so if TypeScript isn't your stack, the steps below translate directly, only the syntax changes. You won't need an API key or an external service of any kind. The example tool built here runs entirely locally, on purpose, so you can follow every step without paying for anything or depending on a third party being reachable.
That last choice is deliberate, not incidental. Most public MCP tutorials build a weather tool that calls a live government API, which is a fine demo but quietly mixes two separate skills together: learning the protocol, and wiring up an HTTP client with error handling for a service you don't control. Separating them means that if something fails while you're following along, you know for certain it's the MCP wiring and not a flaky third-party endpoint, a rate limit, or a network issue on your end.
What does an MCP server actually expose to a client?
Before writing any code, it's worth being precise about what you're building, because "MCP server" covers three genuinely different kinds of capability, and mixing them up is a common source of confusion for a first project. Per the specification's own summary, a server can offer clients any combination of three things: "Resources: Context and data, for the user or the AI model to use", "Prompts: Templated messages and workflows for users", and "Tools: Functions for the AI model to execute" (modelcontextprotocol.io/specification/2026-07-28/index, fetched Sept 5, 2026). Resources are contextual data a client can read, similar in spirit to a file; prompts are reusable templates a user can invoke, often surfaced as slash commands; tools are functions the model itself can call with arguments and get a result back. This tutorial builds a tools server because that's the shape most first projects take, and it's the one the official quickstart itself walks through first, before introducing resources or prompts.
Step 1: Scaffold the project
mkdir word-counter-mcp
cd word-counter-mcp
npm init -y
npm install @modelcontextprotocol/server zod
npm install -D @types/node typescript
mkdir src
touch src/index.ts
Add "type": "module" and a build script to package.json:
{
"type": "module",
"scripts": {
"build": "tsc"
}
}
And a minimal tsconfig.json:
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"outDir": "./build",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["src/**/*"]
}
This mirrors the current official TypeScript quickstart's project setup exactly, including the package names: @modelcontextprotocol/server for the SDK and zod for schema validation of tool inputs (modelcontextprotocol.io/quickstart/server, TypeScript tab, fetched and verified Sept 5, 2026).
Step 2: Write the server and register a tool
Open src/index.ts and set up the server instance:
import { McpServer } from "@modelcontextprotocol/server";
import { StdioServerTransport } from "@modelcontextprotocol/server/stdio";
import { z } from "zod";
const server = new McpServer({
name: "word-counter",
version: "1.0.0",
});
Now register one tool. This example counts words and characters in a block of text, deliberately simple and free of any network call or dependency beyond the SDK itself:
server.registerTool(
"count_words",
{
description: "Count words and characters in a block of text",
inputSchema: z.object({
text: z.string().describe("The text to analyze"),
}),
},
async ({ text }) => {
const words = text.trim().split(/\s+/).filter(Boolean).length;
const chars = text.length;
return {
content: [
{
type: "text",
text: `${words} words, ${chars} characters.`,
},
],
};
},
);
registerTool takes the tool's name, a description and a Zod schema the client and model both use to know what arguments it accepts, and a handler that returns its result as a content array. That shape, { content: [{ type: "text", text: "..." }] }, is what the current SDK expects a tool handler to return, verified directly against the live quickstart's own TypeScript example.
Two details in this small example are worth calling out because they're easy to get wrong on a first server, and both come straight from the SDK's own type contract rather than being a style preference. The .describe() call on the text field in the Zod schema isn't decorative: it's the description the model actually reads when deciding what to pass into the tool, so a vague or missing description is a common reason a model calls a tool with the wrong kind of input, or doesn't call it at all when it plausibly should. And the tool name itself, count_words, uses an underscore rather than a space or a hyphen; naming conventions can vary by SDK, but consistency with whatever your chosen SDK's own examples use is worth matching rather than improvising, since some clients build their slash-command names directly from it.
Finally, wire up stdio transport and run the server:
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
console.error("word-counter MCP server running on stdio");
}
main().catch((error) => {
console.error("Fatal error in main():", error);
process.exit(1);
});
Use console.error, never console.log, for anything you print from a stdio server. The spec's transport requirements mean stdio carries the actual JSON-RPC protocol messages on standard output; writing your own log line to stdout with console.log corrupts those messages and breaks the connection. console.error writes to stderr instead, which is safe. Build it with npm run build; this step matters enough that skipping it is one of the most common reasons a first server fails to connect.
Step 3: Connect it to Claude Desktop
Open (creating it if it doesn't exist) ~/Library/Application Support/Claude/claude_desktop_config.json on macOS, or the equivalent path on Linux or Windows documented in the quickstart, and add your server under the mcpServers key:
{
"mcpServers": {
"word-counter": {
"command": "node",
"args": ["/ABSOLUTE/PATH/TO/word-counter-mcp/build/index.js"]
}
}
}
Use the absolute path to your built index.js, not a relative one. Save the file and restart Claude Desktop completely. On restart, Claude Desktop launches your server as a subprocess using exactly the command and args you specified, and if it starts cleanly, the tool becomes available for the model to call. This config shape, an mcpServers object keyed by server name with command and args, is the exact structure the current quickstart documents for every language it covers, TypeScript included.
Confirm it worked by opening a new chat and asking something that would naturally call your tool, like "how many words are in this sentence: the quick brown fox." If the model calls count_words and reports back a number, the whole chain, your process launching correctly, the stdio transport carrying JSON-RPC messages both directions, and the tool's schema being readable by the client, is working end to end. If nothing happens, the most common causes are a relative path in the config, a build that didn't actually run, or a stray console.log call somewhere in your code corrupting the stdio stream; check those three before anything more exotic. Each one is a five-minute check, and together they account for the large majority of "my first server won't connect" reports.
Is roots how you sandbox a server to specific files? No, and this matters.
You may have read that MCP servers can be scoped to specific directories through a feature called roots. That's outdated, and worth correcting directly rather than repeating.
Under the spec's feature lifecycle policy, a deprecated feature stays in the specification for at least twelve months after the revision that deprecates it before it becomes eligible for removal, so roots still exists and some clients may still send it. Just don't build new server logic that trusts it as a security boundary, because the spec itself never claimed that role for it, even when it was current.
If you do need real access control, the honest answer is that it's your responsibility as the server author, the same way it would be for any other program you write that touches the filesystem or a network resource. Validate and constrain paths inside your own tool handlers, run the server with the narrowest OS-level permissions it actually needs, and never assume a client-supplied hint about "relevant directories" is a security guarantee. It was described as guidance for scoping a workspace picker UI, not as a sandbox, and treating it as one was always a misreading of what the feature was for, deprecated or not.
How do you go from a local stdio server to one other people can actually reach?
Everything above runs as a subprocess your own client launches on your own machine, which is exactly right for a first server and for anything meant to run locally. To make a server reachable over a network, the specification defines a second standard transport, Streamable HTTP, where "each message is an HTTP POST to a single MCP endpoint; replies arrive as a JSON object or a request-scoped SSE stream" (modelcontextprotocol.io/specification/2026-07-28/basic/transports, fetched Sept 5, 2026).
| stdio | Streamable HTTP | |
|---|---|---|
| Where it runs | Subprocess launched by the client on the same machine | A server reachable over the network |
| Typical use | Local desktop clients (Claude Desktop, local CLI tools) | Remote/hosted servers other people connect to |
| Auth | None needed; the client starts the process directly | OAuth 2.1 is a spec-level MUST for HTTP authorization |
That auth requirement isn't optional once you're on HTTP: the spec requires OAuth 2.1 for authorizing HTTP-based MCP connections, which is meaningfully more setup than a stdio server needs. Prompt Architects runs a Streamable HTTP MCP server in production at mcp.prompt-architects.com/mcp, using OAuth 2.1 for the browser-based sign-in flow it offers alongside personal access tokens, connectable today from six clients we document and test against directly: Claude Desktop, Claude.ai, Cursor, Claude Code, Codex, and Codex CLI (checked against this repo's own /integrations/mcp page, Sept 5, 2026). It's a working example of exactly the deployed shape this section describes, if you want to see the far end of this path before building it yourself.
For your first server, stdio and a local client is the right place to stop. Get the tool actually working and the client actually calling it before adding a network layer and an auth flow on top; OAuth 2.1 for HTTP transport is a real implementation task on its own, not a checkbox, and debugging authorization and protocol wiring at the same time on your very first server makes it much harder to tell which layer a problem actually belongs to. If your server builds cleanly but doesn't show up once you've configured it, our MCP troubleshooting guide covers the common causes, and our walkthrough of Claude Desktop's MCP setup goes deeper on the client side specifically than this tutorial's build-it-yourself focus does.
Stop rewriting prompts. Start shipping.
Works with ChatGPT, Claude, Gemini, Grok, Midjourney, Ideogram, Veo3 & Kling. 4.8★ on the Chrome Web Store.
Create An Account