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.
On this page
The A2A server lets other agents find your agent and call it with the Agent-to-Agent (A2A) protocol. It publishes an agent card, a JSON document that describes your agent, at /.well-known/agent-card.json. Other agents send their requests, called tasks, to /a2a. MentorAgent runs each task as a full turn, with your model and your actions.
When to choose it
Section titled “When to choose it”Choose it when another program should send questions to your agent in natural language: another MentorAgent host, any other A2A agent, or an orchestrator (a program that splits a job among several agents).
The whole agent, not single methods. A task goes through your model, prompt, specialists, teams and documents, and costs a turn on your model. To publish single methods for a client whose own model chooses, see MCP server.
An answer from tools, or a refusal. The caller is a program that cannot tell a looked-up figure from an invented one. So on a task:
- MentorAgent tells the model to call a tool first and to state only what a tool returned.
- If the reply uses no tool that returns data, MentorAgent discards it and asks the model again, this time requiring a tool call. A navigation, a UI action, a memory write and a MentorAgent skill do not count as such a tool.
- If the second reply returns no data either, the classifier model judges whether it states something about your records, such as a figure or that an entry does not exist. If it does, the caller gets
NOT GROUNDED: …instead.
A reply that states no data, such as a policy read from a MentorAgent skill or your documents, is returned as it is.
One reply per task. The caller gets the complete reply in one message when the turn ends, not a stream of tokens. A turn that produces no reply closes the task as failed.
No onward delegation. A task that arrives over A2A is never passed to this host's own remote agents, so two hosts can call each other without looping. A chain of three MentorAgent hosts does not work: point the first at the third directly.
Any kind of host can serve tasks, such as a Blazor Server app or a backend. To call other A2A agents 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 A2A SDK for ASP.NET Core (1.0.0-preview2) with it. Add it to a Blazor Server app too: you do not need the hub for A2A. 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>Write the actions as usual. These are the same three as on the MCP server page:
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); }Turn the server on, register it and map the endpoints in
Program.cs:using Novior.MentorAgent.Extensions; // AddMentorAgent, AddMentorAgentA2A, MapMentorAgentA2A using Novior.MentorAgent.Models; // AgentCardInfo using Microsoft.Extensions.AI; // AsIChatClient using OpenAI; // OpenAIClientbuilder.Services.AddScoped<CaseTools>(); builder.Services.AddMentorAgent(options => { options.AppName = "Case management"; // the agent card's name 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.A2AServerEnabled = true; }); // Novior.MentorAgent.Server: registers the A2A agent. AddMentorAgentServer() calls it too builder.Services.AddMentorAgentA2A(); // after var app = builder.Build(); app.MapMentorAgentA2A(); // POST /a2a and GET /.well-known/agent-card.jsonAddMentorAgentA2A()registers the A2A agent whenA2AServerEnabledis on. Call it afterAddMentorAgent();AddMentorAgentServer()calls it for you. MentorAgent readsA2AServerEnabledwhenAddMentorAgentruns, so set it in that callback.
Run the app and open /.well-known/agent-card.json. The card has:
- your
AppNameandAppDescription; - one A2A skill for each action that has no role and does not ask for confirmation, here
get_case_status; - the task endpoint, with the JSON-RPC binding;
- text as input and output, and the streaming capability.
The card states the streaming capability, but the reply still arrives as one message when the turn ends. An A2A skill is an entry that tells callers what your agent can do: it is not a MentorAgent skill.
The card still says /a2a, a bare path. An agent on another host needs the full URL: the complete configuration sets it.
Complete configuration
Section titled “Complete configuration”Set the public URL of the task endpoint in the configuration of each environment:
{
"A2A": {
"PublicUrl": "https://cases.example.com/a2a"
}
}Then describe the card, require authentication and limit each calling agent:
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.A2AServerEnabled = true;
// The public URL of the task endpoint, path included: the card publishes it as it is
options.A2AServerUrl = builder.Configuration["A2A:PublicUrl"];
options.AgentCard = new AgentCardInfo
{
Version = "1.2.0",
Provider = "Support operations",
DocumentationUrl = "https://cases.example.com/docs/agent",
};
// Every task is a full turn with the model: a limit per calling agent
options.RateLimitPerUser = 30;
});
// Registers the agent, and passes the authenticated caller of each task to MentorAgent,
// for RequiredRoles and RateLimitPerUser
builder.Services.AddMentorAgentA2A();
// Plus your authentication scheme for the calling agents: builder.Services.AddAuthentication()...
// after var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.MapMentorAgentA2A(configure: e => e.RequireAuthorization()); // the task endpoint and the cardA2AServerUrlis the full public URL of the task endpoint, path included, and the card publishes it as it is. If you map the endpoint elsewhere, withMapMentorAgentA2A("/agent")orA2AServerPath, the URL must end with that path.AgentCardfills the card's version, provider, icon and documentation URL.Tagsis not published: the A2A card has no field for it.configureapplies to both endpoints, the task handler and the card. A MentorAgent caller sends its credentials in theHeadersof itsRemoteAgentsentry, with the card request and with every task.AddMentorAgentA2A()registers the agent, and passes the authenticated caller of/a2ato MentorAgent, in any host: a backend or a Blazor Server app.RequiredRolesandRateLimitPerUserthen work for the calling agent.AddMentorAgentServer()calls it for you.
Options that matter here
Section titled “Options that matter here”| Option | Why it matters for the A2A server |
|---|---|
A2AServerEnabled | Turns the agent on. AddMentorAgentA2A() then registers the task handler. Without the flag, MapMentorAgentA2A() stops the app at startup. MentorAgent reads it when AddMentorAgent runs |
AppName, AppDescription | The card's name and description: what an orchestrator chooses your agent by |
A2AServerUrl | The full public URL of the task endpoint. Without it the card has only the path, which clients on another host cannot follow |
A2AServerPath | Where the task endpoint is mapped when you pass no path. The card stays at /.well-known/agent-card.json |
AgentCard | Version, provider, icon and documentation URL on the card |
EnableImageInput | Adds image/png to the card's input modes |
RateLimitPerUser | Counted per calling agent only when the request is authenticated: the user id is the NameIdentifier claim, else sub, oid or the name. Otherwise the task is anonymous. With the default AnonymousIdentity, every task starts a new count. With Shared, all anonymous callers spend one allowance |
RequireConfirmationForWrites | On by default: a [MentorAction] that declares neither ReadOnly = true nor RequiresConfirmation asks for confirmation, so the card leaves it out and a task is refused. With false, only RequiresConfirmation = true does |
ClassifierChatClient | Also judges whether a reply written without data states something about your records |
Every option is in Configuration options, and the A2A ones under A2A.
Security and roles
Section titled “Security and roles”A task comes from a program: nobody on the other side can answer a confirmation. The card and the task follow the same rules:
| Action | On the card | If a task reaches it |
|---|---|---|
A read with no roles, such as get_case_status (ReadOnly = true) | Listed as an A2A skill | Runs |
RequiredRoles, such as list_team_cases | Left out | Runs only for an authenticated caller who holds one of the roles. The endpoint must authenticate the request: otherwise the caller is anonymous and the action is refused |
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 | Left out | Refused at once |
- Writes ask by default. A
[MentorAction]that changes data is listed and runs for a task only if it declaresRequiresConfirmation = false. A method with only[Description]has no rules: the card lists it and every caller can run it. Mark a method that changes data with[MentorAction]. - How MentorAgent knows the caller. The MCP server asks your
McpCallerPrincipal. The A2A server reads the user of the request, in every host, a Blazor Server app included. MentorAgent reads that user before it asks Blazor's own provider, which works only inside a circuit. To add claims or roles for a task, see Secure the endpoints. A task whose caller is not authenticated is anonymous, and its role-gated actions are refused. - The endpoints are open until you protect them.
/a2aruns a paid turn for whoever reaches it, and the card describes your application to anyone. Secure the endpoints protects them together with the others. - Access is decided here, not by the caller. A MentorAgent host that lists your agent in its
RemoteAgentsdoes not check roles before it sends you a request: any of its users can reach you. What a task may do depends on the authentication on/a2aand on the roles of your actions. - Each task starts clean. A task runs in its own scope, with a new coordinator and a new conversation: nothing carries over from earlier tasks. It has no browser, no current page and no UI actions.
- Per-record checks stay in your methods, as on every channel: see What stays in your code.
How the other channels handle confirmations is in Where nobody can answer.
Common errors
Section titled “Common errors”- The app stops at startup with "Cannot map A2A endpoint: A2AServerEnabled is false". Set
options.A2AServerEnabled = trueinAddMentorAgent, or remove the call. - The app stops at startup with "Cannot map the A2A endpoint: the A2A server is not registered". Nothing registered the agent. Call
AddMentorAgentA2A()afterAddMentorAgent(), or callAddMentorAgentServer(). - The app stops at startup with "A2AServerEnabled is true, but AddMentorAgentA2A() ran before AddMentorAgent()", or "… but it was false when AddMentorAgent() read it". The call or the option is in the wrong place. Call
AddMentorAgentA2A()afterAddMentorAgent(), and setA2AServerEnabledin theAddMentorAgentcallback: a laterConfigure<MentorOptions>or configuration binding does not reach it. See Troubleshooting. - The build does not find
AddMentorAgentA2AorMapMentorAgentA2A. Both methods are in theNovior.MentorAgent.Serverpackage. Add it to the project. - A calling agent fails before it sends anything, or sends tasks to the wrong address.
A2AServerUrlis not set, or it lacks the path. The card then carries only the path: a MentorAgent caller resolves it against your host, but clients such as the A2A SDK's cannot. On Windows their error isUriFormatException; on Linux and macOS the bare path reads as afile:URI, which an HTTP client refuses. - The card lists no A2A skills. One of these causes: every action has roles or asks for confirmation; the class has
[MentorAgent]or is a team member's toolbox; or its assembly is not inScanAssemblies. A[MentorAction]that declares neitherReadOnly = truenorRequiresConfirmationasks for confirmation. - The caller receives "NOT GROUNDED: …". Two attempts ended without a tool that returns data, and the reply stated something about your records. Give the agent an action that returns that data.
- The task fails with "this agent could not produce a reply to the request". The turn produced no text. The cause is in your log, just before the warning about the task.
- A role-gated action is refused for a caller that holds the role. MentorAgent sees an anonymous caller. Require authentication on the endpoint with
configure: e => e.RequireAuthorization(), callUseAuthentication()andUseAuthorization()before the map call, and check that the roles are in the claim that the identity names as itsRoleClaimType. - The caller gets
401on the card.configureprotects the card as well as the task endpoint: the caller must send its credentials for both.
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.
- 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.
- 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.
