Skip to content
Tracon

Connect and expose agents

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

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

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:

{
"endpoint": "https://mcp.example.com/mcp",
"headerConfigurationKeys": {
"Authorization": "Tracon:McpSecrets:ExampleToken",
"X-Api-Key": "Tracon:McpSecrets:ExampleKey"
}
}
Terminal window
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. 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.

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

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

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

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.

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

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

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.

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

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