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.
On this page
The MCP server publishes your actions as tools of the Model Context Protocol (MCP). An MCP client, such as Claude Desktop, VS Code or an agent you build elsewhere, lists them and calls them over HTTP at /mcp.
When to choose it
Section titled “When to choose it”Choose it when another AI application should run your business logic: an AI assistant in the editor, a desktop client, an agent of your own.
- The client's model decides. MentorAgent runs the method the client calls, with no model, prompt or conversation of its own. Every call is a direct call to your code.
- Only what nobody needs to approve. Actions that need a confirmation are never published. A
[MentorAction]that declares neitherReadOnly = truenorRequiresConfirmationcounts as a write. It asks for confirmation by default, so the server does not publish it. Actions with roles are published only to callers that hold one of the roles. See Security and roles. - Only Level 1 actions. The server publishes the methods of your action classes. The methods of a specialist, a class with
[MentorAgent], are never published, not even a read with no rules. To publish such a read, write it in a class without[MentorAgent]. The coordinator then calls it directly too, not through the specialist. - Any ASP.NET Core host. The server is in
Novior.MentorAgent.Server. A backend that callsAddMentorAgentServer()and a Blazor Server app that callsAddMentorAgentMcp()map it the same way.
If the other agent should talk to your whole agent, with your model, specialists and documents, see A2A server. To call MCP servers from your agent, see MCP and A2A clients.
Install the packages
Section titled “Install the packages”.NET CLI
dotnet add package Novior.MentorAgent.ServerPackageReference
<ItemGroup>
<PackageReference Include="Novior.MentorAgent.Server" Version="1.0.0" />
</ItemGroup>Central Package Management
<ItemGroup>
<PackageVersion Include="Novior.MentorAgent.Server" Version="1.0.0" />
</ItemGroup><ItemGroup>
<PackageReference Include="Novior.MentorAgent.Server" />
</ItemGroup>File-based app
#:package Novior.MentorAgent.Server@1.0.0Novior.MentorAgent.Server brings Novior.MentorAgent and the MCP SDK for ASP.NET Core (1.3.0) with it. Add it to a Blazor Server app too: you do not need the hub for MCP. Neither package includes a model provider package. The code on this page uses OpenAI, so add its package too:
dotnet add package Microsoft.Extensions.AI.OpenAI --version 10.6.0For another provider, see Model providers.
Minimal configuration
Section titled “Minimal configuration”Keep the model key out of the source code:
dotnet user-secrets init dotnet user-secrets set OpenAI:Key <your-key>MCP calls never reach the model, but MentorAgent still needs one: the options are checked when the host starts, and an invalid configuration stops it.
Write the actions as usual, in a class you register in dependency injection:
public class CaseTools(ICaseStore store, ICaseQueue queue) { // Published to every caller. ReadOnly: it only reads, and MCP clients are told so [MentorAction(Description = "Gets the status of a support case", ReadOnly = true)] public Task<string> GetCaseStatus(string caseNumber) => store.GetStatusAsync(caseNumber); // Needs a signed-in user with the role [MentorAction(Description = "Lists the open cases assigned to a team", RequiredRoles = ["Supervisors"], ReadOnly = true)] public Task<IReadOnlyList<string>> ListTeamCases(string team) => queue.ListOpenCasesAsync(team); // Needs a person to confirm it [MentorAction(Description = "Closes a support case with a resolution", RequiresConfirmation = true)] public Task CloseCase(string caseNumber, string resolution) => store.CloseAsync(caseNumber, resolution); }The tool names are the method names in snake case:
get_case_status,list_team_cases,close_case. The description is the one in[MentorAction], and the parameters become the tool's input schema.[MentorAction]is in theNovior.MentorAgent.Attributesnamespace;ICaseStoreandICaseQueuestand for your own data access.ReadOnly = truesays that an action only reads. MCP clients receive it with a read-only hint. A[MentorAction]that declares neitherReadOnly = truenorRequiresConfirmationcounts as a write that asks for confirmation, and the server withholds it.Turn the server on, register it and map the endpoint in
Program.cs:using Novior.MentorAgent.Extensions; // AddMentorAgent, AddMentorAgentMcp, MapMentorAgentMcp using Novior.MentorAgent.Options; // MentorOptions (complete configuration) using Microsoft.Extensions.AI; // AsIChatClient using OpenAI; // OpenAIClientbuilder.Services.AddScoped<CaseTools>(); builder.Services.AddMentorAgent(options => { options.AppName = "Case management"; // the MCP server's name options.ChatClient = new OpenAIClient(builder.Configuration["OpenAI:Key"] ?? throw new InvalidOperationException("Set OpenAI:Key with dotnet user-secrets.")) .GetChatClient("gpt-4o-mini") .AsIChatClient(); options.ScanAssemblies = [typeof(Program).Assembly]; options.McpServerEnabled = true; }); // Novior.MentorAgent.Server: registers the MCP server. AddMentorAgentServer() calls it too builder.Services.AddMentorAgentMcp(); // after var app = builder.Build(); app.MapMentorAgentMcp(); // /mcpAddMentorAgentMcp()registers the server whenMcpServerEnabledis on. Call it afterAddMentorAgent();AddMentorAgentServer()calls it for you. MentorAgent readsMcpServerEnabledwhenAddMentorAgentruns, so set it in that callback.The endpoint has no Cross-Origin Resource Sharing (CORS) policy. Desktop clients, editors and the proxy of the MCP Inspector are not browsers, so they need none. A client that runs in a browser needs the opt-in in the complete configuration.
Connect a client
Section titled “Connect a client”Run the app and point an MCP client that supports the Streamable HTTP transport at https://localhost:<port>/mcp, with the port your app listens on. Where you enter the address depends on the client.
- The client lists one tool,
get_case_status: the other two are withheld, as Security and roles explains. The server's name is yourAppName. - If the client refuses the ASP.NET Core development certificate, trust it with
dotnet dev-certs https --trust. - Once the endpoint requires authorization, as in the complete configuration, the client must send credentials your scheme accepts, usually an
Authorization: Bearerheader.
Complete configuration
Section titled “Complete configuration”A server for real clients: authentication, role-gated actions for the callers entitled to them and, for a client that runs in a browser, the origins you allow.
builder.Services.AddScoped<CaseTools>();
builder.Services.AddMentorAgent(options =>
{
options.AppName = "Case management";
options.AppDescription = "Support case management: case status, notes and closure";
options.ChatClient = new OpenAIClient(builder.Configuration["OpenAI:Key"]
?? throw new InvalidOperationException("Set OpenAI:Key with dotnet user-secrets."))
.GetChatClient("gpt-4o-mini")
.AsIChatClient();
options.ScanAssemblies = [typeof(Program).Assembly];
options.McpServerEnabled = true;
});
// Novior.MentorAgent.Server: registers the MCP server. AddMentorAgentServer() calls it too
builder.Services.AddMentorAgentMcp();
// Browsers may call /mcp only from these origins. Same name as MentorAgent's policy: yours is used
// A browser client also needs the session header
builder.Services.AddCors(cors => cors.AddPolicy("MentorAgentMcp", policy => policy
.WithOrigins("https://tools.example.com")
.AllowAnyHeader()
.AllowAnyMethod()
.WithExposedHeaders("Mcp-Session-Id")));
// Who is calling: role-gated actions are published to the callers that hold the role
builder.Services.AddHttpContextAccessor();
builder.Services.AddOptions<MentorOptions>()
.Configure<IHttpContextAccessor>((options, http) =>
options.McpCallerPrincipal = () => http.HttpContext?.User);
// Plus your authentication scheme (JWT bearer, API gateway): builder.Services.AddAuthentication()...
// after var app = builder.Build();
app.UseCors();
app.UseAuthentication();
app.UseAuthorization();
app.MapMentorAgentMcp(configure: e => e.RequireAuthorization().RequireCors("MentorAgentMcp"));CORS. The endpoint has no CORS policy, and a client that is not a browser needs none. A client that runs in a browser needs three things:
- call
UseCorsbeforeUseAuthenticationandUseAuthorization, and write those two calls yourself; - add
RequireCors("MentorAgentMcp")to the endpoint; - register a policy with that name, which lists your origins and exposes the
Mcp-Session-Idheader. You can register it before or afterAddMentorAgentMcp().
If you register no policy with that name, the one that
AddMentorAgentMcp()adds allows any origin.- call
Who may connect.
configure: e => e.RequireAuthorization()applies to the endpoint like any other convention, so a named policy works too. The authentication scheme is yours: Secure the endpoints shows JSON Web Token (JWT) bearer.Who is calling.
McpCallerPrincipalhands MentorAgent the request's user. MentorAgent asks it once per request and does not keep the answer for the next caller. Connecting and seeing a role-gated action are two separate decisions: the first belongs toRequireAuthorization, the second to this resolver. Roles are tested withClaimsPrincipal.IsInRole: see Security and roles.
What every call goes through
Section titled “What every call goes through”A call passes the same checks as a tool that the model calls.
- The result.
OnToolResultruns on the result before the client sees it. If the hook throws, the client gets anisErrorresult and not the data. - The limit. The call counts in the dashboard's top actions and against
RateLimitPerUser. MCP calls have an allowance of their own, apart from chat messages. - Who is counted. A caller is counted by its
NameIdentifier,suboroidclaim, or else by its name. A caller with none of them is counted by its remote address if you callAddHttpContextAccessor():AddMentorAgentServer()andAddMentorAgentA2A()already call it. Behind a proxy, apply the forwarded headers: otherwise every caller looks like the proxy. Without the accessor, all such callers share one allowance. - The log. Each call writes one Information line to the log: the tool, the outcome, the time and a fingerprint of the caller. The caller's id never appears.
Options that matter here
Section titled “Options that matter here”| Option | Why it matters for the MCP server |
|---|---|
McpServerEnabled | Turns the server on. AddMentorAgentMcp() then registers it and the MentorAgentMcp CORS policy, which no endpoint uses until you add RequireCors. Without the flag, MapMentorAgentMcp() stops the app at startup. MentorAgent reads it when AddMentorAgent runs |
AppName | The server name that MCP clients show. MentorAgent reads it when AddMentorAgent runs: set it in that callback |
McpServerPath | Where MapMentorAgentMcp() maps the endpoint when you pass no path. A path passed to the method wins |
McpCallerPrincipal | Resolves the caller, once per request. Roles are tested with IsInRole. Without it, every action with RequiredRoles is withheld |
RequiresApproval | An action it matches is withheld, like one with RequiresConfirmation |
RateLimitPerUser, RateLimitWindowSecs | Limit the MCP calls of each caller, in an allowance of their own. For a limit on the requests themselves, use ASP.NET Core rate limiting, as in Secure the endpoints |
MaxMessageLength, EnableSafetyCheck | No effect: an MCP call runs your method without a turn |
OnToolResult | Changes or redacts the result of every MCP call before the client sees it |
IsReadOnlyAction | Declares by tool name the actions that only read, like ReadOnly = true. MCP clients receive them with readOnlyHint. A [MentorAction] method that this option names does not ask for confirmation by default |
RequireConfirmationForWrites | On by default: a [MentorAction] that declares neither ReadOnly = true nor RequiresConfirmation asks for confirmation, so the server withholds it. With false, only RequiresConfirmation = true withholds an action |
McpServers | Not this page: it connects your agent to other MCP servers |
Every option is in Configuration options, and the MCP ones under MCP.
Security and roles
Section titled “Security and roles”An MCP request has no person on the other side to answer a confirmation, and no signed-in user unless you say who it is. So MentorAgent publishes only what it can check:
| Action | Without McpCallerPrincipal | With McpCallerPrincipal |
|---|---|---|
A read with no roles, such as get_case_status (ReadOnly = true) | Published | Published |
RequiredRoles, such as list_team_cases | Withheld | Published only to an authenticated caller holding one of the roles |
An action that asks for confirmation (RequiresConfirmation = true, a [MentorAction] that declares neither ReadOnly = true nor RequiresConfirmation, or a tool matched by RequiresApproval), such as close_case | Withheld | Withheld, always |
- Withheld means absent. The tool is not in the list, and a call by name runs nothing: the client gets
Unknown tool: '…'withisError, the same answer as for a name that never existed. The first time a client lists or calls the tools, a warning in the log names every withheld action. A write that declared nothing is named too, as(approval). - The endpoint is open until you protect it. Anyone who reaches the port can list and call every published tool. Each call runs your code, so treat it like a public API.
- Per-record checks stay in your methods. A role says who may call the action, not which records the caller may read: see What stays in your code.
- Roles are tested with
IsInRole. It reads the claim type that the identity names as itsRoleClaimType. A token with arolesclaim works when the identity says so. Microsoft.Identity.Web sets the identity up that way. With JWT bearer andMapInboundClaims = false, setTokenValidationParameters.RoleClaimTypetoroles. OtherwiseIsInRolelooks forClaimTypes.Role, and every role-gated action stays withheld. - Read-only actions. Declare an action that only reads with
[MentorAction(ReadOnly = true)], or by name with theIsReadOnlyActionoption. MCP clients receive it withreadOnlyHint. MentorAgent takes your word: it does not check that the method only reads. An action that needs a confirmation is never read-only, and the server does not publish it. Some MCP clients skip their own confirmation for a tool withreadOnlyHint. A write declared read-only by mistake can then run from them with nobody asked. - Writes ask by default. To publish a write, declare
RequiresConfirmation = false. Then any MCP client can run it with nobody asked: do it only when that is what you want. - A method with only
[Description]is a tool with no rules.RequiredRoles,RequiresConfirmationand the default that makes writes ask exist only on[MentorAction]. The server publishes such a method to every caller. Mark a method that changes data with[MentorAction]. Declare one that only reads withIsReadOnlyAction.
The same rules on the other channels are in Where nobody can answer.
Common errors
Section titled “Common errors”Every request to
/mcpanswers 500, and the log says that a middleware supporting CORS was not found. The endpoint hasRequireCors(...)and the pipeline has noapp.UseCors(). Add it beforeUseAuthentication()andUseAuthorization(), and write those two calls inProgram.csyourself. Or removeRequireCorsif no browser calls the endpoint.The app stops at startup with "Cannot map MCP endpoint: McpServerEnabled is false". Set
options.McpServerEnabled = trueinAddMentorAgent, or remove the call.The app stops at startup with "Cannot map the MCP endpoint: the MCP server is not registered". Nothing registered the server. Call
AddMentorAgentMcp()afterAddMentorAgent(), or callAddMentorAgentServer().The app stops at startup with "McpServerEnabled is true, but AddMentorAgentMcp() ran before AddMentorAgent()", or "… but it was false when AddMentorAgent() read it". The call or the option is in the wrong place. Call
AddMentorAgentMcp()afterAddMentorAgent(), and setMcpServerEnabledin theAddMentorAgentcallback: a laterConfigure<MentorOptions>or configuration binding does not reach it. See Troubleshooting.The build does not find
AddMentorAgentMcporMapMentorAgentMcp. Both methods are in theNovior.MentorAgent.Serverpackage. Add it to the project, as in Install the packages.The client lists fewer tools than your class has. The missing ones are gated: the warning in the log names them. A
[MentorAction]that changes data is gated unless it declaresRequiresConfirmation = false, and a read needsReadOnly = true. If the warning does not name a tool, there are four other causes:- The class has
[MentorAgent]: the server does not publish the methods of a specialist, and the warning does not name them. - The class is listed in the
Toolsof a team member. Those methods belong to the member, and an Information line in the log says so. - The method is inherited, static, or has an empty
[Description]. MentorAgent finds only public instance methods declared on the class itself. - The class is not registered in dependency injection, and the log says so with "Could not build MCP server tool".
- The class has
A call gets "Unknown tool: '…'. This server does not offer it". The server does not list that tool for this caller. Nothing runs, and the call is not logged as an error. There are four possible reasons:
- the action needs confirmation;
- the caller does not have the role that the action requires;
- the method belongs to a specialist (see When to choose it);
- the tool name does not exist.
If the client uses an old tool list, or its model guessed a name, list the tools again. If the tool is withheld, see the items on missing tools and on role-gated actions.
A call returns
isErrorwith "Rate limit reached: at most … tool calls every … s". The caller made more calls thanRateLimitPerUserallows withinRateLimitWindowSecsseconds. A call to an unknown tool counts too.A call returns
isErrorwith "The tool ran, but its result could not be returned". YourOnToolResulthook threw. The action did run, and the log has a warning with the exception.A role-gated action never appears, even for an administrator.
McpCallerPrincipalis not set, the request is not authenticated, or the roles are in a claim that the identity does not name as itsRoleClaimType, such as arolesclaim with JWT bearer andMapInboundClaims = false.A browser blocks every request to
/mcp. The endpoint has no CORS policy until you addRequireCors("MentorAgentMcp"). See the CORS item in the complete configuration.Errors about a
DbContextthat is disposed or used by two threads. The action class is shared by every call: injectIDbContextFactory<TContext>.The client gets
401. The endpoint requires authorization and the client does not send credentials your scheme accepts.
See also
Section titled “See also”- Channels: how clients reach the agent — Which channel fits your client: the Blazor or MAUI widget, SignalR or SSE for JavaScript and mobile apps, and MCP or A2A for other agents and AI tools.
- A2A server — Let other agents call your agent over the Agent-to-Agent (A2A) protocol: the agent card, authentication, and the actions that need confirmation.
- Secure the endpoints — Protect the SignalR hub, Server-Sent Events, /mcp and /a2a endpoints with authentication and CORS before you expose them.
- 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.
