Authentication and the Caller

Every call to your MCP server must say who is calling, and that answer must come from your own identity provider, never from what an AI client says about itself. This page sets up the sign-in AI clients expect (OAuth 2.0 with your identity provider), publishes the information they need to find it, registers the clients, and turns each verified caller into an McpCaller: the person, the AI client acting for them, and their permissions.

Why it works this way. People sign in to their AI client's connection with your company's usual sign-in and multi-factor authentication, and the client receives a token. The model never sees a password, no tool ever takes a credential, and Code Effects stores none: a credential pasted into a prompt would end up in the conversation, the AI vendor's logs and the model's context. Your application stays the only place that decides who may do what.

How a Client Signs In

  1. The client calls your server's address (https://rules.example.com/mcp) without a token.
  2. Your server answers 401 with a WWW-Authenticate header pointing to its protected resource metadata, a small document at /.well-known/oauth-protected-resource that names your identity provider (the authorization server) and the scopes to ask for.
  3. The client reads it, sends the person to your identity provider to sign in, and receives an access token for your server.
  4. Every later call carries the token. ASP.NET Core validates it; your MapCaller maps it to an McpCaller.

Steps 2 and 3 are what MCP clients require; Microsoft's MCP SDK for .NET, which the Code Effects server is built on, provides them as an ASP.NET Core authentication scheme.

Step 1: Validate Tokens and Publish the Metadata

using CodeEffects.Rule.Mcp;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using ModelContextProtocol.AspNetCore.Authentication;
using ModelContextProtocol.Authentication;

builder.Services.AddAuthentication(options =>
	{
		options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
		options.DefaultChallengeScheme = McpAuthenticationDefaults.AuthenticationScheme;
	})
	.AddJwtBearer(options =>
	{
		// Your identity provider: Microsoft Entra ID here; Okta, Auth0 or any OpenID Connect provider the same way
		options.Authority = "https://login.microsoftonline.com/<tenant-id>/v2.0";
		options.Audience = "api://rules-mcp";
		// Keep the token's own claim names ("oid", "azp", "scp") instead of ASP.NET Core's long ones
		options.MapInboundClaims = false;
	})
	.AddMcp(options =>
	{
		// Published at /.well-known/oauth-protected-resource: tells an MCP client where to sign in
		options.ResourceMetadata = new ProtectedResourceMetadata
		{
			Resource = "https://rules.example.com/mcp",
			AuthorizationServers = { "https://login.microsoftonline.com/<tenant-id>/v2.0" },
			ScopesSupported = { "api://rules-mcp/rules.access" }
		};
	});

builder.Services.AddAuthorization();
  • AddJwtBearer validates the token: the issuer (Authority), the audience (your server's identifier) and the signature. Use your identity provider's values.
  • AddMcp is the MCP SDK's scheme: it answers the unauthenticated call with the 401 that points to the metadata, and serves the metadata. It is the default challenge scheme, so a request without a token is sent there; JWT bearer remains the scheme that authenticates.
  • Resource is the exact address your users add to their clients. AuthorizationServers is your identity provider's issuer. ScopesSupported is the scope the client asks for.

If your application also signs people in with cookies for RR's screens, keep that scheme for the RR endpoint and name the JWT scheme on the MCP endpoint instead of making it the default: .RequireAuthorization(new AuthorizeAttribute { AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme }).

Step 2: Register the AI Clients with Your Identity Provider

A client needs an identity at your identity provider before it can ask for a token. MCP clients find one of three ways, in this order of preference:

Way What the client does What you do
Client ID Metadata Documents Identifies itself by an HTTPS address that describes it Nothing per client, if your identity provider supports it
Pre-registration Uses a client ID you give it Register an application for the AI client in your identity provider, and give its client ID to whoever adds the connection
Dynamic Client Registration Registers itself on first use Allow it in your identity provider, if it supports it

Microsoft Entra ID, the most common provider for Code Effects customers, does not offer Dynamic Client Registration, so pre-registration is the usual way. Plan about fifteen minutes per AI client, once:

  1. Register your MCP server as an API. In the Microsoft Entra admin center, under App registrations, register an application for the server. Under Expose an API, set its application ID URI (api://rules-mcp in the example) and add a scope (rules.access). Under App roles, add roles for automation if you will have service callers.
  2. Register each AI client. Register one application per AI client (one for Claude, one for each coding tool you allow), with the redirect address that client's documentation gives (Claude Code, for example, uses http://localhost:<port>/callback with the port you choose). Under API permissions, add your server's rules.access scope and grant admin consent.
  3. Give out the client ID. The person or administrator who adds the connection enters it in the client: in Claude, as Use your own OAuth client; in Claude Code, with --client-id; in VS Code, in the oauth block of mcp.json. See Connecting Users to Your MCP Server.
  4. Decide who may sign in. Assign users or groups to the server's enterprise application if you require assignment, and apply your conditional access policies as for any other application.

Other providers (Okta, Auth0, Keycloak) follow the same pattern: an API (audience and scope) for the server, and a client application per AI client unless the provider registers clients dynamically.

Step 3: Map the Caller

MapCaller runs on every call, after the token is validated. It receives the signed-in ClaimsPrincipal and the HttpContext, and returns who the caller is and what they may do, or null to refuse the call.

using System.Security.Claims;
using CodeEffects.Rule.Common.Models;
using CodeEffects.Rule.Mcp;

public static class Callers
{
	public static McpCaller? Map(ClaimsPrincipal user)
	{
		// Microsoft Entra ID's claims; other identity providers name them their own way ("sub", "client_id" ...)
		var id = user.FindFirstValue("oid");
		if(id == null) return null;

		// The app the token was issued to: "azp" in v2.0 access tokens, "appid" in v1.0
		var clientId = user.FindFirstValue("azp") ?? user.FindFirstValue("appid");

		// A token with no person in it (client credentials) has no "scp": your own automation
		if(user.FindFirstValue("scp") == null)
			return new McpCaller
			{
				UserId = "svc-" + clientId,
				UserName = "Nightly rule checks",
				UserType = McpUserType.Service,
				AccessMode = ActionPermissionMode.AllowOnly,
				Actions = new HashSet<ActionPermissionType>
				{
					ActionPermissionType.TestView, ActionPermissionType.TestRun
				}
			};

		return new McpCaller
		{
			UserId = id,
			UserName = user.FindFirstValue("name"),
			AgentId = clientId,
			AgentName = KnownClients.NameOf(clientId),
			AccessMode = ActionPermissionMode.AllowOnly,
			Actions = Permissions.For(user)
		};
	}
}

Permissions.For and KnownClients.NameOf stand for your own code: the first builds the person's permission set from your roles or groups, exactly as your RR endpoint does, so a person can do the same things in RR's screens and through their AI; the second gives each registered AI client a readable name for the user activity.

McpCaller:

Property Default What it is
UserId (required) The accountable user's id, as your application knows them, at most 256 characters. Use the same id your RR endpoint uses for the same person, so their work appears under one user
UserName null The name shown in the user activity
UserType User User (a person, possibly with an AI acting for them), Agent (an AI agent acting on its own) or Service (your own automation)
AgentId null The AI client acting for the person: the client the token was issued to, never the name the client reports about itself. Only with UserType.User; null when the person acts directly
AgentName null The agent's name in the user activity; only with AgentId
AccessMode ViewOnly How Actions is read: ViewOnly, AllowAllExcept or AllowOnly, as on the RR configuration
Actions empty The permissions, read according to AccessMode. Under AllowOnly the list must be complete: a permission without the ones it requires is a configuration error

Automation with No Person

Your own jobs and agents (a nightly test run, a CI pipeline) sign in with the client credentials flow: register them as applications with an app role on your server, and map their tokens to a Service caller (or an Agent caller for an AI agent acting on its own) with its own, narrow permissions, as in the example. They follow the service or agent test-run limits, and, with no person to ask, they run tests that call production methods on permission 66 alone; grant it only when that is what you want.

A key your application issues and checks itself is also possible: validate it in your own authentication handler and map it in the same MapCaller. Never put a key or token in a tool's arguments or an agent's prompt.

What the Server Checks on Every Call

  • The caller came from MapCaller; a null answer refuses the call.
  • The tool list shows only what the caller's permissions allow, and each call is checked again: a refused call is an error naming the missing permission.
  • The rules for agents apply whatever the permissions: nothing goes live, is approved, declined or deleted by an agent, and every save requests approval.
  • Every write names the user and, when there is one, the agent.

Troubleshooting

What you see Why
The client says it cannot find a sign-in The 401 has no WWW-Authenticate pointing to the metadata: AddMcp is missing or is not the default challenge scheme
Sign-in works, then every call is 401 The token's audience or issuer does not match AddJwtBearer, or the client asked for a different scope
Every call is refused after sign-in MapCaller returned null: check the claim names (turn off MapInboundClaims, or read ASP.NET Core's mapped names)
Claude cannot connect at all Your server is not reachable from Anthropic's cloud: Claude on the web, desktop and phone connects from there
The agent sees no tools The caller's permission set allows none; ViewOnly gives read tools for the core objects only

Ask on Stack Overflow - Yes, it's still active
Ask in GitHub Discussions

p102

l097 --

l102

p101

×