# 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.

- URL: https://docs.mentoragent.net/configure/mcp-server
- Verified with: MentorAgent 1.0.0
- Last updated: 2026-10-14
- Packages: Novior.MentorAgent.Server, Novior.MentorAgent

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

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 neither `ReadOnly = true` nor `RequiresConfirmation` counts 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](https://docs.mentoragent.net/configure/mcp-server#security-and-roles).
- **Only Level 1 actions.** The server publishes the methods of your [action classes](https://docs.mentoragent.net/concepts/agent-levels#level-1-actions). The methods of a [specialist](https://docs.mentoragent.net/concepts/agent-levels#level-2-specialists), 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 calls `AddMentorAgentServer()` and a Blazor Server app that calls `AddMentorAgentMcp()` map it the same way.

If the other agent should talk to your whole agent, with your model, specialists and documents, see [A2A server](https://docs.mentoragent.net/configure/a2a-server). To call MCP servers from your agent, see [MCP and A2A clients](https://docs.mentoragent.net/concepts/channels#mcp-and-a2a-clients).

## Install the packages

**.NET CLI**

```shell
dotnet add package Novior.MentorAgent.Server
```

**PackageReference**

```xml title="YourApp.csproj"
<ItemGroup>
  <PackageReference Include="Novior.MentorAgent.Server" Version="1.0.0" />
</ItemGroup>
```

**Central Package Management**

```xml title="Directory.Packages.props"
<ItemGroup>
  <PackageVersion Include="Novior.MentorAgent.Server" Version="1.0.0" />
</ItemGroup>
```

```xml title="YourApp.csproj"
<ItemGroup>
  <PackageReference Include="Novior.MentorAgent.Server" />
</ItemGroup>
```

**File-based app**

```csharp title="app.cs"
#:package Novior.MentorAgent.Server@1.0.0
```

`Novior.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:

```shell
dotnet add package Microsoft.Extensions.AI.OpenAI --version 10.6.0
```

For another provider, see [Model providers](https://docs.mentoragent.net/configure/model-providers#install-the-packages).

## Minimal configuration

1. Keep the model key out of the source code:

   ```shell
   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.

2. Write the actions as usual, in a class you register in dependency injection:

   ```csharp title="CaseTools.cs"
   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 the `Novior.MentorAgent.Attributes` namespace; `ICaseStore` and `ICaseQueue` stand for your own data access. `ReadOnly = true` says that an action only reads. MCP clients receive it with a read-only hint. A `[MentorAction]` that declares neither `ReadOnly = true` nor `RequiresConfirmation` counts as a write that asks for confirmation, and the server withholds it.

3. Turn the server on, register it and map the endpoint in `Program.cs`:

   ```csharp title="Program.cs"
   using Novior.MentorAgent.Extensions;   // AddMentorAgent, AddMentorAgentMcp, MapMentorAgentMcp
   using Novior.MentorAgent.Options;      // MentorOptions (complete configuration)
   using Microsoft.Extensions.AI;  // AsIChatClient
   using OpenAI;                   // OpenAIClient
   ```

   ```csharp title="Program.cs"
   builder.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();    // /mcp
   ```

   `AddMentorAgentMcp()` registers the server when `McpServerEnabled` is on. Call it after `AddMentorAgent()`; `AddMentorAgentServer()` calls it for you. MentorAgent reads `McpServerEnabled` when `AddMentorAgent` runs, 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](https://docs.mentoragent.net/configure/mcp-server#complete-configuration).

> **Important:**
> On this channel MentorAgent creates your action class once, for the whole application, whatever lifetime you register. Every call from every client reuses that instance. Keep action classes stateless and thread-safe. Inject `IDbContextFactory<TContext>`, not a `DbContext`, and create one context per call.

### 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](https://docs.mentoragent.net/configure/mcp-server#security-and-roles) explains. The server's name is your `AppName`.
- 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](https://docs.mentoragent.net/configure/mcp-server#complete-configuration), the client must send credentials your scheme accepts, usually an `Authorization: Bearer` header.

## Complete configuration

A server for real clients: authentication, [role-gated](https://docs.mentoragent.net/reference/glossary#gated) actions for the callers entitled to them and, for a client that runs in a browser, the origins you allow.

```csharp title="Program.cs"
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:
  1. call `UseCors` before `UseAuthentication` and `UseAuthorization`, and write those two calls yourself;
  2. add `RequireCors("MentorAgentMcp")` to the endpoint;
  3. register a policy with that name, which lists your origins and exposes the `Mcp-Session-Id` header. You can register it before or after `AddMentorAgentMcp()`.

  If you register no policy with that name, the one that `AddMentorAgentMcp()` adds allows any origin.
- **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](https://docs.mentoragent.net/configure/secure-endpoints) shows JSON Web Token (JWT) bearer.
- **Who is calling.** `McpCallerPrincipal` hands 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 to `RequireAuthorization`, the second to this resolver. Roles are tested with `ClaimsPrincipal.IsInRole`: see [Security and roles](https://docs.mentoragent.net/configure/mcp-server#security-and-roles).

### What every call goes through

A call passes the same checks as a tool that the model calls.

- **The result.** [`OnToolResult`](https://docs.mentoragent.net/reference/options#ontoolresult) runs on the result before the client sees it. If the hook throws, the client gets an `isError` result and not the data.
- **The limit.** The call counts in the dashboard's top actions and against [`RateLimitPerUser`](https://docs.mentoragent.net/reference/options#ratelimitperuser). MCP calls have an allowance of their own, apart from chat messages.
- **Who is counted.** A caller is counted by its `NameIdentifier`, `sub` or `oid` claim, or else by its name. A caller with none of them is counted by its remote address if you call `AddHttpContextAccessor()`: `AddMentorAgentServer()` and `AddMentorAgentA2A()` 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

| Option | Why it matters for the MCP server |
|---|---|
| [`McpServerEnabled`](https://docs.mentoragent.net/reference/options#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`](https://docs.mentoragent.net/reference/options#appname) | The server name that MCP clients show. MentorAgent reads it when `AddMentorAgent` runs: set it in that callback |
| [`McpServerPath`](https://docs.mentoragent.net/reference/options#mcpserverpath) | Where `MapMentorAgentMcp()` maps the endpoint when you pass no path. A path passed to the method wins |
| [`McpCallerPrincipal`](https://docs.mentoragent.net/reference/options#mcpcallerprincipal) | Resolves the caller, once per request. Roles are tested with `IsInRole`. Without it, every action with `RequiredRoles` is withheld |
| [`RequiresApproval`](https://docs.mentoragent.net/reference/options#requiresapproval) | An action it matches is withheld, like one with `RequiresConfirmation` |
| [`RateLimitPerUser`](https://docs.mentoragent.net/reference/options#ratelimitperuser), [`RateLimitWindowSecs`](https://docs.mentoragent.net/reference/options#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](https://docs.mentoragent.net/configure/secure-endpoints#complete-configuration) |
| [`MaxMessageLength`](https://docs.mentoragent.net/reference/options#maxmessagelength), [`EnableSafetyCheck`](https://docs.mentoragent.net/reference/options#enablesafetycheck) | No effect: an MCP call runs your method without a turn |
| [`OnToolResult`](https://docs.mentoragent.net/reference/options#ontoolresult) | Changes or redacts the result of every MCP call before the client sees it |
| [`IsReadOnlyAction`](https://docs.mentoragent.net/reference/options#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`](https://docs.mentoragent.net/reference/options#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`](https://docs.mentoragent.net/reference/options#mcpservers) | Not this page: it connects your agent to other MCP servers |

Every option is in [Configuration options](https://docs.mentoragent.net/reference/options), and the MCP ones under [MCP](https://docs.mentoragent.net/reference/options#mcp).

## 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: '…'` with `isError`, 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](https://docs.mentoragent.net/concepts/governance#what-stays-in-your-code).
- **Roles are tested with `IsInRole`.** It reads the claim type that the identity names as its `RoleClaimType`. A token with a `roles` claim works when the identity says so. Microsoft.Identity.Web sets the identity up that way. With JWT bearer and `MapInboundClaims = false`, set `TokenValidationParameters.RoleClaimType` to `roles`. Otherwise `IsInRole` looks for `ClaimTypes.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 the [`IsReadOnlyAction`](https://docs.mentoragent.net/reference/options#isreadonlyaction) option. MCP clients receive it with `readOnlyHint`. 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 with `readOnlyHint`. 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`, `RequiresConfirmation` and 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 with `IsReadOnlyAction`.

The same rules on the other channels are in [Where nobody can answer](https://docs.mentoragent.net/concepts/governance#where-nobody-can-answer).

## Common errors

- **Every request to `/mcp` answers 500, and the log says that a middleware supporting CORS was not found.** The endpoint has `RequireCors(...)` and the pipeline has no `app.UseCors()`. Add it before `UseAuthentication()` and `UseAuthorization()`, and write those two calls in `Program.cs` yourself. Or remove `RequireCors` if no browser calls the endpoint.
- **The app stops at startup with "Cannot map MCP endpoint: McpServerEnabled is false".** Set `options.McpServerEnabled = true` in `AddMentorAgent`, 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()` after `AddMentorAgent()`, or call `AddMentorAgentServer()`.
- **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()` after `AddMentorAgent()`, and set `McpServerEnabled` in the `AddMentorAgent` callback: a later `Configure<MentorOptions>` or configuration binding does not reach it. See [Troubleshooting](https://docs.mentoragent.net/get-started/troubleshooting#the-app-stops-at-startup-cannot-map-the-mcp-or-a2a-endpoint).
- **The build does not find `AddMentorAgentMcp` or `MapMentorAgentMcp`.** Both methods are in the `Novior.MentorAgent.Server` package. Add it to the project, as in [Install the packages](https://docs.mentoragent.net/configure/mcp-server#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 declares `RequiresConfirmation = false`, and a read needs `ReadOnly = 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 `Tools` of 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".
- **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](https://docs.mentoragent.net/configure/mcp-server#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 `isError` with "Rate limit reached: at most … tool calls every … s".** The caller made more calls than [`RateLimitPerUser`](https://docs.mentoragent.net/reference/options#ratelimitperuser) allows within [`RateLimitWindowSecs`](https://docs.mentoragent.net/reference/options#ratelimitwindowsecs) seconds. A call to an unknown tool counts too.
- **A call returns `isError` with "The tool ran, but its result could not be returned".** Your [`OnToolResult`](https://docs.mentoragent.net/reference/options#ontoolresult) hook threw. The action did run, and the log has a warning with the exception.
- **A role-gated action never appears, even for an administrator.** `McpCallerPrincipal` is not set, the request is not authenticated, or the roles are in a claim that the identity does not name as its `RoleClaimType`, such as a `roles` claim with JWT bearer and `MapInboundClaims = false`.
- **A browser blocks every request to `/mcp`.** The endpoint has no CORS policy until you add `RequireCors("MentorAgentMcp")`. See the CORS item in the [complete configuration](https://docs.mentoragent.net/configure/mcp-server#complete-configuration).
- **Errors about a `DbContext` that is disposed or used by two threads.** The action class is shared by every call: inject `IDbContextFactory<TContext>`.
- **The client gets `401`.** The endpoint requires authorization and the client does not send credentials your scheme accepts.

---

- Previous: [Any front end: React, Angular, Vue](https://docs.mentoragent.net/configure/any-front-end.md)
- Next: [A2A server](https://docs.mentoragent.net/configure/a2a-server.md)
