Skip to content

feat(a2a-bridge): stdio MCP server making any MCP harness an A2A client over the mesh - #357

Merged
aojea merged 24 commits into
google:mainfrom
kaisoz:kaisoz/a2a-bridge
Sep 7, 2026
Merged

feat(a2a-bridge): stdio MCP server making any MCP harness an A2A client over the mesh#357
aojea merged 24 commits into
google:mainfrom
kaisoz:kaisoz/a2a-bridge

Conversation

@kaisoz

@kaisoz kaisoz commented Sep 4, 2026

Copy link
Copy Markdown
Collaborator

What

cmd/sam-a2a-bridge: a standalone stdio MCP server (own Go module — the root go.mod is untouched) that makes any MCP harness (Claude Code, Cursor, ...) an A2A client over the SAM mesh. Three tools — send_agent_task, get_agent_task, get_agent_card — backed by the a2a-go v2 client against the sidecar's raw egress path, with the caller-side labels gate (X-Sam-Required-Labels) enforced by the node on every call. Includes the agent skill doc (agents/skills/sam-a2a-bridge/) and improvements to the development/examples/chat-a2a example agent used to exercise it.

A2A feature coverage

# A2A feature Status Reason / trade-off
F1 message/send, plain text implemented
F2 DataPart input (structured JSON) implemented one data object per send; N data parts collapse into one object with no practical loss
F3 FilePart input (bytes) implemented file_path + optional file_name, 5 MB cap, MIME inferred; cap rejects before any network I/O
F6 tasks/get + polling implemented immediate-return semantics; the harness owns the polling loop
F7 Multi-turn (contextId) and replies into input-required tasks (taskId) implemented both ids optional on send_agent_task, echoed in every result
F8 Non-text results: DataParts implemented returned inline (data array), collected from message, status-message, and artifact parts alike
F9 Non-text results: FileParts implemented bytes are written to -download-dir (default ~/.sam/a2a-downloads) and returned as paths — inline base64 would flood the model's context. No auto-cleanup by design (results, not cache). URL parts pass through as strings
F10 Agent card implemented (trimmed) get_agent_card returns name/skills+examples/input+output modes/streaming only; security schemas and provider blurbs cost context and serve no model decision
F4 Multiple file attachments per message not implemented no workflow has needed it; workaround is same-context sends or an archive. Trade-off: "merge these 3 CSVs" needs cooperation from the agent. Will be will be tracked as a follow-up (file_paths array)
F5 File input by URL not implemented a caller-local URL is meaningless on the provider's machine; deferred until a cross-reachable store exists
F11 message/stream (SSE) not implemented streaming through the mesh's HTTP-over-libp2p path is unverified; the node advertises streaming: false in regenerated cards until it is. Trade-off: no incremental output, poll instead
F12 Push notifications (webhooks) not implemented the callback URL lives behind the caller's node; mesh addressing for it is an open design. Trade-off: no wake-on-done; polling covers
F13 tasks/cancel not implemented deferred until a real long-task workflow needs to kill a task (~15 lines when it does). Trade-off: runaway tasks run to completion
F14 metadata / protocol extensions not implemented (hidden) free-form fields only meaningful under out-of-band conventions; surfacing opaque vendor keys costs context for zero decision value. Five-line additive change when our own agents start using them
F15 gRPC transport not applicable the mesh carries HTTP-over-libp2p only; regenerated cards drop gRPC interfaces

Known limitation (send side is capped, receive side is not): response bodies, saved files, and inline data have no size bounds yet — will be tracked as a follow-up.

Why get_agent_card trims the card

The tool's output lands verbatim in the harness model's context and is re-read on every subsequent turn, so the trim rule is: a field survives only if the model would compose a different message because of it. Trimming is also plain token economy: every card field is paid for on every turn of the conversation that follows, so a lean card keeps the context window — and the inference bill — working on the task instead of on boilerplate. What the rule keeps: skills (id, name, description, tags, examples, per-skill input/output modes), the card-level default MIME modes, and the streaming flag — the material that answers what can this agent do, what can I send it, and how will it respond.

What it drops, and why:

Dropped field Why it can't change the model's behavior
securitySchemes, securityRequirements (card- and skill-level) The model never authenticates to anything: the bridge authenticates to the local sidecar with its token, and agent-side access control inside the mesh is biscuit/policy-based. A2A also has no per-skill invocation — skills are advertisements, not endpoints — so skill security is HTTP auth enforced by the agent, on a channel the bridge doesn't carry third-party credentials over. An OAuth flow description is instructions for a login that will never happen.
supportedInterfaces The bridge always dials the local mesh path; the agent's own transport URLs are unreachable and only invite the model to reason about endpoints it must not use.
signatures JWS signatures over the original card; the model can't verify them, and the node's card regeneration invalidates them anyway.
provider, documentationUrl, iconUrl Operator identity and links the model can't follow through the bridge — descriptive metadata with no bearing on message composition.
capabilities other than streaming Push notifications and extensions aren't implemented by the bridge; advertising them invites calls that can't work. streaming survives because "don't call message/stream" is a real decision.

Manual test matrix

Exercised end to end against two enrolled nodes (provider --labels region=eu) hosting the development/examples/chat-a2a agent, driven from Claude Code with the sam-a2a-bridge skill. Prompts as issued:

Test Feature Prompt Verified
T1 F10 "What can the agent chat on peer <peer> do? Check its card before we start." trimmed card, skills + modes, no security/provider material
T2 F1, F6 "Send the chat agent on <peer> the message 'hello, who are you?' and show me its answer." send + autonomous polling to a terminal state
T3 labels gate "...require the provider to be attested region=eu" / "Same question, but require region=us-east-1." EU accepted; US refused fail-closed with verbatim 403: Required labels not attested by provider, and the model reported the refusal instead of weakening labels
T4 F2 "Send the chat agent structured data — {"operation": "review", "priority": 3, ...} — plus the text 'process this payload', in one message." DataPart on the wire; agent's reply reflects the payload
T5 F3 "Attach ~/notes/test.txt and ask the chat agent to summarize the file." + oversized-file variant file summarized; >5 MB rejected before any request
T6 F7 "Ask the chat agent to think of an animal... then ask what the animal was." context_id threaded; conversation memory holds
T7 F7 "Ask the agent to book something ambiguous; when it asks a clarifying question, answer it." task pauses in input-required; answer routes by task_id+context_id; get_agent_task shows it flip to completed
T8 F8, F9 "Ask the agent to generate a CSV... and return it as a file." artifact saved under ~/.sam/a2a-downloads/<task>-...csv, path returned
T9 errors "Send 'hi' to service no-such-service on <peer>." sidecar error passed through verbatim as a tool error

The chat-a2a agent

The matrix was exercised with development/examples/chat-a2a (Gemini-backed chat agent), which was improved in this branch to support it: DataPart and file-attachment input, delivering generated files as A2A artifacts (return_file function call), signalling clarifying questions as the input-required task state via a typed ask_user function call rather than an in-band sentinel, opening the task before the first status update (runtime event-ordering requirement), and a card that honestly advertises its input/output MIME modes.

Testing

25 Go unit tests in the module (wire shapes measured against a2a-go v2.5.0, path-traversal and filename-sanitization cases included); go vet, gofmt, root make lint clean. The manual matrix above covers the live mesh paths unit tests cannot.

The chat agent's first event on a new task was a TaskStatusUpdateEvent,
which the a2a runtime rejects with "Agent should enqueue Task before
TaskStatusUpdateEvent event". Every ask_user turn failed before reaching
the caller; the return_file path only survived because its artifact event
created the task first.

Enqueue a submitted Task when the request carries none.
@kaisoz
kaisoz requested a review from aojea September 4, 2026 06:46
@google-cla

google-cla Bot commented Sep 4, 2026

Copy link
Copy Markdown

Thanks for your pull request! It looks like this may be your first contribution to a Google open source project. Before we can look at your pull request, you'll need to sign a Contributor License Agreement (CLA).

View this failed invocation of the CLA check for more information.

For the most up to date status, view the checks section at the bottom of the pull request.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces the 'sam-a2a-bridge' Go module, which serves as an MCP server bridging local sidecar communication with remote A2A agents on the SAM network, alongside updates to the Python chat-a2a example agent. The review feedback identifies several critical security and robustness improvements, including resolving a path traversal vulnerability and an infinite loop bug in file saving, ensuring only regular files are processed, preventing a potential nil pointer dereference in the HTTP transport, proactively creating user-supplied download directories, and safely handling missing filenames in the Python agent.

Comment thread cmd/sam-a2a-bridge/a2a.go
Comment thread cmd/sam-a2a-bridge/a2a.go
Comment on lines +50 to +58
func (t *samTransport) RoundTrip(req *http.Request) (*http.Response, error) {
req = req.Clone(req.Context())
if t.token != "" {
req.Header.Set(headerSamAuthentication, "Bearer "+t.token)
}
if t.requiredLabels != "" {
req.Header.Set(headerSamRequiredLabels, t.requiredLabels)
}
resp, err := t.base.RoundTrip(req)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

If t.base is nil, calling t.base.RoundTrip(req) will panic. It is safer to fall back to http.DefaultTransport if t.base is not provided.

func (t *samTransport) RoundTrip(req *http.Request) (*http.Response, error) {
	req = req.Clone(req.Context())
	if t.token != "" {
		req.Header.Set(headerSamAuthentication, "Bearer "+t.token)
	}
	if t.requiredLabels != "" {
		req.Header.Set(headerSamRequiredLabels, t.requiredLabels)
	}
	base := t.base
	if base == nil {
		base = http.DefaultTransport
	}
	resp, err := base.RoundTrip(req)

@kaisoz kaisoz Sep 4, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

samTransport is unexported and all three construction sites set base explicitly, so a nil here is a programming error we'd rather surface as a panic in tests than mask with a silent fallback.

Comment on lines +37 to +47
dir := *downloadDir
if dir == "" {
home, err := os.UserHomeDir()
if err != nil {
log.Fatal(err)
}
dir = filepath.Join(home, ".sam", "a2a-downloads")
if err := os.MkdirAll(dir, 0o755); err != nil {
log.Fatal(err)
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Currently, os.MkdirAll is only called if the default download directory is used. If a user provides a custom directory via -download-dir and it does not exist, the bridge will fail later when trying to save files. Proactively creating the directory regardless of whether it is the default or user-supplied makes the application more robust.

Suggested change
dir := *downloadDir
if dir == "" {
home, err := os.UserHomeDir()
if err != nil {
log.Fatal(err)
}
dir = filepath.Join(home, ".sam", "a2a-downloads")
if err := os.MkdirAll(dir, 0o755); err != nil {
log.Fatal(err)
}
}
dir := *downloadDir
if dir == "" {
home, err := os.UserHomeDir()
if err != nil {
log.Fatal(err)
}
dir = filepath.Join(home, ".sam", "a2a-downloads")
}
if err := os.MkdirAll(dir, 0o755); err != nil {
log.Fatal(err)
}

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A user-supplied path is used exactly as given and never auto-created (auto-creating can mask a typo'd path and scatter downloads). However, the bridge now exits at startup with a clear "create it first" error when the directory is missing, instead of failing on the first usage.

Comment thread development/examples/chat-a2a/agent.py Outdated
…iles

Sanitize the remote-minted task id like the filename before it names a
local path, return non-ENOENT stat errors instead of looping, reject
non-regular files on attach, and fail at startup when a user-supplied
-download-dir does not exist. Also default an empty incoming filename in
the chat-a2a example prompt.
Comment thread cmd/sam-a2a-bridge/a2a.go Outdated
@aojea
aojea merged commit c6d2326 into google:main Sep 7, 2026
18 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants