Start Debugging

Specialist A2A Agents vs Distributed Skills over MCP: When to Collapse an Agent into a Skill

If a specialist agent is just a model loop wrapped around a few read-only calls into your own services, collapse it into a skill served over MCP: a SKILL.md plus typed tools that the parent model calls directly. Keep it an A2A agent when it needs its own model, private context, a long multi-step workflow or an independent lifecycle. Here is the decision matrix, the measured cost of each path, and the Agent Framework 1.17 wiring.

Short answer: collapse a specialist A2A agent into a skill over MCP when it is a model loop wrapped around bounded, mostly read-only operations that your parent agent could pick itself. Publish a SKILL.md and typed MCP tools from the same service, and let the parent’s model call the tools directly. Keep the specialist as an A2A agent when it needs its own model, private context it should not hand over, a long multi-step workflow, or a release cycle owned by another team. In Microsoft’s own ski-resort demo (Agent Framework Python agent-framework-core==1.17.0, ModelContextProtocol.AspNetCore 1.2.0, model gpt41), the collapse halved model calls (6-7 down to 3) and cut mean latency from 15.5 s to 6.3 s, but used about 22% more tokens. Faster is not the same as cheaper.

This question got concrete on September 16, 2026, when the Agent Framework team published From Specialist Agents to Distributed Skills over MCP by Tommaso Stocchi. It takes a typical multi-agent setup, an advisor that calls four specialist agents over A2A, and rewrites it so each specialist service becomes an MCP skill provider instead. The demo repository keeps both versions side by side at commit 56f453a, which makes it the cleanest before/after I have seen for this decision. Everything below is pinned to that commit.

The two shapes, on one domain

The domain is lift traffic at a ski resort. The data comes from a LiftDataService that calls an internal HTTP endpoint. What changes is who reasons about it.

Specialist A2A agent. The lift-traffic service hosts its own ChatClientAgent on gpt41, gives it the lift tools, and exposes it with an Agent Card over A2A. The advisor resolves the card and turns the remote agent into a function tool:

// Advisor side, Microsoft.Agents.AI + A2A .NET SDK (ski-resort-demo @ 56f453a)
var resolver = new A2ACardResolver(httpClient.BaseAddress!, httpClient,
    agentCardPath: "/.well-known/agent-card.json");
var agentCard = await resolver.GetAgentCardAsync();
AIAgent liftAgent = agentCard.AsAIAgent(httpClient);

var advisor = projectClient.AsAIAgent(
    model: deploymentName,
    instructions: "...You have access to four specialist agents as tools...",
    tools: [liftAgent.AsAIFunction(), weatherAgent.AsAIFunction() /* ... */]);

When the user asks “where should I start, considering weather and wait times?”, the advisor’s model calls liftAgent with a natural-language task. The lift agent’s model then decides which lift tools to call, calls them, and writes a prose answer. The advisor’s model reads that prose and writes its own answer. Two reasoning loops per specialist touched.

Distributed skill over MCP. The same service hosts an MCP endpoint instead. It publishes two resources (a discovery index and a SKILL.md) and the operations as typed tools:

// Provider side, ModelContextProtocol.AspNetCore 1.2.0, net10.0 (ski-resort-demo @ 56f453a)
builder.Services.AddSingleton<LiftDataService>();
builder.Services.AddMcpServer(o => o.ServerInfo = new() { Name = "lifttrafficskills", Version = "1.0.0" })
    .WithHttpTransport()
    .WithResources<LiftTrafficSkillResources>()   // skill://index.json + skill://lift-traffic/SKILL.md
    .WithTools<LiftTrafficTools>();                // typed, read-only operations

var app = builder.Build();
app.MapMcp("/skillsmcp");
[McpServerToolType]
public sealed class LiftTrafficTools(LiftDataService service)
{
    [McpServerTool(Name = "lift_traffic_least_busy_area", ReadOnly = true,
                   Destructive = false, UseStructuredContent = true)]
    [Description("Recommend the open lift with the shortest wait, or report that no lifts are open.")]
    public async Task<LeastBusyArea> LeastBusyArea(CancellationToken ct) =>
        Read<LeastBusyArea>(await service.SuggestLessBusyAreaAsync(ct));
    // ...lift_traffic_lifts, lift_traffic_lift_status, lift_traffic_wait_times
}

There is no model in this service anymore. The advisor’s model reads the skill description, loads the skill when it is relevant, and then calls lift_traffic_least_busy_area itself. One reasoning loop total.

The detail that makes the demo honest: in the A2A version, the specialist’s tool class is the same code. The A2A project’s LiftTrafficTools carries both AIFunction registrations for its own agent and [McpServerTool] attributes, with a comment noting there is “exactly one implementation per capability; only the hosting surface differs.” In other words, the specialist agent was a gpt41 call sitting between the advisor and four functions. That is the pattern to look for in your own system.

The decision matrix

QuestionSpecialist A2A agentSkill over MCP
Who picks the operations?The specialist’s modelThe parent’s model
Model calls per specialist touched2+ (specialist loop + parent reads its prose)0 extra (parent calls tools directly)
What crosses the wireNatural-language task in, artifact/prose outTyped tool arguments in, structured content out
Parent sees intermediate dataNo, only the specialist’s summaryYes, raw tool results land in the parent’s context
Specialist can use a different modelYesNo, the parent’s model does the reasoning
Private context / prompts stay with the ownerYes, opaque by designNo, SKILL.md instructions are read by the caller
Long, multi-step or long-running workflowsNatural fit (A2A tasks, streaming, push)Awkward; each step is a parent turn
Independent deploy and versioningAgent Card + endpointMCP server + skill index; tool schemas are the contract
Initial parent contextSmall (one function per agent)Skill summaries only, then full tool schemas after load_skill
DebuggingTwo transcripts to correlateOne transcript, one trace
Spec maturity (October 2026)A2A 1.0MCP tools/resources stable; skill discovery is a draft SEP

The A2A vs MCP comparison I wrote in July framed the axis as “structured tool vs opaque agent.” This post is the follow-up question: once you have a fleet of opaque agents, which of them should never have been opaque?

When to collapse the agent into a skill

Collapse it when all of the following are true:

A good smell test: open the specialist’s transcript for ten real requests. If it calls one or two tools and paraphrases the JSON every time, it is a skill wearing an agent costume.

When to keep it an A2A agent

Keep the agent when any of these hold:

One trap the Microsoft post calls out explicitly: wrapping an existing agent in an MCP tool without removing its internal model loop is only a transport change. You still pay for two reasoning loops, you just moved them behind tools/call. The collapse means deleting the specialist’s model, not re-exposing it.

What the numbers actually say

The article ran three paired requests, fresh conversations, same processes, gpt41, prompt “considering weather and waiting time, where should i start?”:

PairPathElapsedInput tokensOutput tokensTotalModel calls
1A2A16.416 s2,9715783,5496
1Skills over MCP8.661 s4,3411784,5193
2Skills over MCP5.866 s4,3371654,5023
2A2A12.835 s2,9745803,5546
3A2A17.188 s3,3946374,0317
3Skills over MCP4.517 s4,3411714,5123

Mean elapsed: 15.480 s for A2A, 6.348 s for skills. Total tokens across the three runs: 11,134 for A2A, 13,533 for skills.

Read the token columns carefully, because they explain the trade:

Whether this is cheaper for you depends on your price ratio. On most current models output tokens cost 4-5x input, so trading ~400 output tokens for ~1,300 input tokens can come out close to even or slightly ahead, and prompt caching of the stable SKILL.md and schemas tilts it further. The article recorded 6,144 cached input tokens on the skills path but could not collect complete cache data for A2A, so do not read the raw totals as a billing ratio. The authors say as much: three runs, live data that changed between requests, and A2A doing slightly more work (more lift-traffic calls) in some pairs. Treat it as a direction, then measure your own.

Also notice pair 1: the advisor’s top-level usage on the A2A path was 1,751 tokens, and the total only reached 3,549 once the specialists’ own calls were added. If your dashboards only meter the orchestrator, your A2A setup looks about half as expensive as it is.

Wiring the parent: progressive disclosure, then direct calls

On the consuming side, the demo’s Python advisor uses SkillsProvider fed by an MCPSkillsSource per provider session. The model starts each turn seeing only skill names and descriptions plus a load_skill helper:

# agent-framework>=1.17.0, mcp>=1.24.0 (ski-resort-demo @ 56f453a)
from agent_framework import (SkillsProvider, MCPSkillsSource,
                             CachingSkillsSource, AggregatingSkillsSource)

sources = [CachingSkillsSource(MCPSkillsSource(client=session)) for session in sessions]
skills_provider = SkillsProvider(
    AggregatingSkillsSource(sources),
    disable_load_skill_approval=True,
    disable_read_skill_resource_approval=True,
)
agent = chat_client.as_agent(
    instructions=INSTRUCTIONS,
    context_providers=[skills_provider],
    middleware=[skill_tools],   # binds a successful load_skill to that provider's tools
)
await skill_tools.initialize(agent, exit_stack)  # opens one MCPStreamableHTTPTool per provider

The interesting part is skill_tools, a FunctionMiddleware that runs after load_skill. When the load succeeds it calls the experimental context.add_tools(...) with that provider’s MCP tools, so they become callable on the next model iteration:

# Inside SkillToolsMiddleware.process, agent-framework 1.17.0
await call_next()
if context.function.name != "load_skill":
    return
skill, native = self.catalogs.get(context.arguments["skill_name"].lower())
# 1.17 returns raw SKILL.md text, not a typed success result,
# so compare against the cached content to detect a real load.
if context.result[0].text == await skill.get_content():
    context.add_tools(native.functions)

Two things are worth copying from this design. First, the SKILL.md itself says “loading is progressive disclosure, not authorization”: loading a skill only makes tools visible, and approvals and auth stay in code. Second, the approvals are disabled on purpose, because in 1.17 an approval pause starts a new function loop and drops the add_tools registrations. If you need human approval on these tools, you will hit that.

The middleware is optional. With a small catalog (a dozen tools), the article notes you can register every MCP tool up front and skip progressive disclosure entirely. The middleware pays off when you have many providers and do not want every schema in every turn, which is the same pressure described in reducing how many MCP tools an agent loads.

If your parent is .NET rather than Python, the client side is UseMcpSkills from Microsoft.Agents.AI.Mcp. I walked through that wiring, and the archive-skill limits that silently drop content, in serving agent skills from an MCP server in .NET. The two halves are independent: the demo’s providers are .NET and its skills advisor is Python.

Gotchas before you migrate

Skill discovery is not core MCP yet. The skill://index.json resource follows a pinned, older draft of SEP-2640. The September 3, 2026 revision of that proposal moves to dedicated skills/list and skills/get methods, and its PR was still open when the article was written. The demo does not implement those methods. Plan for one more change on the discovery side; tools and resources themselves are stable.

The Agent Card description becomes the skill description. Routing used to live in the Agent Card. After the move it lives in the description of the skill index entry, which is what the parent’s model sees before loading anything. Port it deliberately; a vague description means the skill never gets loaded.

Group operations per provider, not per tool. Loading a skill exposes all of that provider’s tool schemas. One skill per provider, with related operations together, keeps the unused-schema cost down. Splitting one domain across five tiny skills multiplies load calls instead.

Keep state and permissions in code. Pagination cursors, authorization and file lifecycle belong in tool handlers, not in SKILL.md prose. The parent’s model now calls your service directly, so input validation in the handler is the only gate. The demo’s tools mark themselves ReadOnly = true, Destructive = false and validate arguments (MinLength(1), ThrowIfNullOrWhiteSpace). If you are designing a larger surface, the MCP server design notes for a big internal API apply.

Migrate one read-only domain first. Keep the A2A path deployed behind a flag, run the same prompts through both, and compare model calls, latency and total tokens, including the specialists’ own usage. The article’s recommendation and mine agree: start with the most boring, read-oriented specialist.

The recommendation, restated

Default to skills over MCP for specialists that are thin model loops around your own read-mostly services. You get one reasoning loop, typed results, one trace and roughly half the model round trips. Keep A2A for specialists that bring something the parent cannot: a different model, private context, a long workflow or an independent owner. Most real systems end up with both, a coordinator holding a few skills and calling one or two genuine agents. The same split shows up in coding agents too, as the skills vs subagents vs MCP servers breakdown for Claude Code argues.

Sources

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.

< Back