MentorAgent Docs
Menu

How-to guidePackages: Novior.MentorAgent.Server, Novior.MentorAgent

Secure the endpoints

Protect the SignalR hub, Server-Sent Events, /mcp and /a2a endpoints with authentication and CORS before you expose them.

View as Markdown
On this page

Every endpoint that MentorAgent maps is open until you protect it: the package cannot know how your application signs users in. This page puts authentication on each endpoint, with Cross-Origin Resource Sharing (CORS) in the right place.

Do it before anyone but you can reach the server. It applies to the endpoints of Novior.MentorAgent.Server: the hub, /mentor/chat, /mcp and /a2a. On Blazor Server the widget talks through the circuit, which your app's authentication already covers: only the endpoints of the MCP server and the A2A server are left, if you map them.

EndpointOpen by defaultWhat an anonymous caller can doProtect it with
/mentor-hubYesRun turns on your modelconfigureTransports
GET and POST /mentor/chatYesRun turns on your modelconfigureTransports
POST /mentor/approve, POST /mentor/cancel, /mentor/sessionPartly: while a signed-in user's connection is open, only that user is acceptedNothing, while a signed-in user's connection is openBuilt in
GET /mentor/admin/metricsNoNothing: the endpoint requires the DashboardRole roleDashboardRole
GET /mentor/tourYes, on purposeRead the tour: public screen names, the same for everyoneNothing to do
/mcpYesCall every action that has no roles and does not ask for confirmationMapMentorAgentMcp(configure: …)
/a2a and /.well-known/agent-card.jsonYesRun tasks on your model and read the cardMapMentorAgentA2A(configure: …)

The endpoints come with Novior.MentorAgent.Server: the hub, Server-Sent Events (SSE), MCP and A2A. Authentication is ASP.NET Core's. For JSON Web Token (JWT) bearer tokens, add its package to the server project, with the same major version as your .NET:

Terminal
dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer

Keep the scheme your application already has: cookies work for a WebAssembly client served by the same host. For clients on other origins, mobile apps and other agents, use bearer tokens:

Program.cs
using System.Security.Claims;                          // ClaimTypes (complete configuration)
using System.Threading.RateLimiting;                   // RateLimitPartition (complete configuration)
using Novior.MentorAgent.Extensions;                          // MapMentorAgentMcp, MapMentorAgentA2A
using Novior.MentorAgent.Server.Extensions;                   // MapMentorAgentServer
using Microsoft.AspNetCore.Authentication.JwtBearer;   // JwtBearerDefaults, JwtBearerEvents
Program.cs
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =>
    {
        options.Authority = builder.Configuration["Auth:Authority"];   // your identity provider
        options.Audience = builder.Configuration["Auth:Audience"];

        // A WebSocket handshake cannot carry a header: SignalR sends the token as ?access_token=
        options.Events = new JwtBearerEvents
        {
            OnMessageReceived = context =>
            {
                var token = context.Request.Query["access_token"];
                if (!string.IsNullOrEmpty(token) && context.Request.Path.StartsWithSegments("/mentor-hub"))
                    context.Token = token;
                return Task.CompletedTask;
            }
        };
    });
builder.Services.AddAuthorization();

In a browser, WebSocket and Server-Sent Events connections cannot carry an Authorization header. There the client, JavaScript or Blazor WebAssembly, sends the token in the access_token query string. Outside a browser, the .NET and Java clients send the header. The OnMessageReceived event above reads the query string for the hub only: every other endpoint still needs the header. If you map the hub on another path, change /mentor-hub in the event too.

After Build(), with MentorAgent and AddMentorAgentServer() registered as on the other pages:

Program.cs
// after var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();

app.MapMentorAgentServer(configureTransports: e => e.RequireAuthorization());   // hub and both /mentor/chat
app.MapMentorAgentMcp(configure: e => e.RequireAuthorization());                // /mcp
app.MapMentorAgentA2A(configure: e => e.RequireAuthorization());                // /a2a and the agent card
  • configureTransports covers the hub and both /mentor/chat endpoints, the three that run a turn.
  • MCP and A2A are mapped by their own calls, so configureTransports does not reach them. Their configure callback does, and for A2A it covers the card too.
  • Leave out the lines for the endpoints you do not map. MapMentorAgentMcp() and MapMentorAgentA2A() also need AddMentorAgentMcp() and AddMentorAgentA2A(). AddMentorAgentServer() calls both.

Then send the credentials from each client: accessTokenFactory in a JavaScript client, AccessTokenProvider in a WebAssembly client, and the Headers of a RemoteAgents entry in another MentorAgent host.

A server with browser clients on another origin and other programs on /mcp and /a2a. The services go next to the JWT registration above:

Program.cs
// Browser clients on other origins: SignalR sends credentials, so list the origins
builder.Services.AddCors(cors => cors.AddPolicy("MentorClients", policy => policy
    .WithOrigins("https://app.example.com")
    .AllowAnyHeader()
    .AllowAnyMethod()
    .AllowCredentials()));

// Other programs call /mcp and /a2a: a limit per caller
builder.Services.AddRateLimiter(limiter =>
{
    limiter.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
    limiter.AddPolicy("agents", context => RateLimitPartition.GetFixedWindowLimiter(
        context.User.FindFirstValue(ClaimTypes.NameIdentifier) ?? "anonymous",
        _ => new FixedWindowRateLimiterOptions { PermitLimit = 60, Window = TimeSpan.FromMinutes(1) }));
});
// after var app = builder.Build();
app.UseCors("MentorClients");   // before authentication and authorization
app.UseAuthentication();
app.UseAuthorization();
app.UseRateLimiter();

app.MapMentorAgentServer(configureTransports: e => e.RequireAuthorization());
app.MapMentorAgentMcp(configure: e => e.RequireAuthorization().RequireRateLimiting("agents"));
app.MapMentorAgentA2A(configure: e => e.RequireAuthorization().RequireRateLimiting("agents"));
  • CORS comes first. Call UseCors before UseAuthentication and UseAuthorization, and write those two calls yourself. Otherwise the browser's preflight request, which carries no credentials, can be refused before CORS answers it.
  • One CORS policy. UseCors("MentorClients") applies to every endpoint without a policy of its own: the hub, the /mentor endpoints, /a2a and /mcp. A browser client of /mcp also needs the Mcp-Session-Id header exposed: MCP server shows a policy of its own for that endpoint.
  • A limit for programs. RateLimitPerUser counts an MCP call in an allowance of its own, apart from chat messages. It counts an A2A task only when the request is authenticated. The ASP.NET Core rate limiter in this example bounds the requests to both endpoints, per caller, whatever MentorAgent sees.
OptionWhy it matters for the endpoints
AnonymousIdentityWith the default, PerSession, every hub connection, every SSE request and every A2A task of an anonymous caller is a new user, with its own memory and its own count. Shared puts every anonymous caller on one key: single-user hosts only
RateLimitPerUser, RateLimitWindowSecsCounted by the signed-in user's NameIdentifier, else sub, oid or the name. MCP calls have an allowance of their own. An anonymous caller gets a new allowance with every connection, every SSE request and every A2A task, so only authentication bounds the spend
UseMemoryContext, MemoryAutoCaptureWith either on and /mentor/chat open to anyone, MentorAgent logs a warning at startup
DashboardRoleThe role that GET /mentor/admin/metrics requires. An empty value opens it: development only
McpCallerPrincipalWhich role-gated actions an authenticated MCP caller sees
MaxMessageLengthLonger messages are refused before the model is called

Every option is in Configuration options, and the limits under Security and limits.

  • Who the user is. AddMentorAgentServer() passes the user of the hub connection, or of the request, to MentorAgent. The user's id is the NameIdentifier claim, else sub, oid or the name. RequiredRoles is checked with IsInRole. With JWT bearer, the default claim mapping turns the token's sub and role claims into NameIdentifier and ClaimTypes.Role. With MapInboundClaims = false, the roles need TokenValidationParameters.RoleClaimType: see MCP server.
  • Apps with Razor components. AddRazorComponents() and AddMentorAgentServer() can come in either order. MentorAgent reads the user of the hub, of /mentor/chat and of A2A from the request, before it asks the components' own provider, which works only inside a circuit.
  • Adding claims or roles. To add claims or roles for these endpoints, use an IClaimsTransformation: MentorAgent does not ask a provider of your own there.
  • Without authentication, role-gated actions are refused. Every action with RequiredRoles is refused for an anonymous caller.
  • /mentor/approve, /mentor/cancel and /mentor/session check the owner. While a signed-in user's connection is open, an anonymous request gets 401 and another user gets 403. Send the same token, or the same cookies with credentials: 'include'. The owner is the user with the same id: NameIdentifier, else sub, oid or the name. A name never matches the identifier of another user. The owner is checked before the body of a /mentor/session request is read.
  • The session snapshot is opaque. GET /mentor/session returns the conversation protected with ASP.NET Core Data Protection and bound to the connection's user. The client cannot read or edit it, and only that user can restore it. On a host without sign-in every visitor is the same anonymous user, so there the snapshot is bound to the application only. To restore after a restart, or on another instance, persist and share the Data Protection key ring (external site), as you do for authentication cookies.
  • A confirmation raised over SSE has no owner. Whoever holds its actionId can answer it. Keep /mentor/chat authenticated and treat the actionId as a secret.
  • A confirmation does not outlive its connection. When the hub connection closes, the turn waiting for the confirmation ends with it. A late POST /mentor/approve answers 404 and the action does not run.
  • The dashboard component checks nothing. DashboardRole protects the endpoint, not MentorDashboard: put the component on a page with [Authorize(Roles = "Admin")].

What each channel does with roles and confirmations is in Where nobody can answer.

  • The hub refuses a valid token with 401, or treats the user as anonymous. The server does not read access_token from the query string for the hub's path: add the OnMessageReceived event.
  • The browser blocks the connection from another origin. The origin is not in the policy, the policy lacks AllowCredentials(), or UseCors comes after authorization.
  • Every request to /mcp answers 500. The endpoint has RequireCors(...) and the pipeline has no app.UseCors().
  • /mentor/approve, /mentor/cancel or /mentor/session answer 401 or 403 to the right user. The request does not carry the identity of the hub connection, or the token has none of the claims NameIdentifier, sub, oid or name.
  • POST /mentor/approve answers 404. Nothing waits for the answer any more: it was answered already, the turn was stopped, or the connection closed. It is not a fault: close the dialog.
  • GET /mentor/admin/metrics answers 403. The user lacks the DashboardRole role, Admin unless you change it.
  • At startup the log says that /mentor/chat runs a paid turn for anyone who can reach it. The transports have no configureTransports: e => e.RequireAuthorization().
  • /mcp or /a2a answers 429. The caller went past the allowance of the ASP.NET Core rate limiter. A call to /mcp that went past RateLimitPerUser does not answer 429: it answers 200 with an isError result.
  • POST /mentor/session answers 400. The snapshot is not one that this server issued for this user. Possible causes: the snapshot was edited, it belongs to another user, an older version saved it, or the server lost the Data Protection keys that protected it. Save the conversation again with GET /mentor/session. The live conversation stays untouched.
  • POST /mentor/session answers 415. The request does not have a JSON content type.
  • Roles, confirmation and governance — How MentorAgent checks roles and asks for confirmation before an action runs, on every channel, and what it leaves to your code.
  • Any front end: React, Angular, Vue — Use MentorAgent from React, Angular, Vue, a mobile app or any HTTP client: SignalR or Server-Sent Events, confirmation over HTTP, and how to stop an answer.
  • MCP server — Expose your actions to MCP clients such as Claude Desktop or VS Code: authentication, CORS, and the actions that the server does not list.
  • Configuration options — Every MentorOptions property with its type, default and effect, grouped by area, plus the options of the WebAssembly client and the YAML loader.