Writing

I'm Learning MCP by Building a Server Claude Can Actually Log Into

August 10, 2026 · Amit Solanki

A few weeks ago I built a voice AI receptionist to learn voice AI by doing it badly first. Same instinct, new protocol: this time it’s MCP — the thing that lets an AI assistant actually call into a real system instead of just talking about it. I don’t have a background in it. What follows is what I found out by building one against a real store, not a toy API.

This isn’t a launch either. There’s no store using this for real. It’s a Spree Commerce install — a real open-source Rails e-commerce platform — seeded with 100,000 real historical orders, with an MCP server sitting on top that can answer real business questions about it and, carefully, change things. If you’re after a “here’s how MCP works” tutorial, there are better ones. If you’re curious what breaks when someone builds one of these against something that isn’t a toy, that’s this.

Code’s here: github.com/amitkssolanki/mcp-server.

System architecture Claude authenticates via OAuth 2.1 with PKCE and dynamic client registration, then sends MCP requests to a Rails endpoint guarded by Doorkeeper. That endpoint reads and writes a Spree Commerce store backed by Postgres, seeded with 100,000 real Olist orders. Claude claude.ai connector OAuth 2.1 MCP endpoint Rails · Doorkeeper OAuth 11 scoped tools reads · writes Spree store catalogue · orders · customers real e-commerce data model Postgres · 100k real orders

Why I didn’t seed this with fake data

Faker-generated seed data has a tell: it’s flat. Every category sells about the same, every seller gets about the same reviews, every month looks like every other month. That’s fine for checking the UI renders. It’s useless for building analytics tools, because there’s nothing surprising to find and nothing wrong to catch — a bug that inflates a number by 40x looks the same as a bug that inflates it by nothing, because the baseline was already meaningless.

So instead of generating anything, I imported Olist’s public dataset — 100,000 real Brazilian e-commerce orders from 2016–2018, with real delivery timestamps and real review text. Real data has a shape synthetic data doesn’t: seasonal spikes, a long tail of tiny sellers, and correlations you didn’t put there on purpose. The one that actually surprised me: the five worst-reviewed sellers in the whole dataset all deliver early — up to ten days ahead of estimate. Their problem isn’t logistics, it’s the product. A synthetic dataset would never produce that, because nothing generated it deliberately being true.

This is Claude finding the same thing live, through the MCP server, from a plain question — not something I fed it:

Claude's analysis showing the worst-reviewed sellers all deliver early, with a table of six sellers averaging 2.3–3.4 review scores while delivering 2.7 to 21.7 days ahead of estimate

A bug that vanished 33 rows without saying a word

Importing 100,000 orders through Rails’ normal Order.create! — with its state machine, its callbacks, its totals recalculation — takes hours. Bulk-inserting the same data with insert_all takes four minutes. I used the fast path, which meant I was responsible for producing rows Rails’ own validations would otherwise have caught.

One of those rows types was payments, and I’d written the payment type into a column that happened to sit under a database index meant to stop duplicate payments on the same order. Real orders in this dataset often do have several payments of the same type — installments — so that “duplicate prevention” index quietly ate 33 legitimate rows. No error. No warning. The import just finished a little bit wrong.

The actual mistake wasn’t the column choice — it was using insert_all instead of insert_all!. The non-bang version silently skips any row that violates a constraint; the bang version raises. For an importer, a loud failure is strictly better than a complete-looking result that’s quietly short. I switched every bulk insert in the whole thing to the bang version after finding this, on the theory that if something’s going to be wrong, I’d rather find out immediately than three tools downstream.

The bug that made my own analytics lie to me

Once the data was in, I built MCP tools to answer real business questions — revenue by category, best and worst sellers. One of them reported Health Beauty’s revenue at R$52,545,084. The real number, which I only found by computing it a second, completely different way: R$1,258,681 — about 42 times smaller. Garden Tools had fake-jumped to third place in the ranking; correctly, it belongs around tenth.

The cause was a SQL join fan-out. Each product already carries its own running revenue total, computed once. My query joined that already-totaled number against every matching line item and every matching review before summing — so a product with 5 orders and 3 reviews had its total counted up to 15 times. A quieter version of the same mistake showed up in a seller-ranking tool: averaging review scores across a join meant one seller, who happened to have 21 line items in a single order, had that one order’s score counted 21 times in their average — dragging a real 3.81 down to a reported 2.57.

Both got the same fix: aggregate at the correct grain — one row per thing actually being summed — before joining anything else to it, not after. I didn’t trust my own fix either; I re-verified both against numbers computed an entirely different way before calling it done.

The fixed number, live through claude.ai after everything was wired up — R$1,258,681, exactly matching the number I’d independently verified:

Claude listing store categories by revenue through the Spree connector, showing Health Beauty at R$1,258,681 as the top category

The moment “just skip auth for now” stopped being an option

Wanting to test this against claude.ai for real, my first instinct was the fast path: tunnel localhost straight to the internet, connect it, done — protected by nothing but “it’s read-only, just for a few minutes.” I got as far as actually trying to open that tunnel before something stopped me — a permission guard on my own tooling refused to just run it without a second look. That pause was enough to make me ask the question I’d been skipping past: every real public MCP server has some kind of auth in front of it. What’s actually standard, and is “it’s just a quick test” really a good enough reason not to bother?

There isn’t much of a menu to choose from, it turns out. The MCP spec effectively mandates OAuth 2.1 for anything reachable over the network, and claude.ai’s own connector setup is built around exactly that — point it at a URL, and if the server wants auth, it’s OAuth or nothing; there’s no field anywhere for a plain shared key. That answered “which auth.” It didn’t answer “how much of it.” I had a second reason to build the real thing — all three RFCs, not the smallest version that would technically pass — instead of hand-registering one client and calling it done: this is the exact part of standing up an MCP server that most write-ups either skip or hand-wave in a paragraph. I wanted to know what the spec actually requires, not gesture at it.

Turning down a library that would’ve saved me a day

claude.ai’s connector flow speaks one language: OAuth 2.1, with PKCE and dynamic client registration, so a client can show up with nothing and register itself on the spot rather than me handing out an API key by hand. Building that from the ground up is real work, so naturally I went looking for a shortcut.

I found one — a gem that claimed to implement all of it, the newer registration and metadata RFCs included, in one dependency. Then I looked closer: it implemented OAuth token issuance and validation completely from scratch. Four GitHub stars. Zero forks. One contributor. No CI badge I could find. That’s exactly the wrong place to save a day — token issuance is the one part of this where a subtle bug is a security hole, not an inconvenience.

I built on Doorkeeper instead — the actual, long-established Rails OAuth provider — for the token and PKCE machinery, and wrote the newer, thinner MCP-specific layers (how a client discovers this server and registers itself) by hand on top of it. Those are simple, auditable JSON contracts, not cryptography. Writing them myself was the safer choice, not the riskier one — the opposite of how “just add a gem” usually feels.

Two locks on the same door, and I only knew about one

Once the OAuth flow worked against localhost, I put it behind a real tunnel so I could test it against claude.ai for real, not simulate it. Every single request died before reaching a line of my own code.

First cause: Rails itself silently rejects any request whose Host header it doesn’t recognize — a real security feature, but one that doesn’t know anything about tunnels. Fixed that, tried again, same failure — from a completely different layer. The MCP SDK has its own, separate Host-checking logic, guarding against a different attack (DNS rebinding), defaulting to loopback addresses only. Two different libraries, the same defensive instinct, implemented totally independently of each other — and I had to find and configure both before a single real request got through. “It’s protected” is very often two or three separate systems, not one, and they don’t know about each other.

Watching it actually happen, not just trusting the chat window

Connected for real, I tailed the Rails log live while testing from claude.ai itself. Real requests landed from Anthropic’s own infrastructure, authenticating against a real access token on every single call — I could see the token lookup query in the log each time.

Then I asked it to change a product’s price. It came back with a preview — current price, new price, percent change — and asked me to confirm before touching anything. Said yes, and only then did a write happen. I didn’t take “it worked” on faith: I checked the actual UPDATE statement in the Postgres log and the row in the database independently of what the chat said, then reverted it the same way. If something’s going to have write access to a real store, “the assistant said it worked” isn’t the bar. The row in the table is.

Claude confirming a price update was applied: Perfumery 1E9E8E now priced at R$150,00, up from R$99,90

Where this actually stands

To be direct about the gaps: this isn’t a real store, and nothing here is running unattended anywhere. It’s a Spree instance seeded with anonymized historical orders, reachable only through a tunnel I start by hand. The OAuth flow is real and spec-compliant, but the bootstrapping around it — one hand-created admin account, a dev tunnel, no rate limiting, no audit trail of who approved what — is not something I’d leave running without a person watching it. The products have no real names or photos, because the dataset doesn’t have them; cosmetic, but obvious if you look at the storefront.

The one design choice I’d genuinely like an actual OAuth practitioner to push back on: client registration is wide open, by design, per the spec — anyone can register a new OAuth client against this server with no gate at all. That’s the intended trust model (registering grants nothing by itself; an admin still has to approve every token), and I believe it’s right, but I’d rather hear I’m wrong from someone who’s shipped this before than find out the hard way.

If you’ve built something like this, or do this for a living and can see what I got wrong, I’d genuinely like to know — that’s the point of doing this in public.

Code: github.com/amitkssolanki/mcp-server

Working through something similar?

← All writing