tomydiaz
All posts

MCP Server Hands-On · Part 1 of 5

5 min read

Building an MCP Server Over HTTP, and Getting Claude to Actually See It

A small TypeScript server with one tool, tested with curl before any client touched it, and the three separate reasons Claude could not find it once it was installed.

In the last post of my RAG series I argued that the chatbot’s tools wanted to become an MCP server. This series is the first time I actually built one end to end, against a real API, with real data, running somewhere other than my laptop. It is a hands-on log. The theory of the protocol gets its own series later.

The goal was modest on purpose. Our events platform has an API that the admin app uses to manage events. I wanted an agent to be able to list those events, so the whole server started with a single tool: list_events.

Why HTTP and not stdio

MCP servers come in two shapes. A stdio server is a process the client launches on your own machine and talks to through standard input and output. An HTTP server is something you run once and point clients at.

The deciding factor was what installing it would mean for the people using it. With stdio, every person needs the repo, Node, and a command in a config file. With HTTP, installing means pasting a URL. Since this is going to be used by the whole team, and eventually needs a login, it had to be HTTP from the start.

The server itself

It is the official TypeScript SDK on top of Express, using the Streamable HTTP transport in stateless mode. Every POST /mcp builds a fresh server and a fresh transport, handles the one request, and throws both away. There are no session IDs, so nothing has to be shared between requests, and later between instances.

The tool is a thin wrapper around GET /api/v1/events. The API already accepted an originPlatform filter, so the tool exposes it as an optional argument. The result goes back as JSON text with a count and the events, and the tool is annotated as read only.

Testing it before any client touched it

I did not want my first test to be “open Claude and see if it works”, because when that fails you learn nothing about why. So the first round was raw JSON-RPC with curl, against a tiny mock of the events API so the database was out of the picture:

curl -X POST http://127.0.0.1:3333/mcp \
  -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"list_events","arguments":{}}}'

initialize, tools/list and tools/call all answered. Then I pointed the server at a port with nothing listening, to see what the agent would get when the API was down, and the answer was Failed to list events: fetch failed. Useless. Node’s fetch puts the real reason in error.cause, so the tool now reports ECONNREFUSED, which tells you exactly where to look.

One detour worth admitting. After that fix the message did not change, and I spent a few minutes convinced the fix was wrong. The old server was still running. I had started it with npx tsx, and killing the npx process leaves the Node child alive and holding the port. The new build never got the port at all.

The API that was not deployed

The plan was to point the server at the deployed events API. When I went looking for its URL, the only config value that held it said http://localhost:5000. The API had never been deployed, and the admin app’s “deployed” configuration was pointing at a developer’s machine.

So the first working version pointed at the API running on my own laptop, and deploying the API became its own piece of work, which is part two.

Installing it in Claude Code

Claude Code registers MCP servers at one of three scopes. local is the default: this folder, this user. project writes a .mcp.json into the repo, which is good for sharing but still tied to that folder. user writes to your user config and applies to every session, in any directory.

I wanted to use it the way I would once it was deployed, from anywhere, so it went in at user scope:

claude mcp add --scope user --transport http events http://127.0.0.1:3333/mcp

Two small things here. Without --transport http the CLI assumes stdio and tries to run the URL as a command. And I used the CLI instead of editing the config file by hand, because the desktop app keeps that file open and writes to it while it runs.

claude mcp list then showed the server as connected, and that check is real: it opens a connection and does the MCP handshake.

Three reasons Claude could not see it

Installed, connected, and invisible. It took three separate discoveries to get it on screen.

I opened a chat. The desktop app has a Chat tab and a Code tab, and they do not share MCP configuration. What I installed lives in Claude Code’s config. Chat connectors are configured separately, and more importantly, they connect from Anthropic’s servers rather than from my machine. A chat connector pointed at 127.0.0.1 is pointing at a machine in a data center, which is why nothing local was ever going to show up there.

I asked in the wrong session. I went back to the Code session I had been working in and ran /mcp. That session had started before the server was installed, and MCP servers are loaded when a session starts. Nothing added afterwards appears until you open a new one.

/mcp was not showing what I thought. In the desktop app, /mcp opened a connector directory with a search box. I searched for my server’s name and got a page of unrelated connectors. That screen is the public catalog of connectors you can add, not the list of servers you have. Mine was never going to be there.

A new Code session, a plain “list the events”, and the tool call showed up in the transcript with 157 events from the local database.

Where this left things

A working server, on my laptop, talking to an API on my laptop. Everything was reachable only from this machine, and the API behind the tool validated nothing at all, which turned out to matter a lot more than I expected. That is the next post.