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.
Who this is for
Anyone who can run `npm install` and read TypeScript. You don't need to know what a JSON-RPC framing protocol is. By the end you'll have built one without ever having to think about it.
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");Why console.error and not console.log?
stdout is the MCP transport channel — writing to it corrupts the protocol. Always log to stderr in MCP servers. This is the #1 "why doesn't my server connect" mistake.
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"
- 1Use an absolute path — relative paths won't work; the client doesn't know where you launched from
- 2Check stderr — run the same command in a terminal first. If it crashes there, it'll crash for the client
- 3Confirm the build ran —
ls dist/index.js. The most common bug is editing TS and forgetting to rebuild - 4Fully restart the client — Claude Desktop, Cursor and Antigravity all cache the MCP list; a soft reload won't pick up new servers
- 5Use 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.