← All Posts

How I Built My First MCP Server in Node.js


I know the exact moment I decided to build an MCP server, because I was doing something humiliating at the time.

I was pasting our database schema into Claude. Again. Every time a session started fresh, or context got compacted, or a new task touched a table I hadn't mentioned yet, I'd go dig up the migration files, copy the relevant chunk, and paste it in with some variation of "here's the current schema, please don't invent columns."

And it worked. That's the insidious part. It worked well enough that I kept doing it instead of fixing it.

What finally broke me wasn't a failure. It was noticing that my paste was stale. A teammate had shipped a migration two days earlier, I hadn't pulled, and I'd just handed Claude a confident, authoritative, completely outdated description of reality. It wrote perfectly good code against a table shape that no longer existed. The model wasn't hallucinating. I was the hallucination.

A person mechanically feeding the same photocopied page into a machine over and over, while the real document behind them has visibly changed - a stack of identical stale copies piling up on the floor.

That's the thing my hooks couldn't fix. Hooks are about when — they let me guarantee that something happens at a specific moment in the lifecycle. But no hook was going to give Claude a live connection to a database it had no way to reach. I'd solved timing. I hadn't solved reach.

So I went to learn how to build an MCP server. And I ran into something I didn't expect.


The part where everything is in Python

I want to be fair here: the MCP learning material out there is genuinely good. It's just that almost all of it is Python.

I read Anthropic's own MCP course first, which is the best conceptual grounding you'll find — it covers all three primitives (tools, resources, prompts) and, importantly, teaches the protocol as transport-agnostic, which turned out to be the single most useful idea I took away from any of this. Python SDK throughout.

Then I worked through freeCodeCamp's FastMCP walkthrough, MachineLearningMastery's task-tracker server, and Firecrawl's very thorough FastMCP guide — that last one is the most complete single article I found, covering tools, resources, prompts, the Inspector, and deployment. Python, Python, Python.

Auth0's blog-search server was the first one that felt like a real product instead of a demo, and it's where I properly understood what stdio transport actually means. Still Python. Danilchenko's notes-and-commits guide was the most honest one — the author admits to having "written, broken, and rewritten three different MCP servers" and the whole post is scar tissue. Python. And Apigene's build-deploy-scale piece, which synthesizes production complaints from dozens of developer threads and is worth reading even if you never write a line of Python.

Meanwhile our entire stack is TypeScript. Next.js front end, Supabase underneath it, pnpm workspace, the works. And the schema I wanted to expose already existed as TypeScript — generated types, checked into the repo, imported by every part of the app that touches the database.

And here's the thing — I could have just used Python. I've shipped plenty of it. FastMCP looked genuinely pleasant, the decorator API is lovely, and following those tutorials line by line would have been the path of least resistance by an enormous margin. I want to be honest that I sat with that option for about a day, because it was clearly the easier week.

What stopped me had nothing to do with the language. It was that a Python sidecar would need its own copy of everything it was describing — its own model of our schema, drifting out of sync the moment a teammate shipped a migration. Which is to say: I'd be solving a staleness problem by introducing a second thing that could go stale. Buying a second car because the first one needed gas.

So I built in Node deliberately, knowing full well that every tutorial I had was in the wrong language. And then I read all the Python posts anyway.

A reader sitting in a library where every book on every shelf is bound in the same color, taking notes into a single notebook of a completely different color - the notes clearly diagrams, not sentences.

That turned out to be the right call, for a reason I didn't anticipate.

The Python tutorials taught me the protocol and the failure modes. The only thing they couldn't hand me was the code — and the code turned out to be the cheap part.

Every single gotcha those posts warned me about showed up in my Node server. Not analogously. Not "in spirit." Literally the same bug, wearing a different runtime. The decorators became Zod schemas, print() became console.log, uv became pnpm — but the shape of every mistake was identical.

Here's what that actually cost me.


The bug that ate a Saturday

The first version of my server ran fine in the MCP Inspector. Every tutorial I'd read insisted on the Inspector, and Danilchenko in particular called it "the single most useful tool in the FastMCP development loop." They were right, and I want to say clearly that I did listen. npx @modelcontextprotocol/inspector node build/index.js, poke the tools, watch them return. Green across the board.

Then I wired it into Claude Code and got: server disconnected.

No stack trace. No error. No log line explaining itself. The server just... didn't exist, as far as the client was concerned. I spent an embarrassing number of hours checking my config, my Node version, my paths, my build output, and my life choices, roughly in that order.

The answer was sitting in Danilchenko's post the whole time. He warns that if a Python tool calls subprocess.run without capturing output, "the child's stdout collides with the MCP transport's stdin/stdout, and the server hangs."

I'd read that. I'd filed it under "interesting, but I'm not shelling out to subprocesses, so it doesn't apply to me."

Reader, in Node it applies to everyone.

Under stdio transport, your server's stdout is the protocol. It is a stream of JSON-RPC messages, and the client parses every byte of it. Anything else that lands there is not a log message — it's corruption. And in Node, the thing that lands there is console.log, which is the most reflexively typed statement in the entire language.

// this is not a debug line.
// this is a malformed protocol frame with a friendly face.
console.log("loaded schema for", tableName);

I hadn't even written that line myself. It came from a dependency printing a deprecation notice on import. One well-meaning library, one console.log, and the entire transport was garbage.

The fix is trivial once you know: everything that isn't protocol goes to stderr. console.error, or a real logger explicitly pointed at stderr. Claude Code will happily show you stderr; it just can't tolerate anything on stdout.

A clean pipeline of neatly stacked, identical message envelopes flowing left to right, with one crumpled handwritten note jammed sideways into the stream, causing the envelopes downstream to scatter and tear.

The Python posts all had a version of this warning. Firecrawl mentions it. The Auth0 post is careful about it. Every one of them said the same thing and I still had to lose a Saturday to it, because I read it as a Python problem rather than a transport problem.

That reframe is the whole lesson. stdio doesn't care what language you're in. It's a pipe, and you don't get to put your feelings in the pipe.


The second bug, which was dumber

This one is pure Node tax, and I've since watched two other people hit it, so I'm going to say it loudly.

Almost every tutorial warns you to use absolute paths in your client config. The aiagentskit guide says relative paths "fail silently." That's true, I obeyed it, and it still bit me — because in Python the tutorials point the config at server.py, which is the file you are editing. In TypeScript, you point it at build/index.js, which is not the file you are editing.

So there's a gap that simply does not exist in the Python workflow: your source and your running server are two different artifacts, and nothing tells you when they've drifted apart.

I spent a solid half hour convinced I'd found a bug in the SDK's schema validation. I had changed a tool's input shape, restarted the client, and watched it reject my new parameter as unknown. The SDK was correct. It was validating against the tool I'd compiled that morning. My change was sitting in a .ts file that nothing was reading.

Now the build runs in watch mode any time I'm touching the server, and the first question I ask when something looks impossible is "am I debugging today's code?" Roughly a third of the time, I am not.


I built twelve tools. I shipped four.

The Apigene post has a line that rewired how I thought about this, and it's not about Python at all. Describing a badly designed server, it notes that it "dumps 43 tools into the context window" before doing any work — and that this destroys agent performance.

My first pass had twelve tools. getSchema, getTable, listTables, getMigrations, getLatestMigration, getIndexes, getConstraints — you can see the disease. I'd built a thin RPC wrapper around every internal function I already had, because each one was easy to add and I was enjoying myself.

Then I actually used it, and watched Claude call three tools in sequence to answer a question that one well-shaped tool should have answered.

I cut it to four. And the cutting wasn't the interesting part — the interesting part was that the descriptions ended up mattering more than the implementations.

The dev.to weekend-project post makes this point almost in passing: docstrings are critical, because they're what the model uses to decide whether to call the thing at all. In Python, that's your docstring. In TypeScript it's your tool description string and your Zod .describe() calls, and they are doing exactly the same job.

I rewrote my tool descriptions more times than I rewrote my tool bodies. Not by a little — by a factor of maybe five. A tool whose description says "gets table info" will get called constantly, at random, for no reason. A tool whose description says "returns the current column definitions, types, and constraints for one table; use this before writing any query or migration against that table" gets called at the right time, for the right reason.

Your tool description is not documentation. It's the prompt. It's the only thing the model sees before it decides.

Two toolbelts side by side: one overloaded with a dozen near-identical, unlabeled tools crammed into every loop, the other holding four distinct tools, each with a clear handwritten tag tied to it.


The context window is a budget, not a bucket

Related, and I fell straight into it: Apigene lists "output bloat from returning raw JSON instead of processed results" as a top production complaint, and I want to add my own testimony to that pile.

My first getSchema tool did the honest, lazy thing — queried the information schema and returned the result. All of it. Every column of every table, as raw JSON.

It was something like eleven thousand tokens. For one call. To answer a question about one table.

It "worked" in the sense that nothing errored. It failed in the sense that the model now had a giant wall of mostly-irrelevant structure sitting in its context for the rest of the conversation, crowding out the actual task.

MachineLearningMastery's tutorial builds a deliberately tiny task server and notes plainly that in-memory storage isn't production-ready — the same instinct applies here in reverse. The tutorial version of a tool returns everything because everything is three rows. The real version has to have an opinion.

So my tools got opinions. They return formatted, trimmed, human-shaped text. They omit columns nobody asks about. They summarize instead of dumping. Firecrawl's guide sets a hard MAX_FILE_SIZE on its document reader for basically this reason, and I've come around to thinking every tool wants some version of that ceiling.

The mental shift: a tool's return value isn't a response, it's a permanent cost. Everything you hand back stays in context. You are spending the user's budget every time you're generous.


The day someone else wanted to use it

For a while my server was a purely local thing. stdio, one process, launched by my client, dying when I closed it. That's what almost every tutorial builds, and for good reason — Auth0's post is explicit that stdio "enables local desktop application integration," and that's genuinely where you should start.

Then a teammate asked if he could use it, and I discovered the exact trap Danilchenko describes: people deploy their server and forget the transport argument, "leaving servers listening only on stdin." Which is a very funny thing for a server on a public URL to be doing.

I did it too. Deployed, hit the URL, got nothing, blamed the platform.

The thing that saved me from a long debugging session was the one idea from the Anthropic course I mentioned at the start: MCP is transport-agnostic. Same tools, same primitives, same server object — the transport is a swap at the edge, not a rewrite. Once I actually believed that, the fix was obvious, because I stopped looking for the bug in my tools and started looking at how the thing was being served.

Apigene lays out the three options cleanly: stdio for local, SSE for older remote setups and on the way out, and Streamable HTTP for production remote servers. That's the map. I'd wandered off it without noticing.


The wall I did not climb

I'll be straight about this one: I stopped short of proper authentication, and I stopped short deliberately.

Apigene calls auth "the #1 challenge," quotes a developer saying they "definitely underestimated how tricky it would be, especially once authentication enters the picture," and describes OAuth failures that are silent, poorly documented, and inconsistent across clients. Danilchenko has a war story about a point release enforcing stricter token audience validation and breaking configs that had been working fine.

Reading those back to back, I made a call: our server stays inside the network, on our own infrastructure, reachable only by people who already have access to the database it describes. It exposes strictly read-only, already-internal information. That's a scope decision, not a security implementation, and I want to name it as such rather than pretend I solved something.

If you're building something that crosses an organizational boundary, budget real time for auth. Every source I read that had actually shipped one said the same thing, and none of them said it was quick.


What actually changed

Here's the part I didn't expect, and it's the reason I'm writing this at all.

I thought I was building a convenience. Removing a copy-paste step. Saving myself two minutes a session.

What I actually removed was an entire category of wrong answer.

Before, every session started with Claude knowing whatever I'd last told it, which was a snapshot of whatever I'd last looked at. The quality of its output was bounded by the freshness of my memory, and my memory is bad. When it got something wrong, the root cause was usually upstream of the model entirely — I'd handed it a stale premise and it had reasoned impeccably from bad inputs.

Now it just checks. It doesn't ask me what the schema is; it doesn't infer from surrounding code; it doesn't reason from a paste I made on Tuesday. It calls a tool and reads the current state, and the answer it builds on is true at the moment it's building on it.

The migration incident that started all this can't happen anymore. Not because Claude got smarter, and not because I got more disciplined — I have made no observable progress on discipline — but because the stale-paste step is no longer in the loop at all.

A figure handing over a photocopy is fading out; in their place, a direct glowing line runs from a reasoning silhouette straight down into a live, softly pulsing data source.


Closing thoughts

Two things I'd tell myself in that first week.

Read the Python posts. All of them. Don't skip them because you're shipping TypeScript, and don't skim the warnings because they're phrased against a runtime you're not using this time. Every meaningful thing that broke for me was described, in advance, in an article whose code samples I never once copied. The gotchas are protocol-level. The code is the only part that's language-level, and the code is the easy part.

Build in your own stack. Not because you couldn't manage another one — I could have written this in Python, and day one would have been easier. But your MCP server's whole job is to expose your system, and it should live where your system lives. Mine imports the same generated schema types the rest of the app already uses. Someone ships a migration, the types get regenerated, and the server stops compiling until I deal with it. That's not a feature I built; it's just what happens when the tool lives in the same repo as the thing it describes. A Python sidecar would have needed its own copy of that knowledge, and a copy is exactly the problem I was trying to delete.

Last time I wrote that hooks are about when — the moment a rule gets enforced, whether or not the model remembers it.

MCP is the other half. It's about reach: what the model can actually touch on its own, without me acting as a courier between it and reality.

Hooks stopped me from relying on Claude to remember. MCP stopped Claude from relying on me to remember. And between the two of them, I've finally stopped pasting my schema into a chat window like it's 2023.

If you've got a Node MCP server in production, I'd genuinely like to hear how you handled auth — it's the one part of this I punted on, and I know I'll be back.


References: