Building Your First MCP Server: A Step-by-Step Tutorial
The first time I read the Model Context Protocol specification I closed the tab. It read like a transport-layer RFC, full of JSON-RPC framing details and capability negotiation handshakes, and I assumed building a server would take days. Then I actually sat down to build one and had a working tool exposed to Claude Desktop in about ninety minutes, most of which was me getting distracted reading about resources versus tools versus prompts. If you have been putting off building an MCP server because the docs feel heavy, this is the tutorial I wish someone had handed me.
What we are actually building
We are going to build a small MCP server that exposes one tool: it looks up the current status of a package on npm, including the latest version and when it was last published. It is intentionally simple because the goal here is to internalize the shape of an MCP server, not to build something impressive. Once the shape clicks, extending it to real internal tools, database queries, ticket systems, whatever your team needs, is mostly copy and adapt.
Setting up the project
Start with a plain Node project and the official SDK.
npm init -y
npm install @modelcontextprotocol/sdk zod
npm install -D typescript @types/node tsx
The SDK gives you the server primitives, and zod is what we will use to validate tool input, which the SDK expects natively. Create a tsconfig.json with a reasonably modern target, ES2022 is fine, module set to Node16 or NodeNext so the imports resolve correctly.
The server skeleton
Create src/index.ts and start with the imports and server instance.
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: 'npm-status-server',
version: '1.0.0',
});
The StdioServerTransport is the important detail here. Most MCP servers, especially the ones running locally as part of an editor or desktop app integration, communicate over standard input and output rather than HTTP. Claude Desktop, for instance, spawns your server as a child process and talks to it over stdio. There is also a streamable HTTP transport for remote servers, but stdio is the right starting point because it removes an entire category of networking and auth complexity while you are learning the shape of the protocol.
Defining the tool
This is the part that actually matters. A tool in MCP has a name, a description the model will read to decide when to call it, an input schema, and a handler function.
server.registerTool(
'get_npm_package_status',
{
title: 'Get npm package status',
description: 'Fetches the latest version and last publish date for an npm package by name',
inputSchema: {
packageName: z.string().describe('The exact npm package name, e.g. react or lodash'),
},
},
async ({ packageName }) => {
const res = await fetch('https://registry.npmjs.org/' + packageName);
if (!res.ok) {
return {
content: [{ type: 'text', text: 'Could not find a package named ' + packageName }],
isError: true,
};
}
const data = await res.json();
const latest = data['dist-tags'].latest;
const publishedAt = data.time[latest];
const summary = packageName + ' is at version ' + latest + ', published ' + publishedAt;
return {
content: [{ type: 'text', text: summary }],
};
}
);
A few things worth calling out here because they trip people up the first time. The description field is not documentation for humans, it is the primary signal the model uses to decide whether this tool is relevant to the current request. Write it the way you would explain the tool to a coworker who has never seen your codebase, specific and unambiguous, not marketing copy. The zod describe calls on each input field matter for the same reason, the model reads them when it is deciding what to pass in.
The return shape is also strict. Tools return a content array, and each item needs a type, almost always text for simple cases, though MCP also supports image and resource content types for richer responses. If something goes wrong, setting isError to true rather than throwing lets the model see the failure and potentially recover or explain it to the user, instead of the whole call just dying.
Wiring up the transport and starting the server
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
}
main().catch((err) => {
console.error('Fatal error starting MCP server:', err);
process.exit(1);
});
One mistake I made early on: do not use console.log anywhere in a stdio-based MCP server. Standard out is the transport channel. Any stray log line corrupts the JSON-RPC stream and the client will fail with a cryptic parse error. Use console.error for anything you want to see during development, since stderr is left alone.
Testing it locally before wiring up a client
Before pointing Claude Desktop at this, use the official MCP inspector, which gives you a small web UI to call your tools directly and see the raw request and response.
npx @modelcontextprotocol/inspector tsx src/index.ts
This opens a browser tab where you can pick your registered tool, fill in the packageName field, and see exactly what comes back. I would genuinely recommend never skipping this step. Debugging a broken tool through a chat interface, where you cannot see the raw JSON, is miserable compared to seeing it directly in the inspector.
Connecting it to Claude Desktop
Once the inspector confirms it works, add it to your Claude Desktop config, usually at ~/Library/Application Support/Claude/claude_desktop_config.json on macOS.
{
"mcpServers": {
"npm-status": {
"command": "npx",
"args": ["tsx", "/absolute/path/to/src/index.ts"]
}
}
}
Restart Claude Desktop fully, not just the window, and you should see your server listed under the tools icon in a new conversation. Ask it something like what version of lodash is currently published and watch it decide, on its own, to call your tool.
Where to go from here
Once the basic loop clicks, resources and prompts are the natural next things to explore. Resources let your server expose read-only context, like file contents or database rows, that a client can attach without necessarily calling a tool. Prompts let you define reusable, parameterized prompt templates that show up as slash-command-like shortcuts in a client. Neither is strictly necessary for most internal tooling use cases, tools alone cover the vast majority of what people actually build, but they are worth knowing exist once you outgrow the basics.
The honest summary is that the protocol spec is thorough because it is trying to standardize something that will run across many different clients and languages, not because any single server implementation needs to be complicated. Start with one tool, get it working end to end with the inspector, then a real client, and the rest of the surface area will make a lot more sense once you have that one concrete example anchoring it.
A few practical notes I picked up after this first server that are worth mentioning before you go build your own. Keep tool descriptions specific about failure modes, not just success cases, because a model deciding whether to call your tool benefits from knowing when it should not, not only when it should. Version your server the same way you would version any API, since clients may cache tool schemas and a silent breaking change to an input shape can produce confusing failures on the client side days after you shipped it. And resist the urge to expose too much in one server. A server with forty loosely related tools is harder for a model to reason about than five focused servers with eight tools each, because tool selection itself is a prediction the model has to get right, and a cluttered, overlapping tool surface makes that prediction meaningfully harder. I split what would have been one large internal server into four smaller, domain-specific ones after noticing the model occasionally called the wrong but similarly named tool, and that single change improved reliability more than any other tweak I made.
Written by AIDToolStack Editorial
The AIDToolStack editorial team reviews developer tools, AI products, and MCP servers with hands-on testing and honest assessments. We use the tools we review every day in production.
References & Further Reading
Related Posts
Sponsor Our Newsletter
Reach thousands of developers who are actively evaluating AI tools, MCP servers, and dev infrastructure. Our weekly newsletter goes to engaged technical decision-makers.
All sponsored content is clearly labeled per our editorial policy.