tomydiaz
All posts

MCP Server Hands-On · Part 2 of 5

7 min read

The API Behind the Tool: Token Validation, Two Public Endpoints, and a Port to Functions

The admin app was sending tokens the API never checked. Closing that without breaking my teammates, opening exactly two endpoints for the MCP server, and moving the API to Azure Functions.

Part one ended with an MCP server on my laptop, calling an events API that was also on my laptop because it had never been deployed. Before deploying it, I wanted to know who would be able to call it once it had a public URL. The answer was anyone, for everything.

A login that protected the screens and nothing else

The admin app, which I did not write, has a real login. Colleagues sign in with Microsoft Entra ID, and every call the app makes to the events API carries a bearer token for the API’s scope. That part was done properly.

The API never looked at the token. No [Authorize], no authentication middleware, nothing. The login protected the admin’s screens, but anyone who called the API directly skipped it entirely, and that included POST, DELETE, and a maintenance endpoint that rewrites every date in the collection.

Running on localhost, none of that matters. With a public URL it would have been the first thing to go wrong.

What the MCP server needed from it

The MCP server has no user yet, so it cannot present a token. It only needs to read events, though, and the events worth reading are the published ones, which are already public on the website.

So two endpoints became anonymous, the list and the detail, and everything else requires a valid token. Anonymous callers only ever get published events. The filter lives in the Mongo query, and the detail endpoint answers 404 for an unpublished event, the same as for one that does not exist. A signed-in caller sees drafts too, which is what the admin needs.

There is one rule I added that I would not have thought of a year ago. If a request carries an Authorization header and the token fails validation, the public endpoints answer 401 instead of quietly falling back to the anonymous view. Otherwise an expired admin token would show the admin a shorter list with no error, and someone would lose an afternoon wondering where the drafts went.

The flag I added and then removed

My first version put all of this behind an on/off switch, off by default, so my teammates’ local setups would keep working. Testing it turned up a bug in that plan. With authentication off, every caller counted as anonymous, so the local admin would have stopped showing drafts. The switch meant to protect them would have broken their setup anyway.

Then the obvious question: did the admin even need the switch? I checked. Every call it makes to the events API goes through one wrapper function, and that function always attaches the token. The only other fetch in the admin goes to a geocoding service. So the admin already sends a valid token everywhere, and authentication could simply be on, always, locally included.

The switch is gone. The API now refuses to start if the tenant and client configuration is missing, which removes the possibility of a deploy that is open by accident.

The tenant that was not the tenant I assumed

The admin signs in against an Entra External ID tenant, the customer identity flavour of Entra, and I only found that out by reading its auth config. It matters because External ID tenants issue tokens from a ciamlogin.com authority, not from login.microsoftonline.com. A validator pointed at the usual authority will reject every real token and give you no useful hint about why.

Two smaller details from the same afternoon. The issuer in the tenant’s metadata uses the tenant ID as the hostname, while the admin’s authority URL uses the tenant’s subdomain, so the issuer has to come from the metadata rather than being derived from the config. And the audience can arrive as either the bare client ID or the api:// form depending on the token version, so the API accepts both.

Against the real development database, anonymously, the list returned 157 events and zero drafts. Every protected endpoint answered 401.

Porting it to Azure Functions

Our other backend services run as Azure Functions in the isolated worker model, with a pipeline that deploys to test and then production. I wanted this API to deploy the same way, which meant porting it off ASP.NET Core controllers.

Most of the port was mechanical. Each controller action became a function with an HTTP trigger. The domain, repository and scraping code did not change at all. A few things were worth the attention:

  • The URLs had to stay exactly the same, because the admin must not notice. Functions prefixes every route with /api by default, so host.json sets the prefix to empty and each function declares its full route. That included POST /api/v1/events/{id}:clone, whose colon I did not trust until I watched it match.
  • There is no authentication middleware in the isolated worker. Each protected function calls a token validator explicitly as its first line. It is more repetitive than an attribute, and easier to read, because the check sits right where it applies.
  • The validation filter went away too. FluentValidation used to run through an MVC filter. Now the functions that take a body validate it themselves.
  • OpenAPI stopped inferring. The Functions OpenAPI extension only documents what carries its attributes, and it serves the document at /openapi/v3.json instead of the old /openapi/v1.json. An endpoint without attributes silently disappears from the generated client types.
  • dotnet run no longer works. It is func start now, run from the project folder.

A smaller surprise: the web SDK had been supplying implicit using directives for logging and configuration. A Functions project uses the plain SDK, so half the existing files stopped compiling until those namespaces were declared once, globally.

The controller tests became function tests, which mostly meant building an HttpRequest and saying explicitly whether the caller was signed in. One test kept failing, in the scraping code I had not touched. Before blaming anything I stashed my changes and ran it on a clean checkout. It failed there too, so it went on the list of existing problems instead of into this change.

Deploying, and the checks that mattered

Two Function Apps, test and production. Database credentials are Key Vault references resolved by each app’s managed identity, so the configuration holds pointers and never the secrets themselves.

I could not find a way to ask Azure whether those references had resolved on this plan. The check that settled it was a /health/db endpoint that pings Mongo and returns the database name, which proves the identity, the Key Vault access, the credentials and the network path to the database all at once. It is also what the pipeline’s smoke test calls after each deploy.

That endpoint caught two mistakes. The first was mine: I had pointed the test app at the development database. The rule we settled on is simple: local runs use dev, the test app uses the test database, and production uses production. The endpoint then said the test app was on the test database, with 167 published events.

The second was stranger. The production deploy reported success, and every route, including /health, returned 404. The app had zero functions registered and no deployment package at all. The publish had never reached it. Publishing again took two minutes, and production then returned 170 events.

The part I expected to fight with, the database’s IP allow list, needed no changes at all. That is on my list to go and understand rather than enjoy.

What changed for the team

The admin needed no changes. The rest of the team did get something new to learn: a different command to run the API locally, a new OpenAPI URL, and the fact that trying a protected endpoint with curl now needs a real token. I wrote all of that in a short run readme next to the service, because the first question after pulling these changes will be why dotnet run stopped working.

With the API deployed and closed, the MCP server could stop depending on my laptop. Part three is about deploying it and connecting Claude to it for real.