{
  "slug": "building-your-first-mcp-server",
  "url": "https://adaas.dev/blog/building-your-first-mcp-server",
  "formats": {
    "html": "https://adaas.dev/blog/building-your-first-mcp-server",
    "markdown": "https://adaas.dev/blog/building-your-first-mcp-server.md",
    "plaintext": "https://adaas.dev/blog/building-your-first-mcp-server.txt",
    "json": "https://adaas.dev/blog/building-your-first-mcp-server.json"
  },
  "title": "MCP Is the USB-C of AI: Building Your First MCP Server in 30 Minutes",
  "description": "The Model Context Protocol is what gives an AI agent senses, the ability to read your database, drive a browser, hit your production logs. This is the tutorial. Thirty minutes from a blank file to a working custom MCP server your Claude Code agent can call. Plus the gotchas that wasted a thousand dollars of our token spend.",
  "publishedAt": "2026-05-05",
  "updatedAt": null,
  "author": "Hunter Hodnett",
  "authorRole": "Co-founder & CTPO, Chipp",
  "authorUrl": null,
  "category": "Engineering",
  "tags": [
    "mcp",
    "mcp-server",
    "claude-code-mcp",
    "tutorial",
    "autonomous-development"
  ],
  "keywords": [
    "claude code mcp",
    "mcp server",
    "model context protocol",
    "mcp tutorial",
    "build mcp server",
    "browser mcp"
  ],
  "coverImage": null,
  "readingMinutes": 13,
  "canonicalUrl": "https://adaas.dev/blog/building-your-first-mcp-server",
  "bodyMarkdown": "Every USB cable I owned in 2018 was different. Mini, micro, lightning, USB-A, USB-B, the weird trapezoidal one for old printers. None of them worked with each other. Owning a cable for one device meant nothing for any other device.\n\nUSB-C ended that. One cable, one port, one protocol, and overnight, every device manufacturer's hardware became compatible with everyone else's. The standard didn't make any individual device better. It made the *ecosystem* better.\n\nThe Model Context Protocol (MCP) is doing the same thing for AI agents. Before MCP, every framework had its own format for tools. Each integration was bespoke; each integration was three integrations. After MCP, you write one server, and any MCP-compatible client. Claude Code, Cursor, Goose, increasingly anything, can use it.\n\nThis post is the tutorial I wish I'd had when I started. By the end of 30 minutes you'll have a working custom MCP server, registered to Claude Code, with one real tool that does something useful. Then we'll talk about what to build *after* the tutorial.\n\nIf you want the conceptual case for why MCP matters at all, [the manifesto covers it](/blog/autonomous-development#part-5-the-five-pillars). This post assumes you're convinced and want to build.\n\n## Part 1: What MCP actually is\n\nA language model only does one thing: output text. Every demo of an \"AI agent\", the ones that browse the web, query a database, send Slack messages, those are not the model doing anything. They're the model emitting *text*, and software around the model interpreting that text as an instruction.\n\nThe conventional way for the model to describe what it wants is a *tool call*. The model emits something like:\n\n```\nI want to call a tool named \"read_file\" with the argument {\"path\": \"src/index.ts\"}\n```\n\nThe framework around the model parses that, runs the actual `read_file` tool on your machine, gets the result, and feeds the result back into the model's next turn. The model now \"knows\" the contents of `src/index.ts`. It didn't read the file. The framework read the file. The model just got text saying \"here's what was in it.\"\n\nThis is the loop. Every interesting capability of every modern AI agent reduces to this loop.\n\n> \"The model never executes anything. It just describes what it wants executed, and software outside the model does the executing.\"\n> — Hunter Hodnett, Chipp CTPO\n\nMCP is the standardized way for the framework to find out *what tools exist*, *what parameters they take*, and *how to call them*. Before MCP, every framework had its own format. After MCP, you build a small server that conforms to the protocol, and any MCP-compatible client can use it.\n\nThe protocol itself is conceptually simple: an MCP server speaks JSON-RPC over either standard input/output (for local servers) or HTTP (for remote servers). It exposes a few methods, `tools/list` to enumerate what's available, `tools/call` to invoke one. Claude Code calls those methods on your behalf.\n\nYou don't need to memorize any of this. The TypeScript SDK handles the protocol details for you. You just declare your tools, write the implementations, and the SDK does the JSON-RPC plumbing.\n\n## Part 2: What we're building\n\nTo make this tutorial useful instead of abstract, we'll build a real MCP server: **a database query tool for a hypothetical app's `users` table**.\n\nIn 30 minutes, you'll have:\n\n- A local MCP server written in TypeScript.\n- A `query_users` tool the agent can call to look up users by email or ID.\n- A safe-column allowlist so the agent never accidentally exfiltrates passwords.\n- The server registered with Claude Code so you can use it interactively.\n\nThis is the shape of every internal MCP server we've built at Chipp. By the end you'll know how to write your own for your billing system, your job queue, your feature flag store, anything where a custom tool would beat a generic one.\n\n## Part 3: Setup (5 minutes)\n\nMake a fresh directory and initialize a Node project. We'll use TypeScript and the official MCP SDK.\n\n```bash\nmkdir my-first-mcp && cd my-first-mcp\nnpm init -y\nnpm install @modelcontextprotocol/sdk zod\nnpm install -D typescript @types/node tsx\n```\n\nCreate a minimal `tsconfig.json`:\n\n```json\n{\n  \"compilerOptions\": {\n    \"target\": \"ES2022\",\n    \"module\": \"ESNext\",\n    \"moduleResolution\": \"node\",\n    \"esModuleInterop\": true,\n    \"strict\": true,\n    \"outDir\": \"dist\"\n  },\n  \"include\": [\"src/**/*\"]\n}\n```\n\nAnd add a `\"type\": \"module\"` to your `package.json` so we can use ESM imports.\n\nThat's the entire setup. You're done.\n\n## Part 4: The smallest working server (10 minutes)\n\nCreate `src/server.ts`:\n\n```typescript\n\n\nimport {\n  CallToolRequestSchema,\n  ListToolsRequestSchema,\n} from \"@modelcontextprotocol/sdk/types.js\";\n\n// 1. Define the input schema for the tool.\nconst QueryUsersArgs = z.object({\n  email: z.string().optional(),\n  id: z.string().optional(),\n});\n\n// 2. Define the tool itself.\nconst QUERY_USERS_TOOL = {\n  name: \"query_users\",\n  description:\n    \"Look up a user from the application's users table. \" +\n    \"Pass either `email` or `id`. Returns the user's id, email, name, \" +\n    \"and account_status. Sensitive columns (password_hash, \" +\n    \"oauth_tokens) are filtered at this layer and never exposed.\",\n  inputSchema: {\n    type: \"object\",\n    properties: {\n      email: { type: \"string\", description: \"Email address to look up\" },\n      id: { type: \"string\", description: \"User ID to look up\" },\n    },\n  },\n};\n\n// 3. The fake \"database\" so this tutorial runs standalone.\nconst FAKE_DB = [\n  { id: \"u_1\", email: \"alice@example.com\", name: \"Alice\", account_status: \"active\" },\n  { id: \"u_2\", email: \"bob@example.com\", name: \"Bob\", account_status: \"suspended\" },\n];\n\n// 4. Boot the server, register handlers.\nconst server = new Server(\n  { name: \"my-first-mcp\", version: \"0.1.0\" },\n  { capabilities: { tools: {} } }\n);\n\nserver.setRequestHandler(ListToolsRequestSchema, async () => ({\n  tools: [QUERY_USERS_TOOL],\n}));\n\nserver.setRequestHandler(CallToolRequestSchema, async (req) => {\n  if (req.params.name !== \"query_users\") {\n    throw new Error(`Unknown tool: ${req.params.name}`);\n  }\n  const args = QueryUsersArgs.parse(req.params.arguments);\n  const user = FAKE_DB.find(\n    (u) => (args.email && u.email === args.email) || (args.id && u.id === args.id)\n  );\n  return {\n    content: [\n      {\n        type: \"text\",\n        text: user ? JSON.stringify(user, null, 2) : \"No user found.\",\n      },\n    ],\n  };\n});\n\n// 5. Wire stdio transport.\nconst transport = new StdioServerTransport();\nawait server.connect(transport);\n```\n\nThat's the full server. About 50 lines. Save it.\n\nYou can run it locally to make sure it doesn't crash:\n\n```bash\nnpx tsx src/server.ts\n```\n\nIt'll print nothing because it's waiting for JSON-RPC messages on stdin. Hit Ctrl-C.\n\n## Part 5: Register with Claude Code (3 minutes)\n\nTell Claude Code about the server by editing `~/.claude.json` (or your project's `.mcp.json` if you want it scoped to one project):\n\n```json\n{\n  \"mcpServers\": {\n    \"my-first-mcp\": {\n      \"command\": \"npx\",\n      \"args\": [\"tsx\", \"/absolute/path/to/my-first-mcp/src/server.ts\"]\n    }\n  }\n}\n```\n\nReplace `/absolute/path/to/` with the real path. Restart Claude Code.\n\nVerify the server is loaded:\n\n```\n$ claude\n> /mcp\n```\n\nYou should see `my-first-mcp` in the list, with one tool available.\n\n## Part 6: Try it out (5 minutes)\n\nIn a Claude Code session, ask:\n\n```\nLook up the user with email alice@example.com and tell me their account status.\n```\n\nThe agent should call `query_users` with `{\"email\": \"alice@example.com\"}`, get back the JSON for Alice, and report that her account is active.\n\nTry the negative case:\n\n```\nLook up the user with email nobody@example.com.\n```\n\nThe agent should call the tool, get back \"No user found.\", and report that.\n\nCongratulations, you have a working MCP server. The protocol plumbing is handled. The tool is yours.\n\n## Part 7: The discipline that makes this useful (7 minutes)\n\nNow the part that separates a tutorial MCP from a production MCP.\n\n### Tool descriptions are prompt engineering\n\nThe model decides whether to call a tool *based on the description*. Not the name. Not the parameters. The description.\n\nThe description we wrote above is honest but generic. A production version of the same tool would look more like this:\n\n```typescript\nconst QUERY_USERS_TOOL = {\n  name: \"query_users\",\n  description:\n    \"Look up a user from the application's users table. \" +\n    \"Pass either `email` (case-insensitive, exact match) or `id` (UUID). \" +\n    \"Returns: id, email, name, account_status. \" +\n    \"Sensitive columns (password_hash, oauth_tokens, payment_methods) \" +\n    \"are filtered at this layer and are NEVER exposed in any session. \" +\n    \"Use this when investigating customer-reported issues, debugging \" +\n    \"auth problems, or verifying user state. \" +\n    \"Do NOT use this to enumerate users (no list/scan capability) or \" +\n    \"to modify users (read-only). For mutations, use `update_user` instead.\",\n  ...\n};\n```\n\nThat description does five things the original didn't:\n\n1. Tells the model exactly what data shape to expect.\n2. Explicitly names the columns that are filtered out, so the model doesn't ask for them.\n3. Tells the model when to use the tool (and when not to).\n4. References sibling tools the model should use instead for related operations.\n5. Sets expectations about behavior (case-insensitive, exact match, etc.) so the model doesn't guess.\n\nThis level of detail is the difference between an MCP that the agent calls correctly and one that the agent calls in confusion. The investment compounds, every session that uses the tool benefits.\n\nA heuristic I use: **if the model would have to guess about anything, the description is too short**. Add a sentence.\n\n### Have Claude write your descriptions\n\nThe model is much better at writing descriptions for itself than you are. Once you've drafted the implementation, paste the schema into Claude Code and say *\"Write a tool description for this MCP tool. The agent will read this description to decide when to call the tool. Be specific. Mention edge cases. Tell the agent when not to use this tool.\"*\n\nThe first draft is usually 80% of the way there. Tighten and ship.\n\n### Filter sensitive data at the MCP layer\n\nThis is the big one. **Anything you don't want the agent to see should be filtered out at the server layer, not at the prompt layer.**\n\nIf you tell the agent \"don't look at password hashes\" via a system prompt, the agent might still look at password hashes when it's confused. If your MCP server *cannot return* password hashes, because the SQL has a hardcoded column allowlist that doesn't include them, the agent literally cannot see them. There's no failure mode where it accidentally gets exposed.\n\nWe give our autonomous agents read access to production databases. The reason this is safe is that the database MCP server has hardcoded column allowlists per table. The agent can query `users`, but the only columns the MCP server will return are `id, email, name, account_status, created_at`. Everything else is filtered at the server. Even if the agent goes off the rails and asks for `password_hash`, the MCP server returns the allowlisted columns and ignores the rest.\n\nThis is how you ship MCP servers that touch production data without losing sleep.\n\n### Restart Claude Code when you change the server\n\nThe most painful gotcha. Claude Code starts the MCP server process *once*, at session start. If you change the server's source code mid-session, your edits won't take effect until you restart.\n\nYou will, at some point, edit your MCP server, run it, find a bug, fix the bug, and watch Claude Code call the *old version* of the tool. Then you'll spend an hour debugging \"why isn't my fix working\" before you remember the gotcha.\n\nWhen in doubt: restart Claude Code. There's a cheaper way (the `/mcp` command lets you reconnect), but the brute-force restart is the muscle memory worth building.\n\n### Don't `console.log` from your MCP server\n\nLocal MCP servers communicate with Claude Code over stdio in a structured JSON-RPC format. If you `console.log` anything, that text gets injected into the protocol stream, the framework chokes on the malformed bytes, and your tool stops working in confusing ways.\n\nUse a real logger that writes to *stderr*, not stdout. The MCP SDK has examples. The pain of debugging a stdio-corrupted MCP server is the kind of pain you feel only once before you internalize the rule.\n\n## Part 8: Beyond the Tutorial (What to Actually Build)\n\nThe fake-database MCP we just built is a tutorial, not a production system. Here's what to build *after* this tutorial, in order of leverage.\n\n### A custom database MCP for your real database\n\nReplace the `FAKE_DB` array with a real connection to your application's database. Hardcode the column allowlists per table. Add tools for the queries you actually need, `query_users`, `query_orders`, `query_subscriptions`, whatever your domain requires.\n\nThis will be the most-used MCP in your cluster. We dispatch it on almost every Bug Bot ticket.\n\n### A custom browser MCP\n\nThe single highest-leverage MCP in autonomous development. ([Why →](/blog/mcp-is-not-optional))\n\nWrap a local Chromium instance via the [Chrome DevTools Protocol](https://chromedevtools.github.io/devtools-protocol/). Expose tools for navigate, screenshot, click, fill, console-log retrieval. Bake in your dev login flow as its own tool, `browser_dev_login(role: \"free\" | \"enterprise\" | \"admin\")` that bypasses your auth flow with seeded test credentials.\n\nThat last tool is the differentiator. Off-the-shelf browser MCPs are generic. The MCP we run for Chipp knows how to log in as any user role without going through OAuth. That domain knowledge is what makes verification fast enough to be useful in an autonomous loop.\n\n### A custom log-drain MCP\n\nWrap your log aggregator (we use Loki). Expose `query(labels, time_range)` and a higher-level `user_breadcrumbs(user_id, time_range)` that pulls a user's recent interactions before an error fired.\n\nThe `user_breadcrumbs` tool is what lets the agent reconstruct the user journey that led to a bug, and propose fixes that match real usage, not synthetic edge cases.\n\n### A custom cron / job MCP\n\nIf you have any kind of background job system, wrap it. Tools for *list jobs*, *query job status*, *trigger a job manually*. The agent will use these to debug job failures without you having to baby-sit.\n\n### Don't roll your own when an official one exists\n\nFor Stripe, GitHub, Cloudflare, Supabase, Notion, use the official MCPs. The vendors maintain them. They keep up with API changes. They handle auth.\n\nWhere to roll your own: anything internal to your codebase, anything where the off-the-shelf MCP is too generic to give the agent the right context (we found this true for databases, off-the-shelf DB MCPs hallucinated column names constantly).\n\n## Part 9: The mental model to keep\n\nWithout MCP, you have a model that emits text. That's it. It can't read your code, can't look at your screen, can't query your database, can't verify anything it produces.\n\nWith MCP, the model can interact with the world. It can check its own work. It can correct its own mistakes. It can chain together capabilities that no single component of your system would have alone.\n\nThat's not an upgrade. That's a phase transition.\n\nEvery additional MCP server you build is more *senses* the agent has. The agent that ships your code in 2027 will have access to twenty MCP servers and will navigate them as fluently as you navigate your filesystem. The teams that build those servers earliest will have agents that ship the most reliable code, because their agents will have the most ways to verify themselves.\n\nBuild one this weekend. Then build the next one. The cluster works as well as your tools let it.\n\n**[Join the Alchemist waitlist →](/#waitlist)**\n\n---\n\nIf you want the conceptual case for MCP, the [manifesto](/blog/autonomous-development#part-5-the-five-pillars) covers why MCP is one of the five pillars of autonomous development.\n\nIf you want to see how MCP fits into a production cluster, read [Building a Self-Healing Bug Bot](/blog/self-healing-bug-bot). Component 4 walks through the four MCPs every Bug Bot setup needs.\n\nIf you want the foundational discipline for managing the context MCPs add, read [Context Engineering](/blog/context-engineering)."
}