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

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

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

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](https://docs.mentoragent.net/configure/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:
  1. MentorAgent tells the model to call a tool first and to state only what a tool returned.
  2. 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](https://docs.mentoragent.net/reference/attributes#mentorskill) do not count as such a tool.
  3. If the second reply returns no data either, the [classifier model](https://docs.mentoragent.net/reference/glossary#classifier) 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](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 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:

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

2. Write the actions as usual. These are the same three as on the [MCP server](https://docs.mentoragent.net/configure/mcp-server) page:

   ```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);
   }
   ```

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

   ```csharp title="Program.cs"
   using Novior.MentorAgent.Extensions;   // AddMentorAgent, AddMentorAgentA2A, MapMentorAgentA2A
   using Novior.MentorAgent.Models;       // AgentCardInfo
   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 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.json
   ```

   `AddMentorAgentA2A()` registers the A2A agent when `A2AServerEnabled` is on. Call it after `AddMentorAgent()`; `AddMentorAgentServer()` calls it for you. MentorAgent reads `A2AServerEnabled` when `AddMentorAgent` runs, so set it in that callback.

Run the app and open `/.well-known/agent-card.json`. The card has:

- your `AppName` and `AppDescription`;
- 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](https://docs.mentoragent.net/reference/attributes#mentorskill).

The card still says `/a2a`, a bare path. An agent on another host needs the full URL: the [complete configuration](https://docs.mentoragent.net/configure/a2a-server#complete-configuration) sets it.

## Complete configuration

Set the public URL of the task endpoint in the configuration of each environment:

```json
{
  "A2A": {
    "PublicUrl": "https://cases.example.com/a2a"
  }
}
```

Then describe the card, require authentication and limit each calling agent:

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

- **`A2AServerUrl`** is the full public URL of the task endpoint, path included, and the card publishes it as it is. If you map the endpoint elsewhere, with `MapMentorAgentA2A("/agent")` or `A2AServerPath`, the URL must end with that path.
- **`AgentCard`** fills the card's version, provider, icon and documentation URL. `Tags` is not published: the A2A card has no field for it.
- **`configure`** applies to both endpoints, the task handler and the card. A MentorAgent caller sends its credentials in the [`Headers`](https://docs.mentoragent.net/reference/options#mentorremoteagentheaders) of its `RemoteAgents` entry, with the card request and with every task.
- **`AddMentorAgentA2A()`** registers the agent, and passes the authenticated caller of `/a2a` to MentorAgent, in any host: a backend or a Blazor Server app. `RequiredRoles` and `RateLimitPerUser` then work for the calling agent. `AddMentorAgentServer()` calls it for you.

### Options that matter here

| Option | Why it matters for the A2A server |
|---|---|
| [`A2AServerEnabled`](https://docs.mentoragent.net/reference/options#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`](https://docs.mentoragent.net/reference/options#appname), [`AppDescription`](https://docs.mentoragent.net/reference/options#appdescription) | The card's name and description: what an orchestrator chooses your agent by |
| [`A2AServerUrl`](https://docs.mentoragent.net/reference/options#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`](https://docs.mentoragent.net/reference/options#a2aserverpath) | Where the task endpoint is mapped when you pass no path. The card stays at `/.well-known/agent-card.json` |
| [`AgentCard`](https://docs.mentoragent.net/reference/options#agentcard) | Version, provider, icon and documentation URL on the card |
| [`EnableImageInput`](https://docs.mentoragent.net/reference/options#enableimageinput) | Adds `image/png` to the card's input modes |
| [`RateLimitPerUser`](https://docs.mentoragent.net/reference/options#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`](https://docs.mentoragent.net/reference/options#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`](https://docs.mentoragent.net/reference/options#classifierchatclient) | Also judges whether a reply written without data states something about your records |

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

## 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 declares `RequiresConfirmation = 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](https://docs.mentoragent.net/configure/mcp-server#security-and-roles) 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](https://docs.mentoragent.net/reference/glossary#circuit). To add claims or roles for a task, see [Secure the endpoints](https://docs.mentoragent.net/configure/secure-endpoints#security-and-roles). A task whose caller is not authenticated is anonymous, and its [role-gated](https://docs.mentoragent.net/reference/glossary#gated) actions are refused.
- **The endpoints are open until you protect them.** `/a2a` runs a paid turn for whoever reaches it, and the card describes your application to anyone. [Secure the endpoints](https://docs.mentoragent.net/configure/secure-endpoints) protects them together with the others.
- **Access is decided here, not by the caller.** A MentorAgent host that lists your agent in its `RemoteAgents` does 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 `/a2a` and 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](https://docs.mentoragent.net/concepts/governance#what-stays-in-your-code).

How the other channels handle confirmations is in [Where nobody can answer](https://docs.mentoragent.net/concepts/governance#where-nobody-can-answer).

## Common errors

- **The app stops at startup with "Cannot map A2A endpoint: A2AServerEnabled is false".** Set `options.A2AServerEnabled = true` in `AddMentorAgent`, 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()` after `AddMentorAgent()`, or call `AddMentorAgentServer()`.
- **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()` after `AddMentorAgent()`, and set `A2AServerEnabled` 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 `AddMentorAgentA2A` or `MapMentorAgentA2A`.** Both methods are in the `Novior.MentorAgent.Server` package. Add it to the project.
- **A calling agent fails before it sends anything, or sends tasks to the wrong address.** `A2AServerUrl` is 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 is `UriFormatException`; on Linux and macOS the bare path reads as a `file:` 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 in `ScanAssemblies`. A `[MentorAction]` that declares neither `ReadOnly = true` nor `RequiresConfirmation` asks 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()`, call `UseAuthentication()` and `UseAuthorization()` before the map call, and check that the roles are in the claim that the identity names as its `RoleClaimType`.
- **The caller gets `401` on the card.** `configure` protects the card as well as the task endpoint: the caller must send its credentials for both.

---

- Previous: [MCP server](https://docs.mentoragent.net/configure/mcp-server.md)
- Next: [Secure the endpoints](https://docs.mentoragent.net/configure/secure-endpoints.md)
