# Connect and expose agents

> Tracon documentation. Tracon is a .NET package family that adds a control
> plane on Microsoft Agent Framework, published as NuGet packages that run
> inside your own ASP.NET Core application.
>
> Page: https://tracon.dev/guides/external-agents/
> Summary: Consume MCP tools and context, or publish selected Tracon agents through MCP and A2A with explicit security boundaries.

Tracon supports three different external-agent directions. Keep them separate:

| Direction | Purpose | Registration | HTTP surface |
|---|---|---|---|
| MCP client | Bring remote tools, prompts, and resources into Tracon | `UseMcp()` | Managed through `/api/mcp-servers/*` |
| MCP server | Publish a Tracon agent as an MCP tool | `UseMcpServer()` | `/tracon/mcp` by default |
| A2A server | Publish an agent through the agent-to-agent protocol | `UseA2A()` | `/tracon/a2a/{agent}` by default |

```mermaid
flowchart LR
    accTitle: The three external-agent directions
    accDescr: As an MCP client Tracon calls remote servers to gain tools. As an MCP server and as an A2A server Tracon is called by outside callers, and each of those directions has its own allowlist, budget, and credential requirement.
    subgraph Inbound["Who can call your agents"]
        MCPC["MCP caller"] --> MCPS["UseMcpServer<br/>allowlist · run budget · ExternalInvoke key"]
        A2AC["A2A caller"] --> A2AS["UseA2A<br/>one agent card per exposed agent"]
    end
    MCPS --> AGENT["Your agent"]
    A2AS --> AGENT
    AGENT --> CLIENT["UseMcp<br/>discovery, refreshed on an interval"]
    CLIENT --> REMOTE["Remote MCP server<br/>tools · prompts · resources"]
```

The first direction expands what your agents can call. The other two expand who can
call your agents. They have different trust boundaries and must be enabled separately.

## Consume a remote MCP server

Add the non-AOT `Tracon.Mcp` package and register discovery:

```csharp
var tracon = builder.AddTracon()
    .UseOpenAI(builder.Configuration.GetSection(OpenAIProviderOptions.SectionName))
    .UsePostgreSql(connectionString)
    .UseMcp(options =>
    {
        options.RefreshInterval = TimeSpan.FromMinutes(5);
        options.ConnectionTimeout = TimeSpan.FromSeconds(30);
        options.MaxToolsPerServer = 100;
    });
```

Create server records through the console or management API. Only the configuration
key name is persisted; the credential value stays in your secret provider:

```json
{
  "endpoint": "https://mcp.example.com/mcp",
  "headerConfigurationKeys": {
    "Authorization": "Tracon:McpSecrets:ExampleToken",
    "X-Api-Key": "Tracon:McpSecrets:ExampleKey"
  }
}
```

```bash
dotnet user-secrets set "Tracon:McpSecrets:ExampleToken" "Bearer ..."
dotnet user-secrets set "Tracon:McpSecrets:ExampleKey" "..."
```

Each key name sits under `Tracon:McpSecrets:`, and a tenant other than the default one
uses `Tracon:McpSecrets:<tenant>:`. A credential in plain `headers` is refused with
`400` — see [credential headers](/getting-started/security/#credential-headers-are-declared-by-key-name).
The value is read on every connection, but a changed value does not reconnect by
itself: call the refresh endpoint below after rotating it.

Discovery is asynchronous and does not block startup. Refresh immediately after a
configuration change with `POST {prefix}/api/mcp-servers/refresh`. A server that
cannot be reached produces a warning; other servers continue. If the server was
listed before and its definition is unchanged, its last tool list is kept for
`StaleToolRetention` (15 minutes) after that listing, so one network blip does not
stop the agents that use it. After that the tools drop out, and an agent that names
one is refused until the server lists them again. A server that is edited, deleted
or disabled drops its tools at once.

An agent compiled against a tool also follows what the server says about it: when
a refresh lists a changed description or input schema, the next run compiles again
and the model sees the new definition.

### The MCP client security boundary

- Only HTTP and HTTPS transports are accepted. Tracon does not start MCP `stdio`
  processes on the host.
- Discovered tool names are `{server}_{tool}`. A code-defined tool with the same name
  wins, so a remote server cannot replace it.
- New MCP tools require approval by default.
- Discovery is tenant-scoped and limited to 100 tools per server by default.
- OAuth tokens live in memory. Plan for reauthorization after a process restart.

An agent can also receive static MCP resources at run start through
`AgentDefinition.McpResourceUris`. Each entry is `{server}:{uri}`. The limits are
64 KB per resource and 256 KB total. `UseMcp()` is required. This field is currently
code/HTTP-only; the console editor does not preserve it.

## Publish agents as MCP tools

Choose the allowlist before the application is built, then map the management API
before the MCP endpoint:

```csharp
var tracon = builder.AddTracon()
    .UseOpenAI(builder.Configuration.GetSection(OpenAIProviderOptions.SectionName))
    .UsePostgreSql(connectionString)
    .UseMcpServer(options =>
    {
        options.ExposedAgents.Add("support");
        options.ToolNamePrefix = "acme";
        options.Budget = new AgentRunBudget
        {
            MaxDepth = 1,
            MaxTotalRuns = 4,
            MaxTotalTokens = 40_000,
        };
    });

var app = builder.Build();

app.MapTracon("/tracon", options =>
{
    options.AllowRemoteAccess = true;
    options.RequireRolePolicies = true;
});
app.MapTraconMcpServer();
```

No agent is exposed by default. `ExposeAllAgents` exists, but an allowlist is safer
for a catalog that operators can edit. Each MCP call creates a fresh run budget; the
default maximum depth is one, so an external caller cannot open an unbounded agent
tree.

The endpoint inherits the loopback, bearer, and authorization settings from
`MapTracon`. Remote exposure also requires at least one active, unexpired API key
with the exact `ExternalInvoke` scope. A static bearer token alone is rejected at
startup. Create the key before enabling remote access.

An exposed agent cannot contain an approval-required tool. A tool needs approval when its
registration sets `RequiresApproval`, or when the function is already wrapped in
`ApprovalRequiredAIFunction` — the default of Microsoft Agent Framework's
`DockerShellExecutor.AsAIFunction()`. The application waits
until the catalog is queryable, checks the selected agents, and prevents MCP requests
from running if an approval boundary would be crossed. An external protocol caller
cannot act as the missing human. A tool that gains approval after the agent was exposed
is refused at the call, and the run closes `Failed` with the error type
`approval_unanswerable`: nobody can ever answer it, so it does not wait.

The skill tools follow the same rule. They are not in the tool registry: Microsoft Agent
Framework adds them when it builds the agent, and it makes all three ask for approval. An
exposed agent that names skills must set `Skills.DisableReadApproval` on its definition
(see [Approval of the skill tools](/concepts/tools/#approval-of-the-skill-tools)). Without it,
the host refuses to open the agent and the message names the agent and the setting. With it,
`load_skill` and `read_skill_resource` run. `run_skill_script` keeps its approval, so the check does
not count it; a model that calls it ends the run with `approval_unanswerable`, unless a standing
approval rule covers the tool.

The file tools of a [session workspace](/guides/context-and-memory/#give-an-agent-files-in-each-session)
are not in the registry either. The tools that change a file always ask for approval, so an exposed
agent may have a workspace only if it is read-only and asks for no approval to read:
`Workspace.ReadOnly` and `Workspace.DisableReadApproval` together. Any other workspace on an
exposed agent stops the host from opening it, and the message names the agent and the settings.

The check reads the catalog once, when the host starts, with no request in scope. An agent that you
save through the API after that is not checked, and neither is an agent that a callable agent
reaches. The run-time refusal is the boundary for those agents.

### Long-running calls as MCP tasks

By default, a `tools/call` for a published agent holds the connection open until the
agent finishes. A client that speaks the `2026-07-28` revision gets the response
headers together with the result, not before it, so a proxy that times out while it
waits for the first byte cuts a long call. Set `EnableTasks` to serve long-running calls
as MCP tasks instead: a task-aware client gets an immediate task id back, closes the connection, and polls
`tasks/get` for the result — useful when an agent's response can take longer than a
client is willing to keep a connection open.

```csharp
.UseMcpServer(options =>
{
    options.ExposedAgents.Add("support");
    options.EnableTasks = true;
    options.TaskTimeToLive = TimeSpan.FromHours(2);
    options.TaskPollInterval = TimeSpan.FromSeconds(5);
});
```

`EnableTasks` is `false` by default: a client that never asks for the tasks
capability keeps getting today's immediate response either way. The task id is the
same id the run itself uses, so a task also shows up as an ordinary run in the
management API and cost reports. An agent that ends up needing approval never
surfaces as a task waiting on input — it is reported as a completed task carrying
the same rejection message the synchronous path returns, for the same reason an
approval-required tool cannot be exposed in the first place: an external protocol
caller cannot act as the missing human.

## Publish agents through A2A

A2A exposes a distinct identity and agent card for each selected agent:

```csharp
var tracon = builder.AddTracon()
    .UseOpenAI(builder.Configuration.GetSection(OpenAIProviderOptions.SectionName))
    .UsePostgreSql(connectionString)
    .UseA2A(options =>
    {
        options.ExposedAgents.Add("support");
        options.Budget = new AgentRunBudget
        {
            MaxDepth = 1,
            MaxTotalTokens = 40_000,
        };
    });

var app = builder.Build();

app.MapTracon("/tracon", options =>
{
    options.AllowRemoteAccess = true;
    options.RequireRolePolicies = true;
});
app.MapTraconA2A();
```

The invocation URL is `/tracon/a2a/support`; its card is under
`/tracon/a2a/support/.well-known/agent-card.json`.

A2A names are frozen during service registration because the underlying hosting API
registers one server per name. The agent implementation is still resolved from the
catalog on every call, so updating a database definition changes later behavior, but
adding a new name requires an application restart and registration change. There is
no expose-all switch.

Tracon declares streaming, push notifications, and background A2A runs as
unsupported. The same `ExternalInvoke`, approval, tenant, and budget boundaries as
the MCP server apply.

### A2A conversations

A message that carries a `contextId` continues a conversation. Tracon stores the
session of each conversation, so the agent sees the earlier turns of the same
`contextId`. A message without a `contextId` is a single turn: the response carries
a new `contextId` that the caller can send next.

A conversation belongs to the caller. Tracon stores it under the tenant, the agent,
the caller, and the `contextId`. A `contextId` is a handle, not a credential: a
caller that sends the `contextId` of another caller starts a new conversation and
cannot read or extend the first one.

| The request carries | The caller is |
|---|---|
| A signed-in user | That user. The user is read the way the audit trail reads its actor, so `Tracon:Audit:ActorClaimType` applies |
| An API key | That key |
| The static `AuthToken` | The installation |
| Nothing (loopback, no token) | The local operator. All such requests share one conversation space |

A signed-in user whose identity cannot be read is refused. Tracon does not move that
user into the local operator's space.

Keep these effects in mind:

- **Cost.** Each turn sends the whole history to the model, so the tokens per message
  grow with the conversation. Send a new `contextId` to start over.
- **Storage.** Every message Tracon answers leaves a session, also a message with no
  `contextId`: Tracon makes one up and the reply carries it. Session retention is off by
  default because a session is user data. Set a maximum age for the `sessions` target to
  bound the table; see [Retention defaults](/reference/configuration/#retention-defaults).
- **Parallel messages.** Two messages on one `contextId` at the same time cannot both
  be stored. One succeeds. The other fails and the caller receives a JSON-RPC
  internal error (`-32603`). Send the messages one after the other.
- **Refusals.** A refusal reaches the caller as the same opaque `-32603` error. The
  cause is in the server log at `Error` level.
- **Session ownership.** When session ownership is on and
  `RequireAuthenticatedOwner` is `true`, a message whose identity Tracon cannot
  attribute is refused **before** the model is called, and so is a message that resumes
  a conversation another user owns. This matters when one API key speaks for several
  users: the key is one caller, the owner is the end user. Bind `IRunAttributionContext`
  to the identity pipeline, or an A2A caller cannot open a conversation.
- **A turn that needs an approval.** If a tool that needs approval reaches an exposed
  agent after startup, the turn ends with the same error and stays in the conversation;
  the call is recorded as declined, and the next message goes through.
- **The console.** A conversation is a session. The Sessions page lists it under the
  exposed agent, with an identity that starts with `a2a:`.

To store conversations somewhere else, register an `AgentSessionStore` keyed by the
agent name, or without a key; Tracon uses yours. To name the caller differently,
register an `AgentIsolationKeyProvider`. Both types come from Microsoft Agent
Framework and are marked experimental there. A store of your own receives the caller
as the `isolation` partition of the key and must keep the callers apart.

Both surfaces are configured in code, never from the console:
`TraconMcpServerOptions` for the MCP server and `TraconA2AOptions` for A2A.
They do not carry the same settings.

`ExposeAllAgents` and `ToolNamePrefix` are **MCP-server settings only**:
`ExposeAllAgents` opts out of naming published agents one by one, and
`ToolNamePrefix` keeps the published tool names from colliding with another
server's. A2A has neither — its names are fixed at registration, which is why
there is no expose-all switch for it.

`Budget`, which bounds what an external caller may spend, is on both.

## Production checklist

- [ ] Expose only names whose input contract is safe for another system.
- [ ] Expect token use to grow with each turn of a `contextId`; decide how a caller starts a new conversation.
- [ ] Create a tenant-bound `ExternalInvoke` key and test revocation and expiry.
- [ ] Keep approval-required and high-impact tools out of exposed definitions.
- [ ] Set `Skills.DisableReadApproval` on every exposed agent that names skills, and keep script
      skills off exposed agents.
- [ ] Give an exposed agent no session workspace, or a read-only one with `Workspace.DisableReadApproval`.
- [ ] Set token, depth, and child-run budgets for externally initiated work.
- [ ] Terminate HTTPS at a trusted proxy and preserve the request path.
- [ ] Alert on external run error rate, token use, latency, and rejected credentials.
- [ ] Test catalog availability when migrations are applied outside the process.

MCP server and A2A routes are protocol surfaces, not management endpoints. They do
not appear in the generated OpenAPI operation count. Their runs still use the normal
recording, tenancy, trace, cost, quota, and audit infrastructure.

## Read next

- [Tools, skills, and MCP](/concepts/tools/) — the other direction: consuming an MCP server rather than publishing one
- [Securing the endpoints](/getting-started/security/) — bind exposed agents to authentication, tenant scopes, and execution limits
- [Compatibility matrices](/reference/compatibility/) — which protocol revisions and transports are supported
