What a 401 Means to an MCP Client ended on a deliberately unfinished note: the token asserts who the caller is, it does not decide what they may do once inside. That sentence has been an IOU ever since. This post pays it, with claims-based authorization implemented in the sample, the same denial rendered as a 403 JSON body on one door and as recovery prose on the other, and every state verified live, including two detours that taught me more than the happy path did.
One decision, one place
The design rule from the first post was that neither door contains business logic, and authorization is business logic. So the policy lives in the kitchen:
public static class OrderPolicy
{
public const string RequiredRole = "Orders.Place";
public static bool MayPlaceOrders(CallerContext caller) =>
caller.Roles.Any(r => string.Equals(r, RequiredRole, StringComparison.OrdinalIgnoreCase));
}
Anyone may search restaurants and read menus; placing an order requires the Orders.Place role. The doors do exactly two things: gather identity however their protocol allows, and render the kitchen's decision in their caller's dialect. The REST door answers code, so it returns a machine-shaped refusal:
{"error":"forbidden","detail":"Placing orders requires the Orders.Place role.","requiredRole":"Orders.Place"}
The MCP door answers an agent, so it returns an instruction it can act on: "This caller is not allowed to place orders; that requires the Orders.Place role. Searching restaurants and reading menus remain available." And because that sentence goes out through the telemetry helper from the confusion-telemetry work, every denial is also an attributable order_forbidden row in Application Insights.
The REST door: a sentence in a token
App Service authentication validates the bearer token and hands the function the caller's claims in the X-MS-CLIENT-PRINCIPAL header; the door parses out the roles and asks the kitchen. Verifying it took one Entra app role (value Orders.Place on the restaurant-mcp-auth registration), one role assignment to my own account, and produced the cleanest before-and-after in this series: the same curl, the same body, the same endpoint, a 403 with the role named in it, and then an order confirmation for 23 euros. The only thing that changed between the two was one sentence inside the token.
Getting that sentence took two detours worth telling. First, the role assignment needs an enterprise application to attach to, and the portal's one-click MCP auth button had created the app registration without a service principal; the "Managed application in local directory: Create Service Principal" link on the registration's overview was the missing step, and until it was clicked, the app simply did not exist in the Enterprise applications list to assign anyone to.
Second, and more operationally important: after the assignment, the token stubbornly kept returning no roles. Not propagation, not misconfiguration, but two layers of caching, the Azure CLI reusing its still-valid cached token, and, more fundamentally, the fact that claims are minted at token issuance, not looked up live. A role you grant right now reaches a caller only when they next acquire a token. For human users that is an inconvenience; for agents, which hold tokens and run long sessions, it is an architectural fact: revoking or granting a role changes agent behavior at token-expiry speed, not immediately. Decode the token when in doubt; the roles claim either is or is not in there, and no amount of retrying the API changes it.
The MCP door cannot see you
Here is the honest half. The Functions MCP extension authenticates the request, Entra and Easy Auth both did their jobs in the previous posts, and then hands the tool code a ToolInvocationContext containing the tool name, the arguments, a session id, and transport info. Nothing about the caller. The platform knows exactly who is calling; the tool does not get told. Per-user authorization on the MCP door is therefore not possible at this layer today, the same category of gap as the clientInfo one from the telemetry post, and the feature request writes itself: forward the authenticated principal to the tool invocation.
What remains honest in the meantime is door-level authorization: an MCP_DOOR_ROLES app setting grants roles to the door as a whole, an explicit operator decision about what the MCP channel may do, which is a real and common enterprise posture ("agents may read, agents may not transact") even though it is coarser than per-user claims.
And the telemetry proved the gate works, in the most satisfying way this series has produced. The traffic driver's deliberately wrong order calls hit the door before the grant and landed in Application Insights as order_forbidden. One app setting later, the same calls landed as order_rejected, because authorization now passed and the invalid arguments failed business validation instead. Same requests, different sentinel, and the shift between the two is the authorization gate becoming visible in a Log Analytics table. (One footnote for your own dashboards: the isolated worker emits each log line twice through the host, so absolute counts double; dedupe on operation id or read ratios, not totals.)
Door parity, completed
The commenter's test from months ago asked whether both doors preserve the same auth, error, and idempotency behavior. The answer the sample now gives in full: the decision is identical because it is taken once, in the kitchen, from the same policy. The rendering is deliberately different per door, a status code for code, a sentence for an agent, and the denial is observable in both channels, as a 4xx in the request logs and as a sentinel in the confusion telemetry. Parity of decisions, dialect per door.
The takeaway
Authentication moved to the platform over the course of this series; authorization never left your code, and this post is the argument that it should not. Put the policy in the kitchen where both doors share it, feed it claims where the platform forwards them and explicit door grants where it does not yet, render denials in each caller's dialect, and log every denial with the same attributable telemetry as any other contract failure. And remember the quiet operational lesson from the token detour: claims are minted, not looked up, so authorization changes reach your agents exactly as fast as their tokens expire, and no faster.
The policy, both doors, and the traffic driver are in the companion repo; the whole implementation is one small policy class and a dozen lines per door.


Top comments (0)