A practical distinction between capability access and agent collaboration
One of the fastest ways to get lost in agent architecture is to treat every protocol as if it solves the same problem.
MCP and A2A-style collaboration sit close enough in the conversation that people often compress them into one mental bucket: “agent protocols.” That bucket is convenient, but it hides the design decision that actually matters.
Some problems are about connecting an agent to capabilities. Other problems are about coordinating work between autonomous participants.
Those are not the same problem.
Capability Access Is Not Coordination
When you connect an agent to a tool, the main question is: how does the model discover, describe, and call an external capability safely? The engineering work is about schemas, permissions, result shapes, resources, and execution boundaries.
When you coordinate agents, the main question is: how do multiple workers share intent, delegate tasks, exchange progress, and complete a larger workflow without collapsing into one giant controller? The engineering work is about roles, state, handoffs, deadlines, and accountability.
That difference gives a simple rule of thumb.
Use MCP thinking when the agent needs a disciplined tool surface.
Use A2A-style thinking when autonomous workers need to collaborate across roles or responsibilities.
That rule is useful, but it can still sound abstract. The difference becomes clearer when you turn it into a design classifier.
from __future__ import annotations
from dataclasses import dataclass
from enum import Enum
class Surface(str, Enum):
TOOL_CALL = "tool_call"
AGENT_HANDOFF = "agent_handoff"
@dataclass(frozen=True)
class WorkItem:
name: str
expected_minutes: int
needs_state: bool
transfers_responsibility: bool
needs_human_review: bool
deterministic_result: bool
def choose_surface(item: WorkItem) -> Surface:
collaboration_signals = sum(
(
item.expected_minutes > 5,
item.needs_state,
item.transfers_responsibility,
item.needs_human_review,
not item.deterministic_result,
)
)
if collaboration_signals >= 2:
return Surface.AGENT_HANDOFF
return Surface.TOOL_CALL
document_search = WorkItem(
name="search documents",
expected_minutes=1,
needs_state=False,
transfers_responsibility=False,
needs_human_review=False,
deterministic_result=True,
)
proposal_review = WorkItem(
name="review manuscript proposal",
expected_minutes=45,
needs_state=True,
transfers_responsibility=True,
needs_human_review=True,
deterministic_result=False,
)
assert choose_surface(document_search) == Surface.TOOL_CALL
assert choose_surface(proposal_review) == Surface.AGENT_HANDOFF
This little classifier is not a universal law. It is a forcing function. If the work is short, deterministic, stateless, and owned by a single system, a tool surface is probably enough. If the work takes time, carries state, transfers responsibility, or needs review, you are probably designing collaboration.
The practical mistake is to ignore those signals because a central agent can technically call one more tool. It can, until the workflow becomes long enough that the central agent becomes a state manager, scheduler, reviewer, and incident coordinator by accident.
The Research Assistant And The Publishing Workflow
Imagine a research assistant. It needs to search documents, read a calendar, summarize a transcript, and draft a response. Those capabilities can be exposed as tools. The assistant is not negotiating with a calendar agent. It is calling a bounded capability with an input and an output. MCP-style tool exposure fits that shape well.
Now imagine a publishing workflow. One worker qualifies a manuscript, another compares adjacent books, another prepares a proposal packet, another monitors submission status. The problem is no longer only tool access. It is delegation and coordination. Each participant owns a different slice of work. The system needs a way to represent handoffs, status, and responsibility. That is where A2A-style architecture becomes more useful.
The common mistake is to solve a coordination problem with only tool calls.
At first, it works. A central agent calls one tool after another. Then the workflow grows. More steps appear. Some steps take hours. Some require manual review. Some fail and need a retry. Some need a different specialist. Soon the central agent is not an assistant anymore. It is a fragile orchestrator with too much state and too little accountability.
The opposite mistake also happens: teams turn every capability into an agent.
That adds ceremony where a simple tool would have been clearer. If the task is deterministic, bounded, and owned by one service, an agent wrapper can make the system harder to test. Not every function deserves autonomy. Some things should remain boring.
MCP-Shaped Capability Access
In an MCP-shaped design, the key asset is the capability contract. The caller should know what the tool does, what input shape it accepts, what permissions it requires, and what result shape comes back. The result can be rich, but the ownership is still simple: caller asks, tool executes, caller receives.
Here is a minimal Python shape for that kind of capability. It is not a full MCP server. It is the core contract you want before exposing anything through MCP or any other tool protocol.
from dataclasses import dataclass
@dataclass(frozen=True)
class DocumentSearchRequest:
query: str
max_results: int = 5
@dataclass(frozen=True)
class SearchResult:
title: str
snippet: str
class DocumentIndex:
def __init__(self, documents: dict[str, str]) -> None:
self._documents = documents
def search(self, request: DocumentSearchRequest) -> tuple[SearchResult, ...]:
if not request.query.strip():
raise ValueError("query is required")
if request.max_results < 1 or request.max_results > 20:
raise ValueError("max_results must be between 1 and 20")
query = request.query.lower()
matches = [
SearchResult(title=title, snippet=body[:120])
for title, body in self._documents.items()
if query in body.lower()
]
return tuple(matches[: request.max_results])
This belongs on the tool side of the decision. The operation is bounded. The result is immediate. The caller does not transfer ownership of a larger task. If no documents match, the tool returns no matches. It does not become an autonomous participant in a research project.
A2A-Shaped Responsibility Transfer
Now compare that with a manuscript review workflow. The work has lifecycle, ownership, status, and handoff. One participant may perform market analysis. Another may review code. Another may prepare the publisher-specific proposal. The system needs to know who owns the next step, not only what function returned.
from dataclasses import dataclass, replace
from enum import Enum
from uuid import uuid4
class TaskStatus(str, Enum):
OPEN = "open"
IN_PROGRESS = "in_progress"
WAITING_FOR_REVIEW = "waiting_for_review"
DONE = "done"
@dataclass(frozen=True)
class Participant:
name: str
role: str
@dataclass(frozen=True)
class Task:
task_id: str
title: str
owner: Participant
status: TaskStatus
history: tuple[str, ...] = ()
def handoff(task: Task, new_owner: Participant, reason: str) -> Task:
event = f"handoff:{task.owner.name}->{new_owner.name}:{reason}"
return replace(
task,
owner=new_owner,
status=TaskStatus.IN_PROGRESS,
history=task.history + (event,),
)
def request_review(task: Task, reviewer: Participant) -> Task:
event = f"review-requested:{reviewer.name}"
return replace(
task,
owner=reviewer,
status=TaskStatus.WAITING_FOR_REVIEW,
history=task.history + (event,),
)
analyst = Participant("MarketAnalyzer", "market research")
reviewer = Participant("PublishingCoordinator", "proposal review")
task = Task(str(uuid4()), "Evaluate manuscript fit", analyst, TaskStatus.OPEN)
task = handoff(task, reviewer, "market notes complete")
task = request_review(task, reviewer)
assert task.owner == reviewer
assert task.status == TaskStatus.WAITING_FOR_REVIEW
assert len(task.history) == 2
This belongs on the collaboration side. The important output is not just a payload. It is changed responsibility. Someone now owns the task. The state moved. The history records why. That is the kind of information a longer agent workflow needs if it will survive failure, delay, or manual review.
Two Questions Make The Design Concrete
The senior engineering move is to separate capability access from participant coordination.
For each piece of work, ask two questions.
First: does this component expose a capability, or does it own a role in a multi-step workflow?
Second: does the caller need a result, or does the caller need another participant to take responsibility?
If the caller needs a result from a bounded capability, design a tool surface. If the caller needs responsibility transfer, design a collaboration surface.
That distinction changes how you test the system. Tool tests focus on schemas, error handling, idempotency, and permissions. Collaboration tests focus on state transitions, handoff completeness, timeout behavior, and human review gates.
It also changes how you observe the system. Tool observability asks, “What was called, with what input, how long did it take, and what result category came back?” Coordination observability asks, “Who owns the work now, why did it move, what is blocked, and what decision is pending?”
The test strategy should mirror the architecture. A tool test asks whether the capability behaves correctly. A collaboration test asks whether responsibility moves correctly.
def test_document_search_stays_a_tool() -> None:
index = DocumentIndex({"mcp": "MCP exposes tools and resources to AI applications."})
result = index.search(DocumentSearchRequest("tools"))
assert len(result) == 1
assert result[0].title == "mcp"
assert choose_surface(document_search) == Surface.TOOL_CALL
def test_manuscript_review_becomes_a_handoff() -> None:
first_owner = Participant("ProposalWriter", "draft proposal")
next_owner = Participant("PublisherScout", "publisher fit")
task = Task("T-1", "Prepare book proposal", first_owner, TaskStatus.OPEN)
updated = handoff(task, next_owner, "proposal draft ready")
assert updated.owner == next_owner
assert updated.status == TaskStatus.IN_PROGRESS
assert "proposal draft ready" in updated.history[-1]
assert choose_surface(proposal_review) == Surface.AGENT_HANDOFF
This is where teams often discover that their architecture diagrams are lying. If the tests are all function-call tests, the system probably does not have real collaboration semantics yet. If the tests can assert task ownership, lifecycle, deadlines, review gates, and handoff history, the system has started to model work instead of only modeling calls.
When The Central Agent Becomes A Fragile Orchestrator
The strongest smell that you need collaboration is a central agent prompt that keeps growing operational memory. It remembers which worker should do what, which stage is next, which reviewer must approve, which timeout matters, and which result should be retried. At that point the prompt is doing the work of a workflow model.
That does not mean you need a heavy orchestration platform on day one. It means you should move state out of the prompt and into explicit structures. Start with a task model. Add ownership. Add status. Add history. Add review gates. Once those become stable, you can decide whether to implement them with a queue, durable workflow runtime, A2A-style protocol surface, or a small internal coordinator service.
The tool surface answers: “What capability can I invoke?”
The collaboration surface answers: “Who owns the work now?”
Confusing those questions is how architectures become impressive demos and exhausting operations.
What To Try Next
MCP and A2A-style collaboration are more useful when they are not forced to compete. They answer different questions in the same architecture.
The cleanest systems use both ideas without confusing them. Tools give agents disciplined access to capabilities. Collaboration gives agents a way to divide work without losing the thread.
That is the mental model I wish more Python engineers started with: tool access is not delegation, and delegation is not just a longer tool call.
If you want to build this distinction instead of just read about it, my Udemy course MCP and A2A in Python walks through MCP servers, clients, tool integrations, and A2A-style workflows with Python examples.
Start here: https://www.udemy.com/course/mcp-and-a2a-in-python/?referralCode=72A9CCA8CE738C3E023E
MCP vs A2A: Tools, Agents, and Where Each Protocol Belongs was originally published in Level Up Coding on Medium, where people are continuing the conversation by highlighting and responding to this story.