MCP Server Hands-On · Part 5 of 5
Building the Login: Proving a Domain, Two Registrations, and the Client ID I Could Not Hardcode
The design from part four, built. What Entra made me prove before it would accept the server as a name, why the client ID cannot live in my code, and testing the whole chain by hand before writing any of it.
Part four was a design with an open question, a doubt I did not write down, and a list of things I expected to trip over. This part is what happened when I built it. The login works now: a teammate signs in with their own account, the server swaps that sign-in for a token the events API accepts, and the API sees the person instead of the server.
Why the server needed a domain of its own
Two people have now asked me why a login needed DNS records, so here is the short version.
When Claude asks Entra for a token it names the server it wants the token for, by the exact URL the user pasted. Entra looks for an application registered with that URL as its identifier, and it only lets you claim a URL on a domain the tenant has proven it owns. Otherwise I could register an application claiming to be https://google.com.
Our server lived on a subdomain of the hosting platform, which belongs to the platform. Entra does have its own identifier format, api:// and a GUID, but Claude never asks for tokens that way. It asks by URL, Entra finds nothing matching, and the sign-in dies with AADSTS9010010.
So the server got a subdomain of ours, a TXT record to prove it, and a certificate so the URL answers over HTTPS.
Part four ended on a doubt about whether External ID, the flavour of Entra this tenant runs, could verify a domain at all. It can: one TXT record, and from then on Entra accepts identifiers on that name.
What nearly cost me a detour is a different feature with an almost identical name. Custom URL domains put your own domain on the sign-in page itself, and that one does need Azure Front Door in front of it. Nothing here required it. The identifier only cares about ownership, not about what the login page looks like.
The move that the domain forced
Part three mentions in passing that the server moved to Fly.io between deploys, without saying why. The reason is the section above.
The first home was the free tier of Azure App Service, which does not accept custom domains, so the login ruled it out. The paid tier that accepts one costs more per month than the rest of this project combined, and the serverless options start cold, which part four already flagged: Claude gives the sign-in about ten seconds, and a server that takes a few to wake fails logins now and then.
Fly runs the same Express app unchanged, stays awake, and throws in the domain and its certificate for a few dollars a month. In exchange there is no managed identity out there, so the server’s credential with Entra is a secret I store and have to rotate before it expires.
Two registrations, and which secret belongs where
The server’s registration holds the identifier, which is the URL with its /mcp path exactly as users paste it, the mcp.access permission that tokens for this server must carry, the permission on the events API that makes the exchange possible, and a client secret, because the exchange is the server proving it is itself.
Claude’s registration holds the redirect URLs where a sign-in comes back, one for the browser and one for the local port Claude Code listens on, and no secret. I registered those redirects under the mobile and desktop platform rather than the web one, which is what makes Entra accept a sign-in without a secret. Part four listed AADSTS7000218 as an error I expected to meet, the one where Entra demands a secret for a browser redirect. Picking the right platform at registration time is what avoids it. I also pre-authorized Claude’s registration on the server’s, so nobody sees a consent screen.
Two client IDs, then, and only one ever leaves my hands.
Why I cannot just hardcode the client ID
This was my own question, and I was annoyed by the answer before I understood it. If every user pastes the same client ID, why does my server not simply contain it?
Because the client ID does not identify my server. It identifies Claude to Entra, and my server is not in that part of the conversation. The sequence is: Claude calls my server, my server says “you need to sign in, here is where”, and then Claude goes off to Entra on its own. The sign-in happens between Claude and Entra. My server sees the result, never the negotiation.
The protocol has no field for “and use this client ID”. A client is supposed to get one by registering itself automatically, which Entra does not support, so the remaining path is the user typing it in.
There are two ways to spare people that step. On Claude Team or Enterprise, an admin adds the connector once for the whole organization, client ID included, and everyone else just switches it on. Or the server grows the fake registration endpoint from part four, which answers every registration request with the same pre-registered ID.
Worth saying plainly: handing the client ID around is not a leak. It is a public client with no secret, protected by PKCE, and its redirect URLs are fixed at registration. Someone who copies it can start a sign-in that returns to Claude, where they still need an account in our tenant that is allowed in.
Testing the chain before writing any of it
Before touching the server’s code I wrote a script that does by hand what the finished system does: sign in exactly as Claude would, run the on-behalf-of exchange, call the events API with the result. It prints what is inside each token and nothing else.
That script settled the doubt I had not written down. Part four describes the on-behalf-of exchange as if it were a given, and the documentation is thin about whether External ID supports it at all. Ten minutes of a script is a cheaper way to find out than a finished server that fails on its first call. It works, and the script taught me two things I would otherwise have met as bugs.
The token’s audience is the server’s client ID, a GUID, not the URL I had been so careful to register. The URL is how you request the token; the GUID is what ends up inside it. Validation code looking for the URL rejects every valid token.
And the user’s email is in the first token but not the second, where that field holds an internal identifier instead. The object ID is the one way to name the user that survives the exchange.
The script stayed in the repository, with a fourth step that calls the deployed server, so it now checks the chain from sign-in to database.
The code, which was the easy part
A small JSON document at a well-known URL says which authorization server to sign in with. A middleware in front of the MCP endpoint answers 401 with a WWW-Authenticate header pointing at that document, which is how Claude knows where to go. Part four’s other prediction lives in that document: the URL it declares has to match the one the user pasted, /mcp and all. It comes from configuration rather than from the incoming request, because behind the platform’s proxy the server only ever sees its internal address. Validation is a library call against Entra’s public keys, plus checking the token is for this server and carries mcp.access. The exchange is the same form post as the script, with a small cache in front: Claude reuses its token across calls, so otherwise every tool call would pay a round trip to Entra first.
Part four decided the whole server goes behind the login, and I kept that. The connector dialog does offer a middle option where Claude signs in only when the server demands it, and a server can leave some tools anonymous and protect others. It is about fifteen lines, and it doubles the paths to test for a path that, once everyone signs in anyway, exists mainly to be forgotten and broken later. The day this becomes a public listing of events with private write tools, the split earns its keep.
What the login changed
The same tool that had been returning published events now returns drafts as well, because the API is answering a person instead of an anonymous caller: 186 events where before there were 167. Nothing in the tool changed. The identity travelling with the call is the whole difference, and it is the thing the write tools need.
Where this leaves the series
The server is on a domain we own, behind a sign-in, and the events API now records which person made a call rather than which service. Nothing on anyone’s laptop.
The write tools are next, which is what the login was for: create and edit, not delete, as decided in part four.