We've written about [[mcp-explained-2026|what MCP is and why it matters]]. This post is the other half — actually building one. By the end you'll have a working MCP server in TypeScript that exposes a tool, a resource and a prompt, and it'll be wired into Claude Desktop, Cursor, and Antigravity. Total time: about an hour, including reading.
1. Mental model in 30 seconds
┌─────────────┐ JSON-RPC over stdio / SSE / WebSocket
│ MCP CLIENT │◄─────────────────────────────────────────►┌─────────────┐
│ (Claude, │ │ MCP SERVER │
│ Cursor, │ list_tools, call_tool, │ (yours) │
│ Antigrav…) │ list_resources, read_resource, │ │
│ │ list_prompts, get_prompt │ Tools │
│ │ │ Resources │
│ │ │ Prompts │
└─────────────┘ └─────────────┘
│ │
│ The CLIENT decides when to call. The SERVER │
│ exposes the menu. The LLM inside the client │
│ picks from the menu when it needs to. │
└──────────────────────────────────────────────────────────┘Three things a server can offer: tools (functions the LLM can call), resources (data the client can attach to context), and prompts (templated user-invokable instructions). You don't need all three. Start with tools — that's where 90% of the value lives.
2. Prerequisites
- Node.js 20+ installed (
node -vto check) - An MCP client to test against (Claude Desktop is the easiest)
- A text editor (VS Code, Cursor, whatever)
3. Scaffold the project
mkdir my-first-mcp && cd my-first-mcp
npm init -y
npm install @modelcontextprotocol/sdk zod
npm install -D typescript @types/node tsx
npx tsc --initOpen the generated tsconfig.json and confirm "target": "ES2022", "module": "NodeNext", "moduleResolution": "NodeNext". Set "outDir": "./dist".
Then add "type": "module" to your package.json. MCP servers in 2026 are ESM-first.
4. Write the server
Create src/index.ts. This minimal server exposes one tool that returns the current weather (mocked) and one resource (a static markdown doc).
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from
"@modelcontextprotocol/sdk/server/stdio.js";
import {
CallToolRequestSchema,
ListToolsRequestSchema,
ListResourcesRequestSchema,
ReadResourceRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";
import { z } from "zod";
const server = new Server(
{ name: "weather-demo", version: "0.1.0" },
{ capabilities: { tools: {}, resources: {} } }
);
// ── Tools ────────────────────────────────────────────────────────
const GetWeatherInput = z.object({
city: z.string().describe("City name, e.g. 'Bengaluru'"),
});
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [
{
name: "get_weather",
description: "Get current weather for a city (mocked).",
inputSchema: {
type: "object",
properties: {
city: { type: "string", description: "City name" },
},
required: ["city"],
},
},
],
}));
server.setRequestHandler(CallToolRequestSchema, async (req) => {
if (req.params.name !== "get_weather") {
throw new Error(`Unknown tool: ${req.params.name}`);
}
const { city } = GetWeatherInput.parse(req.params.arguments);
// Replace with a real API call when you're ready.
return {
content: [{
type: "text",
text: `Weather in ${city}: 27°C, partly cloudy. (mocked)`,
}],
};
});
// ── Resources ────────────────────────────────────────────────────
server.setRequestHandler(ListResourcesRequestSchema, async () => ({
resources: [
{
uri: "weather://docs/about",
name: "About this server",
mimeType: "text/markdown",
},
],
}));
server.setRequestHandler(ReadResourceRequestSchema, async (req) => {
if (req.params.uri === "weather://docs/about") {
return {
contents: [{
uri: req.params.uri,
mimeType: "text/markdown",
text: "# Weather Demo\nA tiny MCP server built as a teaching example.",
}],
};
}
throw new Error(`Unknown resource: ${req.params.uri}`);
});
// ── Start ────────────────────────────────────────────────────────
const transport = new StdioServerTransport();
await server.connect(transport);
console.error("weather-demo MCP server running on stdio");5. Build and smoke-test
# Quick run (no build, useful in dev)
npx tsx src/index.ts
# Production build
npx tsc
node dist/index.jsThe process should sit there silently (stdout is reserved for MCP). If it crashes, you'll see the error on stderr. Kill it with Ctrl+C; we'll let the client launch it next.
6. Wire it into Claude Desktop
Find your Claude Desktop config:
- macOS:
~/Library/Application Support/Claude/claude_desktop_config.json - Windows:
%APPDATA%\Claude\claude_desktop_config.json - Linux:
~/.config/Claude/claude_desktop_config.json
{
"mcpServers": {
"weather-demo": {
"command": "node",
"args": [
"/absolute/path/to/my-first-mcp/dist/index.js"
]
}
}
}Restart Claude Desktop (fully quit, not just close the window). You should see a tools icon (🔌) in the conversation input. Click it — your weather-demo server should be listed with one tool. Try: "What's the weather in Bengaluru using the get_weather tool?"
7. Wire it into Cursor
{
"mcpServers": {
"weather-demo": {
"command": "node",
"args": ["/absolute/path/to/my-first-mcp/dist/index.js"]
}
}
}Restart Cursor. Confirm in Settings → Features → MCP — green dot = connected. Ask the agent to use your tool.
8. Wire it into Antigravity
Same shape, different file: ~/.antigravity/mcp.json. See our [[how-to-use-google-antigravity-2026|Antigravity guide]] for the full path and options.
9. Debugging the inevitable "it doesn't show up"
- 1
Use an absolute path
relative paths won't work; the client doesn't know where you launched from
- 2
Check stderr
run the same command in a terminal first. If it crashes there, it'll crash for the client
- 3
Confirm the build ran
ls dist/index.js. The most common bug is editing TS and forgetting to rebuild - 4
Fully restart the client
Claude Desktop, Cursor and Antigravity all cache the MCP list; a soft reload won't pick up new servers
- 5
Use the MCP Inspector
npx @modelcontextprotocol/inspector node dist/index.jslaunches a UI you can hit your server with directly. Best debugging tool by far
10. From toy to real — what to add next
- Replace mock with real APIs — swap the weather mock for an OpenWeather or Open-Meteo call
- Auth — read API keys from
process.envand pass them in via theenvblock in the client config - SSE / HTTP transport — for servers that need to run remotely or across a network, switch from
StdioServerTransportto the SSE transport. Same handler code - Prompts — add
ListPromptsRequestSchemaandGetPromptRequestSchemato expose reusable templated prompts the user can invoke from the client - Pagination on resources — for large catalogs, support
cursorinListResourcesRequestSchema
11. Patterns that will save you pain later
- One tool, one job. Don't build a Swiss-army
do_stuffwith a mode parameter. Models pick from a menu — keep the menu legible - Tool names like English verbs.
create_invoice, notinvoiceMgmtAction. The LLM reads names to decide what to call - Detailed input schemas with descriptions. Every field needs
.describe(). The model uses these to know what to pass - Structured errors. Return
{ isError: true, content: [...] }with a clear message. Don'tthrowfor expected failures — the model can recover from a clear error message; it can't recover from a crashed server - Idempotent tools where possible. Models retry; a tool that double-charges on retry is a bad day
12. Publishing your server
If your server is useful beyond your team, publish it. The community catalog at github.com/modelcontextprotocol/servers accepts PRs. Internal-only servers can ship as a private npm package — clients will run them via npx the same way.
TL;DR
- MCP server = small Node process exposing tools / resources / prompts over JSON-RPC
- Use the official
@modelcontextprotocol/sdk; never write the wire format yourself - Log to stderr, not stdout (stdout is the protocol)
- Use absolute paths in client configs; fully restart the client after editing
- Debug with the MCP Inspector before debugging through a client
- One tool, one job, English verb names, descriptive schemas
Building an MCP server for a client product or internal tooling and want a code review or production-hardening pass? Drop us a line — we ship MCP servers for clients almost every week and would be glad to take a look.
Frequently asked questions
How do I set up an MCP server for Claude Desktop?
First, build your server using Node.js and TypeScript. Then, configure Claude Desktop by editing the claude_desktop_config.json file to include your server's command and arguments. Restart Claude Desktop to see your server listed with its tools.
What are the prerequisites for building an MCP server?
You need Node.js 20+, an MCP client like Claude Desktop for testing, and a text editor such as VS Code.
Why should I log to stderr and not stdout in MCP servers?
stdout is reserved for the MCP transport channel, and writing to it can corrupt the protocol. Logging to stderr avoids this issue.
What should I do if my MCP server doesn't show up in the client?
Ensure you're using an absolute path, check for errors on stderr, confirm that the build ran, and fully restart the client. You can also use the MCP Inspector for debugging.
How can I make my MCP server more robust?
Replace mock data with real APIs, implement authentication, consider using SSE/HTTP transport for remote servers, and add structured error handling and idempotent tools.