MentorAgent Docs
Menu

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

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.

View as Markdown
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.

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.
  • 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 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. To call MCP servers from your agent, see MCP and A2A clients.

.NET CLI

Terminal
dotnet add package Novior.MentorAgent.Server

PackageReference

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

Central Package Management

Directory.Packages.props
<ItemGroup>
  <PackageVersion Include="Novior.MentorAgent.Server" Version="1.0.0" />
</ItemGroup>
YourApp.csproj
<ItemGroup>
  <PackageReference Include="Novior.MentorAgent.Server" />
</ItemGroup>

File-based app

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:

Terminal
dotnet add package Microsoft.Extensions.AI.OpenAI --version 10.6.0

For another provider, see Model providers.

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

    Terminal
    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:

    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:

    Program.cs
    using Novior.MentorAgent.Extensions;   // AddMentorAgent, AddMentorAgentMcp, MapMentorAgentMcp
    using Novior.MentorAgent.Options;      // MentorOptions (complete configuration)
    using Microsoft.Extensions.AI;  // AsIChatClient
    using OpenAI;                   // OpenAIClient
    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.

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 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, the client must send credentials your scheme accepts, usually an Authorization: Bearer header.

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.

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

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

  • The result. 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. 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.
OptionWhy it matters for the MCP server
McpServerEnabledTurns 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
AppNameThe server name that MCP clients show. MentorAgent reads it when AddMentorAgent runs: set it in that callback
McpServerPathWhere MapMentorAgentMcp() maps the endpoint when you pass no path. A path passed to the method wins
McpCallerPrincipalResolves the caller, once per request. Roles are tested with IsInRole. Without it, every action with RequiredRoles is withheld
RequiresApprovalAn action it matches is withheld, like one with RequiresConfirmation
RateLimitPerUser, RateLimitWindowSecsLimit 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, EnableSafetyCheckNo effect: an MCP call runs your method without a turn
OnToolResultChanges or redacts the result of every MCP call before the client sees it
IsReadOnlyActionDeclares 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
RequireConfirmationForWritesOn 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
McpServersNot this page: it connects your agent to other MCP servers

Every option is in Configuration options, and the MCP ones under MCP.

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:

ActionWithout McpCallerPrincipalWith McpCallerPrincipal
A read with no roles, such as get_case_status (ReadOnly = true)PublishedPublished
RequiredRoles, such as list_team_casesWithheldPublished 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_caseWithheldWithheld, 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.
  • 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 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.

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

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

  • 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);
    • 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 allows within 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 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.

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

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