Compare commits

..

19 Commits

Author SHA1 Message Date
Asim Aslam 9d306dcfc1 Update README with overview and community sections
govulncheck / govulncheck (push) Waiting to run
Harness (E2E) / Harnesses (mock LLM) (push) Waiting to run
Harness (E2E) / Provider harnesses (live LLM conformance) (push) Waiting to run
Lint / golangci-lint (push) Waiting to run
Run Tests / Unit Tests (push) Waiting to run
Run Tests / Etcd Integration Tests (push) Waiting to run
Deploy Jekyll with GitHub Pages dependencies preinstalled / build (push) Waiting to run
Deploy Jekyll with GitHub Pages dependencies preinstalled / deploy (push) Blocked by required conditions
Added an overview section and restructured community information in the README.
2026-07-20 11:03:53 +01:00
Asim Aslam db4401d306 client/service: rename the in-process fast-path to Local + service knob (#4855)
goreleaser / goreleaser (push) Waiting to run
Renames the fast-path option to the cleaner Local across the stack (it's
still unreleased) and adds a service-level knob:

- client.Local() (was client.LocalDispatch) enables the in-process
  fast-path; Options.Local is the field. The internal/network package it
  dispatches through is the "local network".
- service.Local() (aliased micro.Local()) turns it on for a whole
  service's client in one place — every co-located unary call (agent tool
  calls, flow dispatch, gateway -> service) takes the fast-path, no
  per-call wiring. Same o.Client.Init(...) pattern the Broker option uses.

Off by default; a no-op for distributed deployments since the fast-path
falls back to the network for anything not co-located.


Claude-Session: https://claude.ai/code/session_01CmdEY7pYmV5zzwCjNJ4ykL

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-15 17:02:57 +01:00
Asim Aslam 08a3edcff4 client/server: in-process dispatch fast-path (opt-in) (#4854)
When caller and callee run in the same process, a unary Call pays the
full network tax — pool.Get, dial, codec-over-socket, and the transport
pump — even though the handler table is right there. This adds an opt-in
fast-path that dispatches directly.

- internal/network: a neutral registry (transport.Message in/out) so
  client and server wire up without importing each other. A running server
  registers a dispatcher under its name on Start, deregisters on Stop.
- server: localDispatch serves a request in-process through the same
  router (identical wrappers/codecs/error mapping) over an in-memory
  socket — no dial, no pipe, no gob.
- client: LocalDispatch() opt-in. In call(), a unary request whose body and
  response are raw frames (codec/bytes.Frame — the agent/MCP/flow shape)
  dispatches locally; everything else falls back to the network path
  unchanged.

Correctness test proves the fast-path returns byte-identical replies to
the network path; benchmark shows ~545µs -> ~28µs (~20x) and ~3.6x fewer
allocations. Off by default. Covers #4817 (path b); the zero-copy typed
path remains a follow-up.


Claude-Session: https://claude.ai/code/session_01CmdEY7pYmV5zzwCjNJ4ykL

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-15 16:41:10 +01:00
Asim Aslam 1250d33f86 deploy/kubernetes: dependency-light reconcile core (alpha) (#4853)
Adds Reconcile(desired, observed) — the pure decision an operator's
reconcile loop runs: given a desired Agent/Service/Flow resource and the
observed cluster state, it returns the one action to converge (create /
update / noop) plus Ready/Error status conditions.

No controller-runtime, no client-go: the decision is a pure function of
desired + observed, so it's fully unit-testable without a cluster. A
future operator binary supplies Observed from the live cluster and applies
the Action; only that adapter needs the Kubernetes client — keeping the
heavy dependency out of the core module.

Covers #4842 (Option B). Tests: create-when-absent, noop-when-matched-and-
ready, update-on-drift, progressing-when-under-replicated, error-on-invalid
-spec.


Claude-Session: https://claude.ai/code/session_01CmdEY7pYmV5zzwCjNJ4ykL

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-15 15:12:20 +01:00
Asim Aslam 7e2346b8c8 flow: human-in-the-loop pause/resume (durable workflow, stage A) (#4852)
Adds a waiting run state so a flow step can suspend for external input
and resume durably — stage A of the durable-agentic-workflow design in
#4816.

- flow.Await(key, prompt) / flow.AwaitStep(...): a StepFunc that suspends
  the run. runFrom recognizes the signal, checkpoints the run with status
  "waiting" (recording what it awaits), and returns cleanly — a suspend is
  not a failure, and it is not retried or graded.
- Flow.ResumeWith(ctx, runID, input): completes the awaited step with the
  injected input (which becomes that step's output state) and continues
  from the next step.
- Flow.Waiting(ctx): lists suspended runs with their Await metadata.
- ResumePending/Pending skip waiting runs — they need input, not a
  restart. Existing crash-resume (Resume) is unchanged.

Additive: no signature or default-behavior changes. Await ergonomics
(sentinel-return) are the default proposed in #4816; open to AwaitStep-kind
instead if preferred.


Claude-Session: https://claude.ai/code/session_01CmdEY7pYmV5zzwCjNJ4ykL

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-15 14:08:27 +01:00
Asim Aslam 6733d0c7c4 a2a: verify inbound AP2 mandates into the paid path (opt-in) (#4851)
The AP2 primitives (checkout/payment mandates, Ed25519 sign/verify, the
x402 rail reference, attach-to-message) already existed, but the gateway
only *carried* mandates on the resulting task — it never verified them, so
ap2Verifications was never populated and a downstream paid path had no
trust signal.

Wire opt-in verification: set Options.AP2PublicKey (gateway) or
a2a.WithAP2PublicKey (embedded handler) and each mandate carried on a task
is verified (signature + task/context binding) with the result recorded in
task.AP2Verifications; the x402 settlement rail rides along for the paid
path. Off by default — mandates stay carried-but-unverified — so no payment
trust decision enters the default flow.

Adds a gateway integration test driving a real message/send that carries a
signed x402 payment mandate (verified, rail carried; tampered → surfaced as
unverified) plus a default-path test proving carry-only is unchanged.


Claude-Session: https://claude.ai/code/session_01CmdEY7pYmV5zzwCjNJ4ykL

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-15 13:10:00 +01:00
Asim Aslam bbeb3ac920 a2a: guard push-notification callbacks against SSRF (#4849)
The A2A gateway's push-notification flow (tasks/pushNotificationConfig/set
→ deliverPush) POSTed task state to a caller-supplied URL via the default
HTTP client, so an untrusted A2A caller could aim the gateway at internal
addresses (loopback, link-local cloud metadata, RFC1918) it would
otherwise never reach — a server-side request forgery vector (#4129).

Add a default SSRF-safe policy: only http/https callbacks whose host does
not resolve to a loopback, private, link-local, multicast, or unspecified
address. It's enforced when the config is set (caller gets a clear
rejection, nothing stored) and again at delivery, and the delivery client
re-checks the resolved IP at dial time so a name that passes validation
can't be rebound to an internal address before connect.

Operators that need a trusted in-cluster receiver set Options.AllowPushURL
(gateway) or a2a.WithPushURLPolicy (embedded handlers) to own the policy;
that path skips the built-in private-IP dial guard by design.

Tests cover blocked/allowed URLs, the dial-time guard, set-time rejection,
default-deny delivery, and the operator override.


Claude-Session: https://claude.ai/code/session_01CmdEY7pYmV5zzwCjNJ4ykL

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-15 12:20:36 +01:00
Asim Aslam c5962944a4 docs: add Go Micro vs Dapr comparison (#4850)
Co-authored-by: Codex <codex@openai.com>
2026-07-15 11:45:08 +01:00
Asim Aslam aaa03f89e3 deploy/kubernetes: embed CRDs instead of duplicating them (#4845)
goreleaser / goreleaser (push) Waiting to run
The CRD manifests were kept in two places — real YAML under config/crd/
(for kubectl apply) and byte-identical const strings in manifests.go
(for the Go CRDManifests map) — which will silently drift.

Make config/crd/*.yaml the single source of truth and go:embed it;
CRDManifests now reads the embedded bytes. Drops ~120 lines of
duplicated YAML, no behavior change (still stdlib-only, tests unchanged).


Claude-Session: https://claude.ai/code/session_01CmdEY7pYmV5zzwCjNJ4ykL

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-12 16:53:53 +01:00
Asim Aslam 1f5ae1f39a loop: pause automatic runs while we do focused fixes (#4843)
Comment out the automatic triggers on every loop workflow so the
autonomous engine stops firing on its own while we land the current
round of fixes 1:1:

- planner / builder / coherence / security / release: drop the cron
  schedules (no more hourly/daily/weekly runs, no nightly auto-release).
- triage: drop the workflow_run trigger so CI failures no longer
  auto-dispatch agent tasks.

Each keeps workflow_dispatch, so any loop can still be run on demand,
and re-enabling is just uncommenting the trigger. No prompts, tokens, or
logic changed — only when the workflows fire.


Claude-Session: https://claude.ai/code/session_01CmdEY7pYmV5zzwCjNJ4ykL

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-12 13:29:33 +01:00
Asim Aslam 6950870dd9 a2a: conform to external A2A clients — well-known path + spec SSE events (#4832)
* a2a: conform to external A2A clients — well-known path + spec SSE events

The A2A gateway interoperated go-micro-to-go-micro but a real external
client (ADK, LangGraph, a2a-SDK) would not:

- Discovery: served the Agent Card at /.well-known/agent.json, but A2A
  0.3.0 discovers it at /.well-known/agent-card.json. Serve both, with
  agent-card.json canonical and agent.json a legacy alias — per-agent,
  per-skill, and at the single-agent top level.

- message/stream emitted repeated full Task snapshots. External SSE
  clients parse by `kind` and stop on `final:true`; a Task snapshot has
  neither, so they never terminate. Emit spec-shaped TaskArtifactUpdate
  (append) chunks and close with a TaskStatusUpdate final:true. The
  non-streaming and resubscribe paths also close with a terminal marker.

- A streaming error set both `result` and `error` in one JSON-RPC
  response (strict clients reject it). Emit a failed status-update
  instead — never result and error together.

Tests assert the canonical card path, the status-update/artifact-update
event shapes ending in final:true, and that no response carries both
result and error.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CmdEY7pYmV5zzwCjNJ4ykL

* harness: update a2a-streaming to the spec-shaped stream events

The A2A gateway now emits artifact-update deltas and a terminal
status-update (final:true) instead of repeated full Task snapshots, so
the conformance harness must reassemble the answer from the append
artifact-update chunks and assert the final:true marker. This makes the
harness a stronger spec check rather than a snapshot-shape check.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CmdEY7pYmV5zzwCjNJ4ykL

* agent: update a2a stream test to spec-shaped events

TestA2AStreamUsesAgentChatPathWithTools decoded the last SSE event as a
completed Task snapshot with artifacts. The gateway now closes the stream
with a status-update (final:true) and carries the answer as append
artifact-update deltas, so reassemble the answer from those deltas and
assert the terminal completed status-update instead.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CmdEY7pYmV5zzwCjNJ4ykL

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-12 13:27:27 +01:00
Asim Aslam 36f80386f1 loop: refresh priorities after shipped capability (#4844)
Co-authored-by: Codex <codex@openai.com>
2026-07-12 13:27:18 +01:00
Asim Aslam b5df7e0a71 Add Kubernetes CRD foundation (#4839)
Co-authored-by: Codex <codex@openai.com>
2026-07-12 13:01:09 +01:00
Asim Aslam 7e3d2d3b13 x402: harden spend cap — reject invalid amounts, require settler option (#4831)
Two spend-safety fixes from the gap audit (#4814):

- Client.Do refused a 402 only on the budget check, but parsed
  maxAmountRequired with a swallowed error, so a non-decimal, overflowing
  or negative amount became 0 and passed the cap trivially while Payer.Pay
  still signed against the string. Now reject any amount that is not a
  positive integer before signing.

- Require settled only when the facilitator implemented Settler; a
  verify-only facilitator served the resource while no funds moved. Add
  Config.RequireSettlement to fail closed in that case.

Tests cover invalid/negative/overflow amounts and the verify-only
fail-closed path.


Claude-Session: https://claude.ai/code/session_01CmdEY7pYmV5zzwCjNJ4ykL

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-12 12:40:12 +01:00
Asim Aslam 3e4b13e2bd Fix grpcreflect JSON name lint (#4826)
* gateway/mcp: expose reflected gRPC services

* Fix grpcreflect JSON name lint

---------

Co-authored-by: Codex <codex@openai.com>
2026-07-12 11:28:53 +00:00
Asim Aslam 1b83cdff9c mcp: stdio/ws tool results are JSON + isError (fixes garbage to Claude Desktop) (#4825)
Closes #4813. The stdio transport is the path an external MCP host (Claude
Desktop) uses, and it emitted broken output:
- tool results were `fmt.Sprintf("%v", decodedJSON)` → Go map-syntax
  (`map[id:1 name:bob]`), not JSON. Now returned as JSON text.
- tool-execution failures were returned as JSON-RPC protocol errors; per the
  MCP spec they must be a result with `isError:true` so the agent can read the
  failure. Now they are (span/audit still record the error).

Both fixes are shared between stdio and websocket via a new `mcpToolResult`/
`mcpToolError` (dedupes the two transports). Added the missing stdio round-trip
tests (the package had zero) proving JSON output and the isError contract, using
an injected fake client; updated the websocket auth tests that asserted the old
protocol-error-on-tool-failure behavior.

Also fixes a pre-existing golangci-lint failure on master (unnecessary
`string(...)` conversion in grpcreflect.go from #4821) so the mcp package lints
clean — another one the required-checks gap let through.


Claude-Session: https://claude.ai/code/session_01CmdEY7pYmV5zzwCjNJ4ykL

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-12 12:28:06 +01:00
Asim Aslam 3ef265f3c2 loop: refresh planner priorities after gRPC MCP (#4828)
Co-authored-by: Codex <codex@openai.com>
2026-07-12 12:27:04 +01:00
Asim Aslam e8977cf335 gateway/mcp: expose reflected gRPC services (#4821)
Co-authored-by: Codex <codex@openai.com>
2026-07-12 12:09:31 +01:00
Asim Aslam c6ab16f3bf loop: drop shipped x402 buyer priority (#4818)
Co-authored-by: Codex <codex@openai.com>
2026-07-12 11:44:34 +01:00
55 changed files with 2914 additions and 376 deletions
+28 -34
View File
@@ -1,47 +1,41 @@
# Priorities
The ranked work queue for the autonomous improvement loop. The **planner** ranks
it; the **builder** works the top item whose linked issue is still open. Direction
comes from the human (the [roadmap](../../ROADMAP.md) and the
[gap audit](../../internal/docs/GAP_AUDIT.md)) — the planner does NOT invent work,
it ranks the curated backlog. The builder executes well-defined issues; it does
not improvise.
The ranked work queue for the autonomous improvement loop. The **planner** owns
this file: each run it turns the [roadmap](../../ROADMAP.md) plus an internal scan
into a single ordered list — highest-value first — each item linked to a tracking
issue. The **builder** works the top item whose issue is still open. So the
planner decides *what*, the builder *builds* it.
**Advances the strategy vs. grooms a proxy.** Rank by whether an item advances
the actual strategy — real integration/exposure, real capability — not by whether
it produces a green increment. Making MCP speak MCP to an external client, A2A
interoperate with a real external agent, and x402 actually settle is real work.
Guarding docs the loop wrote or chasing a weak provider's quirks is grooming —
`needs-human` it.
**Bias to capability, not busy-work.** The top of this queue is net-new capability
from the roadmap's *Now/Next* items. Hardening/conformance/DX polish is background
work (roadmap *Ongoing*) — kept low here and capped, never allowed to crowd out
capability. If an area has had several increments with no user-visible gain, it is done
for now; rank real-headroom capability instead.
**Reading / editing.** An item is done when its linked issue closes (the PR adds
`Closes #<issue>`). The human reorders this list or the issues at any time.
**Reading / editing.** An item is done when its linked issue closes (the PR that
builds it adds `Closes #<issue>`). The human can reorder this list or the issues at
any time — direction always wins.
**Off-limits to the loop** (never auto-merged): brand/positioning copy, breaking
public-API changes, architectural rewrites. Items labelled `needs-human` below are
1:1 development work — the loop must not auto-build them.
**Off-limits to the loop** (planner proposes as notes, never auto-merged queue
items): brand/positioning copy, breaking public-API changes, architectural
rewrites.
## Work queue (ranked)
### Strategic spine — make integration/exposure actually robust (gap audit)
### Capability — the headline (roadmap: Now / Next)
These are the surfaces the strategy rests on, and they're the least externally-proven
part of the codebase. Highest value.
1. **A2A external-client conformance** ([#4815](https://github.com/micro/go-micro/issues/4815)) — make the gateway easier for non-go-micro agents to discover and stream from by serving the well-known agent card path and spec SSE events.
2. **AP2 mandate foundation for agent payments** ([#4841](https://github.com/micro/go-micro/issues/4841)) — add opt-in checkout/payment mandate signing and verification so A2A-carried payment authority can settle over x402 without changing defaults.
3. **Kubernetes CRD reconciler foundation** ([#4842](https://github.com/micro/go-micro/issues/4842)) — turn the shipped alpha `Agent`, `Service`, and `Flow` CRDs into a minimally runnable native deployment path with workload reconciliation and status conditions.
1. **MCP: stdio/ws tool results must be JSON + `isError`, with a stdio test** ([#4813](https://github.com/micro/go-micro/issues/4813)) — the path Claude Desktop uses currently returns Go `%v` map-syntax instead of JSON and misreports tool errors. Cheapest, highest-impact fix.
2. **x402: fix the budget-cap bypass + require a real Settler** ([#4814](https://github.com/micro/go-micro/issues/4814)) — a malformed amount defeats the spend cap; verify-only serves the resource for free. Hardens the safety the flagship relies on.
3. **A2A: conform to external clients — well-known path + spec SSE events** ([#4815](https://github.com/micro/go-micro/issues/4815)) — an external A2A SDK 404s on discovery and can't parse the stream. Real cross-framework interop.
4. **Agents that pay — wire the x402 buyer into the agent runtime** ([#4786](https://github.com/micro/go-micro/issues/4786)) — the flagship capability; the buyer `Client` exists but is wired into nothing (the agent's "spend budget" is bookkeeping that never pays).
### In flight — do not re-queue
### Capability — reach & deployment (roadmap: Next; builds on the spine)
_None right now._
5. **gRPC-reflection MCP** ([#4796](https://github.com/micro/go-micro/issues/4796)) — expose external reflected gRPC services as MCP tools.
6. **Kubernetes operator + CRDs foundation** ([#4797](https://github.com/micro/go-micro/issues/4797)) — `Agent`/`Service`/`Flow` as native K8s resources.
### Background — hardening & DX (roadmap: Ongoing; capped)
### Human-led — real 1:1 development (needs-human; the loop must NOT auto-build these)
- **MCP transport unification** ([gap audit](../../internal/docs/GAP_AUDIT.md) item 2) — mount the JSON-RPC handler as the HTTP transport and run all transports through one pre-call pipeline (auth→rate→breaker→payment). Architectural.
- **Durable agentic workflow: HITL pause + per-tool-call checkpointing** ([#4816](https://github.com/micro/go-micro/issues/4816)) — the convergence leg; core primitive design.
- **In-process dispatch fast-path** ([#4817](https://github.com/micro/go-micro/issues/4817)) — a local transport so in-process calls skip codec + network hop.
_Evidence base: [`internal/docs/GAP_AUDIT.md`](../../internal/docs/GAP_AUDIT.md) (this session's code audit) + the "requirements discovered from Mu" notes. Restocked by Claude Code; the planner ranks, it does not invent._
_Background hardening is intentionally empty right now. Recent work covered first-agent
wayfinding, plan/delegate recovery, provider fallback repair, streaming, memory
compaction, retry controls, provider-failure inspection, x402 buyer safety, gRPC-reflection MCP,
MCP result conformance, and the alpha Kubernetes CRD surface. Further churn in those
areas should be marked `needs-human` unless it unlocks a clear user-visible capability._
+5 -2
View File
@@ -14,8 +14,11 @@ name: "Loop: Builder"
on:
workflow_dispatch: {}
schedule:
- cron: "29 * * * *"
# PAUSED 2026-07-12: automatic schedule disabled while the team does focused
# 1:1 fixes. Still runnable on demand via workflow_dispatch. Re-enable by
# uncommenting the schedule below.
# schedule:
# - cron: "29 * * * *"
permissions:
issues: write
+5 -2
View File
@@ -14,8 +14,11 @@ name: "Loop: Coherence"
on:
workflow_dispatch: {}
schedule:
- cron: "0 7 * * *"
# PAUSED 2026-07-12: automatic schedule disabled while the team does focused
# 1:1 fixes. Still runnable on demand via workflow_dispatch. Re-enable by
# uncommenting the schedule below.
# schedule:
# - cron: "0 7 * * *"
permissions:
issues: write
+5 -2
View File
@@ -14,8 +14,11 @@ name: "Loop: Planner"
on:
workflow_dispatch: {}
schedule:
- cron: "59 * * * *"
# PAUSED 2026-07-12: automatic schedule disabled while the team does focused
# 1:1 fixes. Still runnable on demand via workflow_dispatch. Re-enable by
# uncommenting the schedule below.
# schedule:
# - cron: "59 * * * *"
permissions:
issues: write
+5 -2
View File
@@ -12,8 +12,11 @@ name: "Loop: Release"
on:
workflow_dispatch: {}
schedule:
- cron: "0 23 * * *"
# PAUSED 2026-07-12: automatic nightly release disabled while the team does
# focused 1:1 fixes. Cut a release on demand via workflow_dispatch. Re-enable
# by uncommenting the schedule below.
# schedule:
# - cron: "0 23 * * *"
permissions:
contents: read
+5 -2
View File
@@ -14,8 +14,11 @@ name: "Loop: Security"
on:
workflow_dispatch: {}
schedule:
- cron: "0 6 * * 1"
# PAUSED 2026-07-12: automatic schedule disabled while the team does focused
# 1:1 fixes. Still runnable on demand via workflow_dispatch. Re-enable by
# uncommenting the schedule below.
# schedule:
# - cron: "0 6 * * 1"
permissions:
issues: write
+7 -3
View File
@@ -7,9 +7,13 @@ name: "Loop: Triage"
# failures become fixes with no human in the middle. Gated on CODEX_TRIGGER_TOKEN.
on:
workflow_run:
workflows: ["Harness (E2E)", "Lint", "Run Tests", "govulncheck"]
types: [completed]
workflow_dispatch: {}
# PAUSED 2026-07-12: automatic CI-failure dispatch disabled while the team
# does focused 1:1 fixes, so failures don't auto-spawn agent tasks. Re-enable
# by uncommenting the workflow_run trigger below.
# workflow_run:
# workflows: ["Harness (E2E)", "Lint", "Run Tests", "govulncheck"]
# types: [completed]
permissions:
issues: write
+10
View File
@@ -22,13 +22,23 @@ below is kept current between tags and rolled into the next version when it ship
- **Model retry jitter controls** — model retry behavior can now use jitter controls to reduce synchronized retry bursts. (`ai/`, `agent/`)
- **Compacted memory summaries** — agent memory now exposes compacted run summaries for easier inspection and recovery. (`agent/`)
- **CLI input resume for agent runs** — the CLI can resume agent runs that require additional user input. (`cmd/micro/`, `agent/`)
- **A2A inbound AP2 mandate verification (opt-in)** — set `Options.AP2PublicKey` (or `a2a.WithPushURLPolicy`'s sibling `a2a.WithAP2PublicKey` for embedded handlers) and the gateway verifies AP2 payment/checkout mandates carried on incoming messages — signature and task/context binding — recording the outcome in each task's `ap2Verifications`, with the x402 settlement rail carried through for the paid path. Off by default; mandates are otherwise carried unverified. (`gateway/a2a/`)
- **Flow human-in-the-loop pause/resume** — a flow step can suspend a run for external input with `flow.Await(key, prompt)` (or `flow.AwaitStep`): the run checkpoints with status `waiting` and `Execute` returns cleanly. `Flow.Waiting` lists suspended runs with what they await, and `Flow.ResumeWith(ctx, runID, input)` injects the input and continues from the next step. Recovery (`ResumePending`) skips waiting runs since they need input, not a restart. (`flow/`)
- **Kubernetes reconcile core (alpha)** — `kubernetes.Reconcile(desired, observed)` decides the single action needed to converge an `Agent`/`Service`/`Flow` resource toward its Deployment (create / update / noop) and returns `Ready`/`Error` status conditions. Dependency-free (no controller-runtime / client-go) and fully unit-testable; a future operator binary supplies observed state and applies the action. (`deploy/kubernetes/`)
- **In-process "local network" fast-path (opt-in)** — `client.Local()` lets a unary `Call` to a service running in the same process skip the network transport and dispatch straight to that server's handlers (for raw `codec/bytes.Frame` bodies — the shape agent/MCP/flow tool calls use), running the same router, wrappers, and codecs. In a benchmark this cut an in-process call from ~545µs to ~28µs (≈20×) with ~3.6× fewer allocations. Off by default; falls back to the network path for anything it doesn't cover. (`client/`, `server/`, `internal/network/`)
- **`micro.Local()` service option** — turn on the in-process fast-path for a whole service in one place: every co-located unary call its client makes (agent tool calls, flow dispatch, gateway → service) takes the fast-path, with no per-call wiring. Off by default; a no-op for distributed deployments. (`service/`, root `options.go`)
### Changed
- **Remote agent chat streaming** — `micro chat` now streams replies from remote agents instead of waiting for the full response. (`cmd/micro/`, `agent/`)
- **A2A external-client conformance** — the A2A gateway now serves the Agent Card at the spec 0.3.0 `/.well-known/agent-card.json` (keeping `/.well-known/agent.json` as a legacy alias), and `message/stream` emits spec-shaped `status-update`/`artifact-update` events ending in a `final:true` status-update instead of repeated full `Task` snapshots — and never sends `result` and `error` together. Standard A2A clients (ADK, LangGraph, a2a-SDK) can now discover and stream from go-micro agents. (`gateway/a2a/`)
### Fixed
- **Provider failure inspection metadata** — provider failures recorded during agent runs now retain classification metadata for inspection. (`agent/`, `ai/`)
### Security
- **x402 spend-cap hardening** — the paying `Client` now refuses a 402 whose `maxAmountRequired` is not a positive integer (a swallowed parse error or negative amount previously bypassed the budget cap), and a new `Config.RequireSettlement` fails closed when a paid request is served by a verify-only facilitator that never captures funds. (`wrapper/x402/`)
- **A2A push-notification SSRF guard** — the A2A gateway no longer delivers task push notifications to caller-supplied URLs that resolve to loopback, private, link-local (incl. cloud metadata), or unspecified addresses. Callbacks are validated when set and re-checked at dial time on the resolved IP (DNS-rebinding safe); non-http(s) schemes are rejected. `Options.AllowPushURL` (and `a2a.WithPushURLPolicy` for embedded handlers) lets operators authorize trusted in-cluster receivers. (`gateway/a2a/`)
---
## [6.7.0] - July 2026
+5 -2
View File
@@ -2,8 +2,7 @@
Go Micro is an **agent harness** and service framework for Go.
**Community:** questions, ideas, or just want to build alongside us? [Join the Discord](https://discord.gg/G8Gk5j3uXr).
## Overview
A harness is the runtime around an agent: the tools it can call, the memory it keeps, the guardrails that bound it, the workflows that trigger it, the services it depends on, and the protocols other agents use to reach it.
Go Micro gives you the harness as Go code. Build an agent and it gets a model, memory, tools, planning, delegation, guardrails, and service discovery; it is reachable over [MCP](https://modelcontextprotocol.io/) and [A2A](https://a2a-protocol.org). Write services and every endpoint becomes an AI-callable tool. Orchestrate the deterministic parts with durable flows. Agents, services, and flows share one runtime because an agent is a distributed system, and building one is building a service.
@@ -18,6 +17,10 @@ Go Micro gives you the harness as Go code. Build an agent and it gets a model, m
**Want to support Go Micro and see your logo here?** [Become a sponsor](https://discord.gg/G8Gk5j3uXr) — reach out on Discord.
## Community
Questions, ideas, or just want to build alongside us? [Join the Discord](https://discord.gg/G8Gk5j3uXr).
## Commercial Support
Running Go Micro in production, or building on it and want help? Paid **support, consulting, training, and retainers** are available directly from the maintainer — and they're what keep the project maintained. See [**Support**](SUPPORT.md) for the tiers, or [open a request](https://github.com/micro/go-micro/issues/new?template=commercial_support.md).
+65 -21
View File
@@ -68,35 +68,79 @@ func TestA2AStreamUsesAgentChatPathWithTools(t *testing.T) {
t.Fatalf("stream body missing tool marker: %s", rr.Body.String())
}
var final struct {
Result struct {
Status struct {
State string `json:"state"`
} `json:"status"`
Artifacts []struct {
Parts []struct {
Text string `json:"text"`
} `json:"parts"`
} `json:"artifacts"`
} `json:"result"`
Error any `json:"error"`
}
// The spec-shaped stream carries the answer as append artifact-update
// deltas and closes with a completed status-update (final:true).
var (
text strings.Builder
finalState string
sawFinal bool
)
for _, line := range strings.Split(strings.TrimSpace(rr.Body.String()), "\n") {
line = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(line), "data: "))
if line == "" {
continue
}
if err := json.Unmarshal([]byte(line), &final); err != nil {
var ev struct {
Result json.RawMessage `json:"result"`
Error any `json:"error"`
}
if err := json.Unmarshal([]byte(line), &ev); err != nil {
t.Fatalf("decode event %q: %v", line, err)
}
if ev.Error != nil {
t.Fatalf("event carried an error field: %+v", ev.Error)
}
var kind struct {
Kind string `json:"kind"`
}
_ = json.Unmarshal(ev.Result, &kind)
switch kind.Kind {
case "artifact-update":
var au struct {
Artifact struct {
Parts []struct {
Text string `json:"text"`
} `json:"parts"`
} `json:"artifact"`
}
_ = json.Unmarshal(ev.Result, &au)
for _, p := range au.Artifact.Parts {
text.WriteString(p.Text)
}
case "status-update":
var su struct {
Status struct {
State string `json:"state"`
} `json:"status"`
Final bool `json:"final"`
}
_ = json.Unmarshal(ev.Result, &su)
if su.Final {
sawFinal = true
finalState = su.Status.State
}
default: // opening "task" snapshot
var task struct {
Artifacts []struct {
Parts []struct {
Text string `json:"text"`
} `json:"parts"`
} `json:"artifacts"`
}
_ = json.Unmarshal(ev.Result, &task)
for _, a := range task.Artifacts {
for _, p := range a.Parts {
if p.Text != "" {
text.WriteString(p.Text)
}
}
}
}
}
if final.Error != nil {
t.Fatalf("final event error: %+v", final.Error)
if !sawFinal || finalState != "completed" {
t.Fatalf("want a completed final:true status-update; sawFinal=%v state=%q", sawFinal, finalState)
}
if final.Result.Status.State != "completed" {
t.Fatalf("final state = %q, want completed", final.Result.Status.State)
}
if len(final.Result.Artifacts) != 1 || len(final.Result.Artifacts[0].Parts) != 1 || !strings.Contains(final.Result.Artifacts[0].Parts[0].Text, "a2a-stream-ok") {
t.Fatalf("final artifacts = %+v, want tool marker", final.Result.Artifacts)
if !strings.Contains(text.String(), "a2a-stream-ok") {
t.Fatalf("reassembled stream text missing tool marker: %q", text.String())
}
}
+60
View File
@@ -0,0 +1,60 @@
package client
import (
"context"
raw "go-micro.dev/v6/codec/bytes"
"go-micro.dev/v6/internal/network"
"go-micro.dev/v6/metadata"
"go-micro.dev/v6/transport"
"go-micro.dev/v6/transport/headers"
)
// localCall is the in-process fast-path for Call. When Local is enabled
// and the callee runs in this same process, a unary request whose body and
// response are raw frames (codec/bytes.Frame) is dispatched straight to the
// server's handlers via internal/network — no dial, no codec-over-socket,
// no transport pump. It returns handled=false to fall back to the network path
// for anything it does not cover (disabled, streaming, non-frame bodies, or a
// service not registered in-process), so behavior is unchanged unless the
// fast-path fully applies.
func (r *rpcClient) localCall(ctx context.Context, req Request, resp interface{}) (handled bool, err error) {
if !r.opts.Local || req.Stream() {
return false, nil
}
reqFrame, ok := req.Body().(*raw.Frame)
if !ok {
return false, nil
}
respFrame, ok := resp.(*raw.Frame)
if !ok {
return false, nil
}
dispatch, ok := network.Lookup(req.Service())
if !ok {
return false, nil
}
header := make(map[string]string)
if md, ok := metadata.FromContext(ctx); ok {
for k, v := range md {
if k == headers.Message { // pub/sub topic header, never forwarded
continue
}
header[k] = v
}
}
header[headers.Request] = req.Service()
header[headers.Endpoint] = req.Endpoint()
header["Content-Type"] = req.ContentType()
header["Accept"] = req.ContentType()
reply, err := dispatch(ctx, &transport.Message{Header: header, Body: reqFrame.Data})
if err != nil {
return true, err
}
if reply != nil {
respFrame.Data = reply.Body
}
return true, nil
}
+139
View File
@@ -0,0 +1,139 @@
package client_test
import (
"context"
"encoding/json"
"testing"
"time"
"go-micro.dev/v6/client"
raw "go-micro.dev/v6/codec/bytes"
"go-micro.dev/v6/registry"
"go-micro.dev/v6/selector"
"go-micro.dev/v6/server"
)
type EchoReq struct {
Msg string `json:"msg"`
}
type EchoRsp struct {
Msg string `json:"msg"`
}
type EchoHandler struct{}
func (EchoHandler) Echo(_ context.Context, req *EchoReq, rsp *EchoRsp) error {
rsp.Msg = "echo:" + req.Msg
return nil
}
// startEchoServer starts a real server on the given registry and returns a stop
// func. The server is reachable over the network transport and (via Start)
// registered for the in-process fast-path.
func startEchoServer(t testing.TB, reg registry.Registry) func() {
t.Helper()
srv := server.NewServer(
server.Name("echo.local"),
server.Address("127.0.0.1:0"),
server.Registry(reg),
)
if err := srv.Handle(srv.NewHandler(&EchoHandler{})); err != nil {
t.Fatalf("handle: %v", err)
}
if err := srv.Start(); err != nil {
t.Fatalf("start: %v", err)
}
// Wait for registration so the client's selector can find a node.
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
if svcs, err := reg.GetService("echo.local"); err == nil && len(svcs) > 0 && len(svcs[0].Nodes) > 0 {
break
}
time.Sleep(10 * time.Millisecond)
}
return func() { _ = srv.Stop() }
}
func newEchoClient(reg registry.Registry, opts ...client.Option) client.Client {
base := []client.Option{
client.Registry(reg),
client.Selector(selector.NewSelector(selector.Registry(reg))),
client.ContentType("application/json"),
}
return client.NewClient(append(base, opts...)...)
}
// callEcho makes an echo call with a raw-frame body (the shape agent/MCP/flow
// dispatch uses) and returns the decoded reply.
func callEcho(t testing.TB, cl client.Client, msg string) EchoRsp {
t.Helper()
body, _ := json.Marshal(EchoReq{Msg: msg})
req := cl.NewRequest("echo.local", "EchoHandler.Echo", &raw.Frame{Data: body}, client.WithContentType("application/json"))
var rsp raw.Frame
if err := cl.Call(context.Background(), req, &rsp); err != nil {
t.Fatalf("call: %v", err)
}
var out EchoRsp
if err := json.Unmarshal(rsp.Data, &out); err != nil {
t.Fatalf("decode reply %q: %v", rsp.Data, err)
}
return out
}
// TestLocalMatchesNetwork proves the in-process fast-path returns the
// exact same result as the network path for the same handler and request.
func TestLocalMatchesNetwork(t *testing.T) {
reg := registry.NewMemoryRegistry()
stop := startEchoServer(t, reg)
defer stop()
net := newEchoClient(reg) // network path
local := newEchoClient(reg, client.Local()) // in-process fast-path
netRsp := callEcho(t, net, "hi")
localRsp := callEcho(t, local, "hi")
if netRsp.Msg != "echo:hi" {
t.Fatalf("network reply = %q, want echo:hi", netRsp.Msg)
}
if localRsp != netRsp {
t.Fatalf("fast-path reply %+v != network reply %+v", localRsp, netRsp)
}
}
// TestLocalFallsBackWhenNotLocal confirms a service not registered
// in-process still works via the network path even with Local on.
func TestLocalFallsBackWhenNotLocal(t *testing.T) {
reg := registry.NewMemoryRegistry()
stop := startEchoServer(t, reg)
defer stop()
// Local is on, but the call still resolves — the fast-path only
// engages when it fully applies, otherwise the network path runs.
local := newEchoClient(reg, client.Local())
if got := callEcho(t, local, "x").Msg; got != "echo:x" {
t.Fatalf("reply = %q, want echo:x", got)
}
}
func benchmarkEcho(b *testing.B, opts ...client.Option) {
reg := registry.NewMemoryRegistry()
stop := startEchoServer(b, reg)
defer stop()
cl := newEchoClient(reg, opts...)
body, _ := json.Marshal(EchoReq{Msg: "hi"})
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
req := cl.NewRequest("echo.local", "EchoHandler.Echo", &raw.Frame{Data: body}, client.WithContentType("application/json"))
var rsp raw.Frame
if err := cl.Call(context.Background(), req, &rsp); err != nil {
b.Fatalf("call: %v", err)
}
}
}
func BenchmarkNetworkCall(b *testing.B) { benchmarkEcho(b) }
func BenchmarkLocalCall(b *testing.B) { benchmarkEcho(b, client.Local()) }
+16
View File
@@ -68,6 +68,11 @@ type Options struct {
PoolSize int
PoolTTL time.Duration
PoolCloseTimeout time.Duration
// Local, when true, lets a unary Call to a service running in this
// same process skip the network transport and dispatch directly to that
// server's handlers (raw byte bodies only). Off by default.
Local bool
}
// CallOptions are options used to make calls to a server.
@@ -181,6 +186,17 @@ func ContentType(ct string) Option {
}
}
// Local enables the in-process fast-path: a unary Call to a service
// running in the same process dispatches straight to that server's handlers
// (skipping dial, codec-over-socket, and the transport pump) when both request
// and response bodies are raw frames (codec/bytes.Frame) — the shape agent,
// MCP, and flow tool calls use. Falls back to the network path otherwise.
func Local() Option {
return func(o *Options) {
o.Local = true
}
}
// PoolSize sets the connection pool size.
func PoolSize(d int) Option {
return func(o *Options) {
+6
View File
@@ -83,6 +83,12 @@ func (r *rpcClient) call(
resp interface{},
opts CallOptions,
) error {
// In-process fast-path: if the callee runs in this process and both bodies
// are raw frames, dispatch directly and skip the network entirely.
if handled, err := r.localCall(ctx, req, resp); handled {
return err
}
address := node.Address
logger := r.Options().Logger
+36
View File
@@ -0,0 +1,36 @@
# Kubernetes deployment foundation (alpha)
This package is the first opt-in Kubernetes foundation for the Go Micro lifecycle:
`Service`, `Agent`, and `Flow` resources. It is intentionally experimental and
additive. Nothing in the Go Micro runtime installs these resources or changes
production defaults.
## What is included
- Alpha CRD manifests in `config/crd/` for `agents.micro.dev`,
`services.micro.dev`, and `flows.micro.dev`.
- A small dependency-free mapper that turns a desired Go Micro resource into the
Kubernetes `Deployment` shape an operator reconciliation loop will own.
- A dependency-free `Reconcile(desired, observed)` core that decides the one
action needed to converge (create / update / noop) and the `Ready`/`Error`
status conditions — no controller-runtime, no client-go, fully unit-testable.
A future operator binary supplies the observed state and applies the action;
only that adapter needs the Kubernetes client.
- Unit tests that validate the structural CRD fragments, the Agent-to-Deployment
mapping, and the reconcile decision/conditions.
## Local validation
```sh
go test ./deploy/kubernetes
```
If you have a Kubernetes cluster and `kubectl` available, you can also perform a
server-side dry run of the CRDs:
```sh
kubectl apply --dry-run=server -f deploy/kubernetes/config/crd/
```
The manifests are `v1alpha1`; expect the API shape to evolve before this becomes
a production operator.
+37
View File
@@ -0,0 +1,37 @@
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
name: agents.micro.dev
spec:
group: micro.dev
scope: Namespaced
names:
plural: agents
singular: agent
kind: Agent
shortNames: [magent]
versions:
- name: v1alpha1
served: true
storage: true
schema:
openAPIV3Schema:
type: object
required: [spec]
properties:
spec:
type: object
required: [image]
properties:
image: {type: string, minLength: 1}
command:
type: array
items: {type: string}
args:
type: array
items: {type: string}
replicas: {type: integer, minimum: 0}
registry: {type: string}
env:
type: object
additionalProperties: {type: string}
+37
View File
@@ -0,0 +1,37 @@
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
name: flows.micro.dev
spec:
group: micro.dev
scope: Namespaced
names:
plural: flows
singular: flow
kind: Flow
shortNames: [mflow]
versions:
- name: v1alpha1
served: true
storage: true
schema:
openAPIV3Schema:
type: object
required: [spec]
properties:
spec:
type: object
required: [image]
properties:
image: {type: string, minLength: 1}
command:
type: array
items: {type: string}
args:
type: array
items: {type: string}
replicas: {type: integer, minimum: 0}
registry: {type: string}
env:
type: object
additionalProperties: {type: string}
+37
View File
@@ -0,0 +1,37 @@
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
name: services.micro.dev
spec:
group: micro.dev
scope: Namespaced
names:
plural: services
singular: service
kind: Service
shortNames: [mservice]
versions:
- name: v1alpha1
served: true
storage: true
schema:
openAPIV3Schema:
type: object
required: [spec]
properties:
spec:
type: object
required: [image]
properties:
image: {type: string, minLength: 1}
command:
type: array
items: {type: string}
args:
type: array
items: {type: string}
replicas: {type: integer, minimum: 0}
registry: {type: string}
env:
type: object
additionalProperties: {type: string}
+8
View File
@@ -0,0 +1,8 @@
// Package kubernetes contains the experimental Kubernetes deployment foundation
// for Go Micro services, agents, and flows.
//
// The package is intentionally small and additive: it exposes alpha custom
// resource manifests and a dry-run mapper that turns a resource spec into the
// Deployment shape an operator would reconcile. It does not install an operator
// or change any runtime defaults.
package kubernetes
+87
View File
@@ -0,0 +1,87 @@
package kubernetes
import (
"strings"
"testing"
)
func TestCRDManifestsAreStructural(t *testing.T) {
for _, kind := range []Kind{KindAgent, KindService, KindFlow} {
manifest := CRDManifests[kind]
if manifest == "" {
t.Fatalf("missing manifest for %s", kind)
}
checks := []string{
"apiVersion: apiextensions.k8s.io/v1",
"kind: CustomResourceDefinition",
"group: micro.dev",
"kind: " + string(kind),
"name: v1alpha1",
"served: true",
"storage: true",
"openAPIV3Schema:",
"type: object",
"required: [image]",
}
for _, check := range checks {
if !strings.Contains(manifest, check) {
t.Fatalf("%s manifest missing %q:\n%s", kind, check, manifest)
}
}
}
}
func TestMapDeploymentForAgent(t *testing.T) {
deployment, err := MapDeployment(Resource{
Kind: KindAgent,
Name: "support-agent",
Namespace: "agents",
Spec: WorkloadSpec{
Image: "ghcr.io/acme/support-agent:v1",
Replicas: 2,
Registry: "kubernetes",
Environment: map[string]string{
"MODEL": "gpt-5.5",
},
},
})
if err != nil {
t.Fatalf("MapDeployment returned error: %v", err)
}
if deployment.Name != "support-agent" || deployment.Namespace != "agents" {
t.Fatalf("unexpected identity: %+v", deployment)
}
if deployment.Replicas != 2 {
t.Fatalf("replicas = %d, want 2", deployment.Replicas)
}
if got := deployment.Labels["micro.dev/kind"]; got != "agent" {
t.Fatalf("micro.dev/kind label = %q, want agent", got)
}
container := deployment.Pod.Container
if container.Image != "ghcr.io/acme/support-agent:v1" {
t.Fatalf("image = %q", container.Image)
}
if got := container.Environment["MICRO_REGISTRY"]; got != "kubernetes" {
t.Fatalf("MICRO_REGISTRY = %q, want kubernetes", got)
}
if got := container.Environment["MODEL"]; got != "gpt-5.5" {
t.Fatalf("MODEL = %q, want gpt-5.5", got)
}
}
func TestMapDeploymentDefaultsAndValidation(t *testing.T) {
deployment, err := MapDeployment(Resource{Kind: KindService, Name: "api", Spec: WorkloadSpec{Image: "api:latest"}})
if err != nil {
t.Fatalf("MapDeployment returned error: %v", err)
}
if deployment.Namespace != "default" || deployment.Replicas != 1 {
t.Fatalf("defaults = namespace %q replicas %d", deployment.Namespace, deployment.Replicas)
}
if _, err := MapDeployment(Resource{Kind: KindFlow, Name: "ingest"}); err == nil {
t.Fatal("MapDeployment without image succeeded")
}
if _, err := MapDeployment(Resource{Kind: "Job", Name: "job", Spec: WorkloadSpec{Image: "job:latest"}}); err == nil {
t.Fatal("MapDeployment with unsupported kind succeeded")
}
}
+32
View File
@@ -0,0 +1,32 @@
package kubernetes
import (
"embed"
"fmt"
)
// crdFS holds the canonical CRD manifests. They live as real YAML under
// config/crd/ so they can be applied directly (`kubectl apply -f
// deploy/kubernetes/config/crd/`) and are embedded here so the Go API serves
// the exact same bytes — one source of truth, no drift.
//
//go:embed config/crd/agent.yaml config/crd/service.yaml config/crd/flow.yaml
var crdFS embed.FS
// CRDManifests contains the alpha CRDs for Go Micro lifecycle resources, loaded
// from the embedded config/crd/ YAML.
var CRDManifests = map[Kind]string{
KindAgent: mustCRD("agent"),
KindService: mustCRD("service"),
KindFlow: mustCRD("flow"),
}
// mustCRD reads an embedded CRD manifest. The files are embedded at compile
// time, so a read error means a build/packaging bug, not a runtime condition.
func mustCRD(name string) string {
b, err := crdFS.ReadFile("config/crd/" + name + ".yaml")
if err != nil {
panic(fmt.Sprintf("kubernetes: embedded CRD %q missing: %v", name, err))
}
return string(b)
}
+105
View File
@@ -0,0 +1,105 @@
package kubernetes
import (
"fmt"
"reflect"
)
// Reconcile is the pure decision core an operator's reconcile loop runs: given
// a desired resource and the currently observed cluster state, it computes the
// one action needed to converge (create / update / nothing) plus the status
// conditions to publish. It does not talk to a cluster — no controller-runtime,
// no client-go — so the whole convergence decision is unit-testable. An adapter
// binary supplies Observed from the live cluster and applies the returned
// Action; that adapter is the only piece that needs the Kubernetes client.
// ActionType is the change a reconcile wants applied.
type ActionType string
const (
// ActionCreate means the workload does not exist yet and should be created.
ActionCreate ActionType = "create"
// ActionUpdate means the workload exists but drifts from desired.
ActionUpdate ActionType = "update"
// ActionNoop means the workload already matches desired.
ActionNoop ActionType = "noop"
)
// Action is the change Reconcile decided on, carrying the desired Deployment.
type Action struct {
Type ActionType
Deployment Deployment
}
// Observed is the current cluster state Reconcile compares against. The adapter
// fills it from the live cluster; a nil Deployment means "not created yet".
type Observed struct {
// Deployment is the workload as it currently exists, or nil if absent.
Deployment *Deployment
// ReadyReplicas is how many pods are ready, from the live Deployment status.
ReadyReplicas int32
}
// Condition is a status condition to publish on the resource — the ready/error
// signal for the inner-loop and deploy story. It mirrors the Kubernetes
// condition shape without importing the API types.
type Condition struct {
Type string `json:"type"` // "Ready" | "Error"
Status string `json:"status"` // "True" | "False" | "Unknown"
Reason string `json:"reason"`
Message string `json:"message,omitempty"`
}
// Reconcile computes the action to bring observed toward desired, plus the
// status conditions. A spec that fails to map returns an Error condition and
// the error (no action).
func Reconcile(desired Resource, observed Observed) (Action, []Condition, error) {
want, err := MapDeployment(desired)
if err != nil {
return Action{}, []Condition{{
Type: "Error", Status: "True", Reason: "InvalidSpec", Message: err.Error(),
}}, err
}
var action Action
switch {
case observed.Deployment == nil:
action = Action{Type: ActionCreate, Deployment: want}
case deploymentDiffers(*observed.Deployment, want):
action = Action{Type: ActionUpdate, Deployment: want}
default:
action = Action{Type: ActionNoop, Deployment: want}
}
return action, conditions(want, observed), nil
}
// conditions derives the Ready condition from observed state against desired.
func conditions(want Deployment, observed Observed) []Condition {
switch {
case observed.Deployment == nil:
return []Condition{{
Type: "Ready", Status: "False", Reason: "Creating",
Message: "workload not yet created",
}}
case observed.ReadyReplicas < want.Replicas:
return []Condition{{
Type: "Ready", Status: "False", Reason: "Progressing",
Message: fmt.Sprintf("%d/%d replicas ready", observed.ReadyReplicas, want.Replicas),
}}
default:
return []Condition{{
Type: "Ready", Status: "True", Reason: "Available",
Message: fmt.Sprintf("%d/%d replicas ready", observed.ReadyReplicas, want.Replicas),
}}
}
}
// deploymentDiffers reports whether the observed deployment drifts from desired
// on the fields this operator manages (replicas, container, labels). Fields the
// cluster owns (status, cluster-assigned metadata) are intentionally ignored.
func deploymentDiffers(current, want Deployment) bool {
return current.Replicas != want.Replicas ||
!reflect.DeepEqual(current.Pod.Container, want.Pod.Container) ||
!reflect.DeepEqual(current.Labels, want.Labels)
}
+88
View File
@@ -0,0 +1,88 @@
package kubernetes
import "testing"
func agentResource() Resource {
return Resource{
Kind: KindAgent,
Name: "support",
Namespace: "agents",
Spec: WorkloadSpec{Image: "example/support:v1", Replicas: 2, Registry: "kubernetes"},
}
}
func TestReconcileCreatesWhenAbsent(t *testing.T) {
action, conds, err := Reconcile(agentResource(), Observed{Deployment: nil})
if err != nil {
t.Fatalf("Reconcile: %v", err)
}
if action.Type != ActionCreate {
t.Fatalf("action = %q, want create", action.Type)
}
if action.Deployment.Name != "support" || action.Deployment.Replicas != 2 {
t.Fatalf("desired deployment = %+v", action.Deployment)
}
if ready := findCondition(conds, "Ready"); ready == nil || ready.Status != "False" || ready.Reason != "Creating" {
t.Fatalf("ready condition = %+v, want False/Creating", ready)
}
}
func TestReconcileNoopWhenMatchedAndReady(t *testing.T) {
want, _ := MapDeployment(agentResource())
action, conds, err := Reconcile(agentResource(), Observed{Deployment: &want, ReadyReplicas: 2})
if err != nil {
t.Fatalf("Reconcile: %v", err)
}
if action.Type != ActionNoop {
t.Fatalf("action = %q, want noop", action.Type)
}
if ready := findCondition(conds, "Ready"); ready == nil || ready.Status != "True" || ready.Reason != "Available" {
t.Fatalf("ready condition = %+v, want True/Available", ready)
}
}
func TestReconcileUpdatesOnDrift(t *testing.T) {
current, _ := MapDeployment(agentResource())
current.Pod.Container.Image = "example/support:v0" // stale image → drift
action, _, err := Reconcile(agentResource(), Observed{Deployment: &current, ReadyReplicas: 2})
if err != nil {
t.Fatalf("Reconcile: %v", err)
}
if action.Type != ActionUpdate {
t.Fatalf("action = %q, want update", action.Type)
}
if action.Deployment.Pod.Container.Image != "example/support:v1" {
t.Fatalf("update should carry the desired image, got %q", action.Deployment.Pod.Container.Image)
}
}
func TestReconcileProgressingWhenUnderReplicated(t *testing.T) {
want, _ := MapDeployment(agentResource())
_, conds, err := Reconcile(agentResource(), Observed{Deployment: &want, ReadyReplicas: 1})
if err != nil {
t.Fatalf("Reconcile: %v", err)
}
if ready := findCondition(conds, "Ready"); ready == nil || ready.Status != "False" || ready.Reason != "Progressing" {
t.Fatalf("ready condition = %+v, want False/Progressing", ready)
}
}
func TestReconcileErrorOnInvalidSpec(t *testing.T) {
// Missing image → MapDeployment fails → Error condition, no action.
_, conds, err := Reconcile(Resource{Kind: KindService, Name: "api"}, Observed{})
if err == nil {
t.Fatal("Reconcile should error on an invalid spec")
}
if e := findCondition(conds, "Error"); e == nil || e.Status != "True" || e.Reason != "InvalidSpec" {
t.Fatalf("error condition = %+v, want True/InvalidSpec", e)
}
}
func findCondition(conds []Condition, typ string) *Condition {
for i := range conds {
if conds[i].Type == typ {
return &conds[i]
}
}
return nil
}
+138
View File
@@ -0,0 +1,138 @@
package kubernetes
import (
"fmt"
"sort"
"strings"
)
const (
// Group is the API group for the alpha Go Micro Kubernetes resources.
Group = "micro.dev"
// Version is the current alpha API version for the CRDs in this package.
Version = "v1alpha1"
)
// Kind identifies a Go Micro lifecycle resource that can be reconciled toward a
// Kubernetes Deployment.
type Kind string
const (
KindAgent Kind = "Agent"
KindService Kind = "Service"
KindFlow Kind = "Flow"
)
// WorkloadSpec is the common alpha spec shared by Agent, Service, and Flow CRDs.
type WorkloadSpec struct {
Image string `json:"image"`
Command []string `json:"command,omitempty"`
Args []string `json:"args,omitempty"`
Replicas int32 `json:"replicas,omitempty"`
Registry string `json:"registry,omitempty"`
Environment map[string]string `json:"env,omitempty"`
}
// Resource is the minimal desired state for a Go Micro lifecycle resource.
type Resource struct {
Kind Kind
Name string
Namespace string
Spec WorkloadSpec
}
// Deployment is a small, dependency-free representation of the Kubernetes
// Deployment fields the alpha reconciler skeleton owns.
type Deployment struct {
Name string
Namespace string
Labels map[string]string
Replicas int32
Pod PodTemplate
}
// PodTemplate describes the pod fields emitted by MapDeployment.
type PodTemplate struct {
Labels map[string]string
Container Container
}
// Container describes the single Go Micro workload container.
type Container struct {
Name string
Image string
Command []string
Args []string
Environment map[string]string
}
// MapDeployment maps a Go Micro alpha resource to the Deployment shape an
// operator reconciliation loop would apply.
func MapDeployment(resource Resource) (Deployment, error) {
if resource.Kind != KindAgent && resource.Kind != KindService && resource.Kind != KindFlow {
return Deployment{}, fmt.Errorf("unsupported kind %q", resource.Kind)
}
name := strings.TrimSpace(resource.Name)
if name == "" {
return Deployment{}, fmt.Errorf("name is required")
}
image := strings.TrimSpace(resource.Spec.Image)
if image == "" {
return Deployment{}, fmt.Errorf("spec.image is required")
}
namespace := strings.TrimSpace(resource.Namespace)
if namespace == "" {
namespace = "default"
}
replicas := resource.Spec.Replicas
if replicas == 0 {
replicas = 1
}
labels := map[string]string{
"app.kubernetes.io/name": name,
"app.kubernetes.io/managed-by": "go-micro",
"micro.dev/kind": strings.ToLower(string(resource.Kind)),
}
env := copyMap(resource.Spec.Environment)
if resource.Spec.Registry != "" {
env["MICRO_REGISTRY"] = resource.Spec.Registry
}
return Deployment{
Name: name,
Namespace: namespace,
Labels: copyMap(labels),
Replicas: replicas,
Pod: PodTemplate{
Labels: copyMap(labels),
Container: Container{
Name: name,
Image: image,
Command: append([]string(nil), resource.Spec.Command...),
Args: append([]string(nil), resource.Spec.Args...),
Environment: env,
},
},
}, nil
}
// EnvironmentKeys returns stable environment variable keys from a mapped
// container. It is useful for deterministic validation and rendering.
func (c Container) EnvironmentKeys() []string {
keys := make([]string, 0, len(c.Environment))
for key := range c.Environment {
keys = append(keys, key)
}
sort.Strings(keys)
return keys
}
func copyMap(in map[string]string) map[string]string {
out := make(map[string]string, len(in))
for k, v := range in {
out[k] = v
}
return out
}
+4 -1
View File
@@ -92,7 +92,10 @@ func (f *Flow) runStepSpan(ctx context.Context, step Step, in State) (State, int
span.SetAttributes(attribute.String(AttrFlowVerificationStatus, "failed"))
}
}
if err != nil {
if a, ok := isAwaitInput(err); ok {
// A suspend is normal control flow, not a step error.
span.SetStatus(codes.Ok, "waiting: "+a.Key)
} else if err != nil {
span.RecordError(err)
span.SetAttributes(attribute.String(AttrFlowErrorKind, string(ai.ClassifyError(err))))
span.SetStatus(codes.Error, err.Error())
+136 -2
View File
@@ -4,6 +4,7 @@ import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"sort"
"text/template"
@@ -110,7 +111,8 @@ type Run struct {
Flow string `json:"flow"`
State State `json:"state"`
Steps []StepRecord `json:"steps"`
Status string `json:"status"` // running | done | failed
Status string `json:"status"` // running | waiting | done | failed
Await *AwaitState `json:"await,omitempty"`
Started time.Time `json:"started"`
Updated time.Time `json:"updated"`
}
@@ -336,6 +338,54 @@ func LLM(prompt string) StepFunc {
}
}
// AwaitInput is the control signal a step returns (via Await) to suspend a run
// pending external input. runFrom recognizes it, checkpoints the run as
// "waiting", and returns cleanly — a suspend is not a failure. ResumeWith
// injects the input and continues.
type AwaitInput struct {
Key string // labels what is awaited (e.g. "approval")
Prompt string // human-facing description of the input needed
}
func (e *AwaitInput) Error() string {
if e.Prompt != "" {
return fmt.Sprintf("flow: awaiting input %q: %s", e.Key, e.Prompt)
}
return fmt.Sprintf("flow: awaiting input %q", e.Key)
}
// AwaitState records, on a suspended run, what it is waiting for.
type AwaitState struct {
Step string `json:"step"`
Key string `json:"key"`
Prompt string `json:"prompt,omitempty"`
}
func isAwaitInput(err error) (*AwaitInput, bool) {
var a *AwaitInput
if errors.As(err, &a) {
return a, true
}
return nil, false
}
// Await is a StepFunc that suspends the run pending external input. The run is
// checkpointed with status "waiting" and returned cleanly; a later call to
// Flow.ResumeWith(ctx, runID, input) completes this step with the injected
// input and continues to the next step. key labels what is awaited (surfaced on
// the run and via Flow.Waiting); prompt describes the input needed.
func Await(key, prompt string) StepFunc {
return func(_ context.Context, in State) (State, error) {
return in, &AwaitInput{Key: key, Prompt: prompt}
}
}
// AwaitStep is a convenience for a named await step:
// Step{Name: name, Run: Await(key, prompt)}.
func AwaitStep(name, key, prompt string) Step {
return Step{Name: name, Run: Await(key, prompt)}
}
// startRun begins a fresh run of the flow's steps with the given input.
func (f *Flow) startRun(ctx context.Context, data string) (Run, error) {
if err := validateSteps(f.opts.Steps); err != nil {
@@ -421,13 +471,79 @@ func (f *Flow) Pending(ctx context.Context) ([]Run, error) {
}
var out []Run
for _, r := range all {
if r.Flow == f.name && r.Status != "done" {
// Waiting runs need injected input (ResumeWith), not a restart, so a
// recovery loop (ResumePending) should not pick them up.
if r.Flow == f.name && r.Status != "done" && r.Status != "waiting" {
out = append(out, r)
}
}
return out, nil
}
// Waiting returns this flow's runs suspended awaiting external input, each with
// its Await metadata, so a caller can prompt for and inject the needed input
// with ResumeWith.
func (f *Flow) Waiting(ctx context.Context) ([]Run, error) {
if f.checkpoint == nil {
return nil, nil
}
all, err := f.checkpoint.List(ctx)
if err != nil {
return nil, err
}
var out []Run
for _, r := range all {
if r.Flow == f.name && r.Status == "waiting" {
out = append(out, r)
}
}
return out, nil
}
// ResumeWith completes a suspended (waiting) run: it injects input for the
// awaited step — the input becomes that step's output state — and continues
// from the next step. It errors if the run is not waiting for input.
func (f *Flow) ResumeWith(ctx context.Context, runID, input string) error {
ctx, cancel := f.withTimeout(ctx)
defer cancel()
if err := validateSteps(f.opts.Steps); err != nil {
return err
}
if f.checkpoint == nil {
return fmt.Errorf("flow %s has no checkpoint configured", f.name)
}
run, ok, err := f.checkpoint.Load(ctx, runID)
if err != nil {
return err
}
if !ok {
return fmt.Errorf("run %s not found", runID)
}
if run.Status != "waiting" {
return fmt.Errorf("run %s is not waiting for input (status %q)", runID, run.Status)
}
steps := f.opts.Steps
i := stepIndex(steps, run.State.Stage)
if i < 0 {
return fmt.Errorf("run %s is waiting at unknown step %q", runID, run.State.Stage)
}
// The awaited step is satisfied by the injected input; record it done and
// advance so runFrom re-enters at the next step.
run.Steps[i].Status = "done"
run.Steps[i].Result = truncate(input, 200)
run.State.Data = []byte(input)
if i+1 < len(steps) {
run.State.Stage = steps[i+1].Name
} else {
run.State.Stage = ""
}
run.Await = nil
run.Status = "running"
_, err = f.runFrom(ctx, run)
return err
}
// runFrom executes steps from the run's current Stage to the end,
// checkpointing before and after each step.
func (f *Flow) runFrom(ctx context.Context, run Run) (Run, error) {
@@ -464,6 +580,19 @@ func (f *Flow) runFrom(ctx context.Context, run Run) (Run, error) {
out, attempts, verification, err := f.runStepSpan(ctx, step, run.State)
run.Steps[i].Attempts = attempts
applyVerificationRecord(&run.Steps[i], verification)
if await, ok := isAwaitInput(err); ok {
// Suspend the run pending external input — checkpoint and return
// cleanly (not a failure). ResumeWith injects the input later.
run.Steps[i].Status = "waiting"
run.Status = "waiting"
run.Await = &AwaitState{Step: step.Name, Key: await.Key, Prompt: await.Prompt}
if saveErr := f.save(ctx, run); saveErr != nil {
spanErr = saveErr
return run, saveErr
}
f.log.Logf(logger.InfoLevel, "Flow %s run %s waiting for input %q at step %q", f.name, run.ID, await.Key, step.Name)
return run, nil
}
if err != nil {
spanErr = err
run.Steps[i].Status = "failed"
@@ -537,6 +666,11 @@ func (f *Flow) runStep(ctx context.Context, step Step, in State) (State, int, Ve
attemptCtx = ai.WithRunInfo(ctx, info)
}
out, err := step.Run(attemptCtx, in)
// An await signal is control flow, not a failure: suspend immediately
// without retrying or grading.
if _, ok := isAwaitInput(err); ok {
return in, attempt, lastVerification, err
}
if err == nil && step.Verify != nil {
lastVerification, err = step.Verify(attemptCtx, out)
if err == nil && !lastVerification.Passed {
+87
View File
@@ -87,6 +87,93 @@ func TestFlowCheckpointResume(t *testing.T) {
}
}
func TestFlowAwaitAndResumeWith(t *testing.T) {
mem := store.NewMemoryStore()
var firstCalls int
var secondInput string
steps := []Step{
{Name: "first", Run: func(_ context.Context, in State) (State, error) {
firstCalls++
in.Data = []byte("first-done")
return in, nil
}},
AwaitStep("approval", "approve", "Approve to continue?"),
{Name: "second", Run: func(_ context.Context, in State) (State, error) {
secondInput = in.String()
in.Data = []byte("second-done")
return in, nil
}},
}
f := New("hitl", WithCheckpoint(StoreCheckpoint(mem, "hitl")), Steps(steps...))
// Execute suspends at the await step — a clean return, not an error.
if err := f.Execute(context.Background(), "start"); err != nil {
t.Fatalf("Execute should suspend cleanly, got %v", err)
}
if firstCalls != 1 {
t.Fatalf("first step calls = %d, want 1", firstCalls)
}
// A waiting run is not pending (restart), it needs input.
if pend, _ := f.Pending(context.Background()); len(pend) != 0 {
t.Errorf("a waiting run must not be pending, got %d", len(pend))
}
waiting, err := f.Waiting(context.Background())
if err != nil {
t.Fatal(err)
}
if len(waiting) != 1 {
t.Fatalf("waiting runs = %d, want 1", len(waiting))
}
w := waiting[0]
if w.Status != "waiting" || w.Await == nil || w.Await.Key != "approve" ||
w.Await.Prompt != "Approve to continue?" || w.Await.Step != "approval" {
t.Fatalf("await metadata = %+v (status %q)", w.Await, w.Status)
}
if w.State.Stage != "approval" {
t.Fatalf("waiting stage = %q, want approval", w.State.Stage)
}
// Injecting input completes the awaited step and runs the rest.
if err := f.ResumeWith(context.Background(), w.ID, "approved"); err != nil {
t.Fatalf("ResumeWith: %v", err)
}
if firstCalls != 1 {
t.Errorf("completed step re-ran on resume; first calls = %d", firstCalls)
}
if secondInput != "approved" {
t.Errorf("second step input = %q, want the injected 'approved'", secondInput)
}
if wr, _ := f.Waiting(context.Background()); len(wr) != 0 {
t.Errorf("no waiting runs after resume, got %d", len(wr))
}
runs, _ := StoreCheckpoint(mem, "hitl").List(context.Background())
if len(runs) != 1 || runs[0].Status != "done" {
t.Fatalf("run should be done after resume, got %+v", runs)
}
if runs[0].Await != nil {
t.Errorf("await metadata should be cleared after resume, got %+v", runs[0].Await)
}
}
func TestFlowResumeWithRejectsNonWaiting(t *testing.T) {
mem := store.NewMemoryStore()
f := New("hitl2", WithCheckpoint(StoreCheckpoint(mem, "hitl2")),
Steps(Step{Name: "only", Run: func(_ context.Context, in State) (State, error) { return in, nil }}))
if err := f.Execute(context.Background(), "x"); err != nil {
t.Fatalf("Execute: %v", err)
}
runs, _ := StoreCheckpoint(mem, "hitl2").List(context.Background())
if len(runs) != 1 {
t.Fatalf("runs = %d", len(runs))
}
if err := f.ResumeWith(context.Background(), runs[0].ID, "input"); err == nil {
t.Error("ResumeWith on a completed (non-waiting) run should error")
}
}
func TestFlowStepContextIncludesRunInfo(t *testing.T) {
var got ai.RunInfo
step := Step{Name: "inspect", Run: func(ctx context.Context, in State) (State, error) {
+209 -44
View File
@@ -27,12 +27,14 @@ package a2a
import (
"context"
"crypto/ed25519"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net/http"
"net/url"
"strings"
"sync"
"time"
@@ -64,6 +66,20 @@ type Options struct {
Client client.Client
// Logger for startup/debug output (defaults to log.Default()).
Logger *log.Logger
// AllowPushURL authorizes an outbound push-notification callback URL
// (tasks/pushNotificationConfig/set). Return a non-nil error to reject it.
// When nil, a default SSRF-safe policy applies: only http/https URLs whose
// host does not resolve to a loopback, private, link-local, or unspecified
// address are allowed, and the connection is pinned to that check at dial
// time (DNS-rebinding safe). Set this to permit a trusted in-cluster
// receiver, or to narrow delivery to an allowlist.
AllowPushURL func(*url.URL) error
// AP2PublicKey, when set, verifies AP2 payment/checkout mandates carried on
// incoming A2A messages against this Ed25519 key and records the outcome in
// each task's ap2Verifications (signature + task/context binding). When
// unset, mandates are carried through unverified. This is opt-in so the
// default flow stays free of a payment trust decision.
AP2PublicKey ed25519.PublicKey
}
// Gateway serves the A2A protocol over HTTP for the registry's agents.
@@ -87,7 +103,20 @@ func New(opts Options) *Gateway {
opts.BaseURL = "http://localhost" + opts.Address
}
opts.BaseURL = strings.TrimRight(opts.BaseURL, "/")
return &Gateway{opts: opts, disp: newDispatcher()}
g := &Gateway{opts: opts, disp: newDispatcher()}
if opts.AllowPushURL != nil {
// Operator owns the trust decision: use their policy and skip the
// built-in private-IP dial guard so trusted in-cluster hosts resolve.
g.disp.allowPushURL = opts.AllowPushURL
g.disp.guardPushDial = false
}
if len(opts.AP2PublicKey) > 0 {
pub := opts.AP2PublicKey
g.disp.ap2Verify = func(s AP2SignedMandate, task Task) AP2Verification {
return VerifyAP2ForTask(s, pub, task, nil)
}
}
return g
}
// Invoke runs an agent for one message and returns its reply. It is the
@@ -98,16 +127,55 @@ type Invoke func(ctx context.Context, text string) (string, error)
// StreamInvoke runs an agent for one message and returns streaming output chunks.
type StreamInvoke func(ctx context.Context, text string) (ai.Stream, error)
// AgentHandlerOption configures an embedded A2A agent handler.
type AgentHandlerOption func(*dispatcher)
// WithPushURLPolicy sets the push-notification callback URL policy for an
// embedded agent handler (the analog of Options.AllowPushURL on the gateway).
// Return a non-nil error to reject a URL. Without it, the default SSRF-safe
// policy applies. Supplying a policy also disables the built-in private-IP dial
// guard, so a trusted in-cluster receiver resolves.
func WithPushURLPolicy(allow func(*url.URL) error) AgentHandlerOption {
return func(d *dispatcher) {
if allow == nil {
return
}
d.allowPushURL = allow
d.guardPushDial = false
}
}
// WithAP2PublicKey verifies AP2 mandates carried on incoming messages against
// pub (the embedded-handler analog of Options.AP2PublicKey), recording the
// outcome in each task's ap2Verifications. Without it, mandates are carried
// unverified.
func WithAP2PublicKey(pub ed25519.PublicKey) AgentHandlerOption {
return func(d *dispatcher) {
if len(pub) == 0 {
return
}
d.ap2Verify = func(s AP2SignedMandate, task Task) AP2Verification {
return VerifyAP2ForTask(s, pub, task, nil)
}
}
}
// NewAgentHandler returns an http.Handler that serves the A2A protocol
// for a single agent: its Agent Card at / and /.well-known/agent.json,
// and the JSON-RPC endpoint at /. invoke runs the agent. This is what an
// agent embeds to speak A2A directly, without a separate gateway.
func NewAgentHandler(card AgentCard, invoke Invoke) http.Handler {
func NewAgentHandler(card AgentCard, invoke Invoke, opts ...AgentHandlerOption) http.Handler {
d := newDispatcher()
for _, o := range opts {
o(d)
}
mux := http.NewServeMux()
card.URL = strings.TrimRight(card.URL, "/")
serveCard := func(w http.ResponseWriter, _ *http.Request) { writeJSON(w, http.StatusOK, card) }
mux.HandleFunc("GET /{$}", serveCard)
// A2A 0.3.0 discovery is /.well-known/agent-card.json; agent.json is the
// pre-0.3 alias, kept so existing clients don't break.
mux.HandleFunc("GET /.well-known/agent-card.json", serveCard)
mux.HandleFunc("GET /.well-known/agent.json", serveCard)
mux.HandleFunc("POST /{$}", func(w http.ResponseWriter, r *http.Request) { d.serve(w, r, invoke) })
return mux
@@ -115,12 +183,16 @@ func NewAgentHandler(card AgentCard, invoke Invoke) http.Handler {
// NewAgentStreamHandler is like NewAgentHandler, but serves A2A message/stream
// by forwarding model chunks as server-sent task updates when stream is non-nil.
func NewAgentStreamHandler(card AgentCard, invoke Invoke, stream StreamInvoke) http.Handler {
func NewAgentStreamHandler(card AgentCard, invoke Invoke, stream StreamInvoke, opts ...AgentHandlerOption) http.Handler {
d := newDispatcher()
for _, o := range opts {
o(d)
}
mux := http.NewServeMux()
card.URL = strings.TrimRight(card.URL, "/")
serveCard := func(w http.ResponseWriter, _ *http.Request) { writeJSON(w, http.StatusOK, card) }
mux.HandleFunc("GET /{$}", serveCard)
mux.HandleFunc("GET /.well-known/agent-card.json", serveCard)
mux.HandleFunc("GET /.well-known/agent.json", serveCard)
mux.HandleFunc("POST /{$}", func(w http.ResponseWriter, r *http.Request) { d.serveWithStream(w, r, invoke, stream) })
return mux
@@ -139,15 +211,19 @@ func (g *Gateway) Handler() http.Handler {
// Discovery: a directory of all agent cards.
mux.HandleFunc("GET /agents", g.handleList)
// Per-agent card (served at the agent's url and at its well-known path).
// A2A 0.3.0 uses agent-card.json; agent.json is the pre-0.3 alias.
mux.HandleFunc("GET /agents/{name}", g.handleCard)
mux.HandleFunc("GET /agents/{name}/.well-known/agent-card.json", g.handleCard)
mux.HandleFunc("GET /agents/{name}/.well-known/agent.json", g.handleCard)
mux.HandleFunc("GET /agents/{name}/skills/{skill}", g.handleSkillCard)
mux.HandleFunc("GET /agents/{name}/skills/{skill}/.well-known/agent-card.json", g.handleSkillCard)
mux.HandleFunc("GET /agents/{name}/skills/{skill}/.well-known/agent.json", g.handleSkillCard)
// Per-agent JSON-RPC endpoint.
mux.HandleFunc("POST /agents/{name}", g.handleRPC)
mux.HandleFunc("POST /agents/{name}/skills/{skill}", g.handleSkillRPC)
// Top-level well-known: serve the single agent's card if there's
// exactly one, otherwise point to the directory.
mux.HandleFunc("GET /.well-known/agent-card.json", g.handleWellKnown)
mux.HandleFunc("GET /.well-known/agent.json", g.handleWellKnown)
return mux
}
@@ -222,6 +298,39 @@ type Artifact struct {
Parts []Part `json:"parts"`
}
// TaskStatusUpdateEvent is an A2A streaming event reporting a change in a
// task's status. External SSE clients parse stream events by `kind` and stop
// on the event whose `final` is true — a full Task snapshot (which older
// versions emitted) carries neither, so strict clients never terminate.
type TaskStatusUpdateEvent struct {
TaskID string `json:"taskId"`
ContextID string `json:"contextId"`
Kind string `json:"kind"` // "status-update"
Status TaskStatus `json:"status"`
Final bool `json:"final"`
}
// TaskArtifactUpdateEvent is an A2A streaming event carrying an artifact (or,
// with Append, one incremental chunk of one).
type TaskArtifactUpdateEvent struct {
TaskID string `json:"taskId"`
ContextID string `json:"contextId"`
Kind string `json:"kind"` // "artifact-update"
Artifact Artifact `json:"artifact"`
Append bool `json:"append,omitempty"`
LastChunk bool `json:"lastChunk,omitempty"`
}
func statusUpdateEvent(t *Task, final bool) TaskStatusUpdateEvent {
return TaskStatusUpdateEvent{
TaskID: t.ID,
ContextID: t.ContextID,
Kind: "status-update",
Status: t.Status,
Final: final,
}
}
// Task is the unit of work returned by message/send and tasks/get.
type Task struct {
ID string `json:"id"`
@@ -469,10 +578,26 @@ type dispatcher struct {
pushConfigs map[string]PushNotificationConfig
watchers map[string]map[chan *Task]struct{}
order []string // task ids in insertion order, for bounded eviction
// allowPushURL authorizes an outbound push-notification callback URL; nil
// means the default SSRF-safe policy. guardPushDial applies the private-IP
// dial guard (on unless an operator supplied a custom policy).
allowPushURL func(*url.URL) error
guardPushDial bool
// ap2Verify, when non-nil, verifies each AP2 mandate carried on a task and
// records the result in the task's AP2Verifications. Nil = carry unverified.
ap2Verify func(AP2SignedMandate, Task) AP2Verification
}
func newDispatcher() *dispatcher {
return &dispatcher{tasks: map[string]*Task{}, pushConfigs: map[string]PushNotificationConfig{}, watchers: map[string]map[chan *Task]struct{}{}}
return &dispatcher{
tasks: map[string]*Task{},
pushConfigs: map[string]PushNotificationConfig{},
watchers: map[string]map[chan *Task]struct{}{},
allowPushURL: defaultPushURLPolicy,
guardPushDial: true,
}
}
func (d *dispatcher) serve(w http.ResponseWriter, r *http.Request, invoke Invoke) {
@@ -534,14 +659,11 @@ func (d *dispatcher) stream(ctx context.Context, w http.ResponseWriter, req rpcR
writeRPC(w, req.ID, nil, e)
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(sseWriter{w: w}).Encode(rpcResponse{JSONRPC: "2.0", ID: req.ID, Result: task})
if f, ok := w.(http.Flusher); ok {
f.Flush()
}
enc, flush := sseResponse(w)
// The Task snapshot first (carries ids and the final artifact), then a
// terminal status-update so external SSE clients see `final:true` and stop.
writeSSE(enc, flush, req.ID, task)
writeSSE(enc, flush, req.ID, statusUpdateEvent(task, true))
}
func (d *dispatcher) streamChunks(ctx context.Context, w http.ResponseWriter, req rpcRequest, invoke StreamInvoke, fallback Invoke) {
@@ -565,46 +687,53 @@ func (d *dispatcher) streamChunks(ctx context.Context, w http.ResponseWriter, re
return
}
defer stream.Close()
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.WriteHeader(http.StatusOK)
enc := json.NewEncoder(sseWriter{w: w})
flush := func() {
if f, ok := w.(http.Flusher); ok {
f.Flush()
}
}
enc, flush := sseResponse(w)
taskID := uuid.New().String()
contextID := p.Message.ContextID
if contextID == "" {
contextID = uuid.New().String()
}
// One artifact id for the whole stream so append:true chunks target it.
artifactID := uuid.New().String()
// Open with the Task snapshot (working) so the client learns the ids.
initial := taskFromReplyWithIDs(p.Message, "", stateWorking, taskID, contextID)
d.store(initial)
writeSSE(enc, flush, req.ID, initial)
var reply strings.Builder
for {
chunk, err := stream.Recv()
if err == io.EOF {
task := taskFromReplyWithIDs(p.Message, reply.String(), stateCompleted, taskID, contextID)
d.store(task)
_ = enc.Encode(rpcResponse{JSONRPC: "2.0", ID: req.ID, Result: task})
flush()
// Spec-shaped terminal: a status-update with final:true — not a
// full Task snapshot, which carries no terminal marker.
writeSSE(enc, flush, req.ID, statusUpdateEvent(task, true))
return
}
if err != nil {
task := taskFromReplyWithIDs(p.Message, "error: "+err.Error(), stateFailed, taskID, contextID)
d.store(task)
_ = enc.Encode(rpcResponse{JSONRPC: "2.0", ID: req.ID, Result: task, Error: &rpcError{Code: errInternal, Message: err.Error()}})
flush()
// A failed status-update (final) — never `result` and `error`
// together in one response, which strict clients reject.
writeSSE(enc, flush, req.ID, statusUpdateEvent(task, true))
return
}
if chunk == nil || chunk.Reply == "" {
continue
}
reply.WriteString(chunk.Reply)
task := taskFromReplyWithIDs(p.Message, reply.String(), stateWorking, taskID, contextID)
d.store(task)
_ = enc.Encode(rpcResponse{JSONRPC: "2.0", ID: req.ID, Result: task})
flush()
// Emit the delta as an append artifact-update; keep the stored task
// current for tasks/get and resubscribe watchers.
d.store(taskFromReplyWithIDs(p.Message, reply.String(), stateWorking, taskID, contextID))
writeSSE(enc, flush, req.ID, TaskArtifactUpdateEvent{
TaskID: taskID,
ContextID: contextID,
Kind: "artifact-update",
Artifact: Artifact{ArtifactID: artifactID, Parts: []Part{{Kind: "text", Text: chunk.Reply}}},
Append: true,
})
}
}
@@ -653,20 +782,16 @@ func (d *dispatcher) resubscribe(ctx context.Context, w http.ResponseWriter, req
}
defer unsubscribe()
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.WriteHeader(http.StatusOK)
enc := json.NewEncoder(sseWriter{w: w})
flush := func() {
if f, ok := w.(http.Flusher); ok {
f.Flush()
}
}
enc, flush := sseResponse(w)
writeEvent := func(t *Task) bool {
_ = enc.Encode(rpcResponse{JSONRPC: "2.0", ID: req.ID, Result: t})
flush()
return isTerminal(t.Status.State)
writeSSE(enc, flush, req.ID, t)
if isTerminal(t.Status.State) {
// Close the stream with a spec-shaped terminal marker so external
// clients see `final:true`.
writeSSE(enc, flush, req.ID, statusUpdateEvent(t, true))
return true
}
return false
}
if writeEvent(task) {
return
@@ -710,6 +835,11 @@ func (d *dispatcher) setPushConfig(w http.ResponseWriter, req rpcRequest) {
writeRPC(w, req.ID, nil, &rpcError{Code: errInvalidParams, Message: "invalid params"})
return
}
// Reject SSRF-unsafe callback targets before storing them.
if err := d.checkPushURL(p.PushNotificationConfig.URL); err != nil {
writeRPC(w, req.ID, nil, &rpcError{Code: errInvalidParams, Message: "push notification url not allowed"})
return
}
d.mu.Lock()
task := d.tasks[p.ID]
if task != nil {
@@ -765,6 +895,15 @@ func (g *Gateway) callAgent(ctx context.Context, name, message string) (string,
// ---------------------------------------------------------------------------
func (d *dispatcher) store(t *Task) {
// Verify any AP2 mandates carried on the task (opt-in) and surface the
// outcome so a downstream paid path can trust — or reject — the mandate.
if d.ap2Verify != nil && len(t.AP2Mandates) > 0 && len(t.AP2Verifications) == 0 {
v := make([]AP2Verification, 0, len(t.AP2Mandates))
for _, m := range t.AP2Mandates {
v = append(v, d.ap2Verify(m, *t))
}
t.AP2Verifications = v
}
d.mu.Lock()
_, exists := d.tasks[t.ID]
d.tasks[t.ID] = t
@@ -878,6 +1017,11 @@ func (d *dispatcher) deliverPush(taskID string, task *Task) {
if !ok || cfg.URL == "" || task == nil {
return
}
// Defense in depth: re-validate the callback URL at delivery time in case
// the policy tightened or the config was set before it applied.
if err := d.checkPushURL(cfg.URL); err != nil {
return
}
body, err := json.Marshal(task)
if err != nil {
return
@@ -892,7 +1036,7 @@ func (d *dispatcher) deliverPush(taskID string, task *Task) {
if cfg.Token != "" {
req.Header.Set("Authorization", "Bearer "+cfg.Token)
}
resp, err := http.DefaultClient.Do(req)
resp, err := d.pushClient().Do(req)
if err == nil && resp.Body != nil {
_ = resp.Body.Close()
}
@@ -1029,6 +1173,27 @@ func requestContext(parent context.Context) context.Context {
return ctx
}
// sseResponse writes the SSE response headers and returns an encoder and a
// flush func for emitting `data:`-framed JSON-RPC events.
func sseResponse(w http.ResponseWriter) (*json.Encoder, func()) {
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.WriteHeader(http.StatusOK)
enc := json.NewEncoder(sseWriter{w: w})
return enc, func() {
if f, ok := w.(http.Flusher); ok {
f.Flush()
}
}
}
// writeSSE emits one JSON-RPC event (result only — never with an error) and flushes.
func writeSSE(enc *json.Encoder, flush func(), id json.RawMessage, result any) {
_ = enc.Encode(rpcResponse{JSONRPC: "2.0", ID: id, Result: result})
flush()
}
type sseWriter struct {
w http.ResponseWriter
}
+201 -100
View File
@@ -9,6 +9,7 @@ import (
"io"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
@@ -99,6 +100,38 @@ func TestAgentCardFromRegistry(t *testing.T) {
}
}
// A2A 0.3.0 discovery is /.well-known/agent-card.json. The card must be
// reachable there (canonical) as well as at the legacy agent.json alias, both
// per-agent and at the single-agent top level.
func TestAgentCardCanonicalWellKnownPath(t *testing.T) {
ts, cleanup := newGatewayWithAgent(t)
defer cleanup()
for _, path := range []string{
"/agents/echo/.well-known/agent-card.json",
"/agents/echo/.well-known/agent.json",
"/agents/echo/skills/task/.well-known/agent-card.json",
} {
resp, err := http.Get(ts.URL + path)
if err != nil {
t.Fatalf("get %s: %v", path, err)
}
if resp.StatusCode != http.StatusOK {
resp.Body.Close()
t.Fatalf("%s status = %d, want 200", path, resp.StatusCode)
}
var card AgentCard
if err := json.NewDecoder(resp.Body).Decode(&card); err != nil {
resp.Body.Close()
t.Fatalf("%s decode card: %v", path, err)
}
resp.Body.Close()
if card.Name != "echo" {
t.Errorf("%s card name = %q, want echo", path, card.Name)
}
}
}
func TestSkillEndpointServesFocusedCardAndRoutesRPC(t *testing.T) {
ts, cleanup := newGatewayWithAgent(t)
defer cleanup()
@@ -191,6 +224,10 @@ func TestMessageSendContinuesExistingTask(t *testing.T) {
func TestPushNotificationConfigDeliversTaskUpdates(t *testing.T) {
d := newDispatcher()
// The test receiver is a loopback httptest server; authorize it the way a
// deployment would authorize a trusted in-cluster push receiver.
d.allowPushURL = func(*url.URL) error { return nil }
d.guardPushDial = false
updates := make(chan Task, 2)
push := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("Authorization"); got != "Bearer secret" {
@@ -335,6 +372,67 @@ func (s *sliceStream) Recv() (*ai.Response, error) {
func (s *sliceStream) Close() error { return nil }
// streamEvent is one decoded SSE JSON-RPC event from a message/stream response.
// A2A streams carry heterogeneous results (Task, status-update, artifact-update)
// discriminated by `kind`, so we keep the raw result and decode on demand.
type streamEvent struct {
Result json.RawMessage `json:"result"`
Error *rpcError `json:"error"`
}
func (e streamEvent) kind() string {
var k struct {
Kind string `json:"kind"`
}
_ = json.Unmarshal(e.Result, &k)
return k.Kind
}
func (e streamEvent) task(t *testing.T) Task {
t.Helper()
var task Task
if err := json.Unmarshal(e.Result, &task); err != nil {
t.Fatalf("decode task event: %v", err)
}
return task
}
func (e streamEvent) status(t *testing.T) TaskStatusUpdateEvent {
t.Helper()
var s TaskStatusUpdateEvent
if err := json.Unmarshal(e.Result, &s); err != nil {
t.Fatalf("decode status-update event: %v", err)
}
return s
}
func (e streamEvent) artifactUpdate(t *testing.T) TaskArtifactUpdateEvent {
t.Helper()
var a TaskArtifactUpdateEvent
if err := json.Unmarshal(e.Result, &a); err != nil {
t.Fatalf("decode artifact-update event: %v", err)
}
return a
}
// collectSSE parses the `data:`-framed JSON-RPC events from an SSE body.
func collectSSE(t *testing.T, body string) []streamEvent {
t.Helper()
var events []streamEvent
for _, line := range strings.Split(strings.TrimSpace(body), "\n") {
line = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(line), "data:"))
if line == "" {
continue
}
var e streamEvent
if err := json.Unmarshal([]byte(line), &e); err != nil {
t.Fatalf("decode event %q: %v", line, err)
}
events = append(events, e)
}
return events
}
func TestMessageStreamChunksStoreFinalTask(t *testing.T) {
d := newDispatcher()
body := `{"jsonrpc":"2.0","id":1,"method":"message/stream","params":{"message":{"role":"user","parts":[{"kind":"text","text":"ping"}],"kind":"message"}}}`
@@ -351,47 +449,61 @@ func TestMessageStreamChunksStoreFinalTask(t *testing.T) {
if ct := rr.Result().Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/event-stream") {
t.Fatalf("content-type = %q, want text/event-stream", ct)
}
var events []struct {
Result Task `json:"result"`
Error *rpcError `json:"error"`
events := collectSSE(t, rr.Body.String())
// Opening Task snapshot + one append artifact-update per chunk + terminal
// status-update.
if len(events) != 4 {
t.Fatalf("events = %d, want 4; body %s", len(events), rr.Body.String())
}
for _, line := range strings.Split(strings.TrimSpace(rr.Body.String()), "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
line = strings.TrimPrefix(line, "data: ")
var event struct {
Result Task `json:"result"`
Error *rpcError `json:"error"`
}
if err := json.Unmarshal([]byte(line), &event); err != nil {
t.Fatalf("decode event %q: %v", line, err)
}
events = append(events, event)
}
if len(events) != 3 {
t.Fatalf("events = %d, want 3; body %s", len(events), rr.Body.String())
}
for i, event := range events {
if event.Error != nil {
t.Fatalf("event %d error: %+v", i, event.Error)
}
if event.Result.ID != events[0].Result.ID || event.Result.ContextID != events[0].Result.ContextID {
t.Fatalf("event %d changed task identity: %+v vs %+v", i, event.Result, events[0].Result)
for i, e := range events {
if e.Error != nil {
t.Fatalf("event %d carried an error field: %+v", i, e.Error)
}
}
if events[0].Result.Status.State != stateWorking || textOf(events[0].Result.Artifacts[0].Parts) != "po" {
t.Fatalf("first event = %+v, want working po", events[0].Result)
if events[0].kind() != "task" {
t.Fatalf("first event kind = %q, want task", events[0].kind())
}
final := events[len(events)-1].Result
if final.Status.State != stateCompleted || textOf(final.Artifacts[0].Parts) != "pong" {
t.Fatalf("final event = %+v, want completed pong", final)
opening := events[0].task(t)
if opening.Status.State != stateWorking {
t.Fatalf("opening task state = %q, want working", opening.Status.State)
}
taskID := opening.ID
// The middle events are append artifact-updates carrying the chunk deltas.
var text strings.Builder
for _, e := range events[1:3] {
if e.kind() != "artifact-update" {
t.Fatalf("event kind = %q, want artifact-update", e.kind())
}
au := e.artifactUpdate(t)
if !au.Append {
t.Fatalf("artifact-update should be append: %+v", au)
}
if au.TaskID != taskID {
t.Fatalf("artifact-update taskId = %q, want %q", au.TaskID, taskID)
}
text.WriteString(textOf(au.Artifact.Parts))
}
if text.String() != "pong" {
t.Fatalf("accumulated artifact text = %q, want pong", text.String())
}
got := rpcTaskFromDispatcher(t, d, final.ID)
if got.ID != final.ID || got.Status.State != stateCompleted || textOf(got.Artifacts[0].Parts) != "pong" {
t.Fatalf("stored task = %+v, want final", got)
// The stream closes with a terminal status-update (final:true).
last := events[len(events)-1]
if last.kind() != "status-update" {
t.Fatalf("last event kind = %q, want status-update", last.kind())
}
su := last.status(t)
if !su.Final || su.Status.State != stateCompleted {
t.Fatalf("terminal event = %+v, want final completed", su)
}
if su.TaskID != taskID {
t.Fatalf("terminal taskId = %q, want %q", su.TaskID, taskID)
}
got := rpcTaskFromDispatcher(t, d, taskID)
if got.ID != taskID || got.Status.State != stateCompleted || textOf(got.Artifacts[0].Parts) != "pong" {
t.Fatalf("stored task = %+v, want final completed pong", got)
}
}
@@ -432,37 +544,31 @@ func TestMessageStreamChunksPropagatesCancellationAndClosesStream(t *testing.T)
t.Fatal("stream was not closed")
}
var events []struct {
Result Task `json:"result"`
Error *rpcError `json:"error"`
events := collectSSE(t, rr.Body.String())
// Opening Task snapshot, then a terminal failed status-update.
if len(events) != 2 {
t.Fatalf("events = %d, want 2; body %s", len(events), rr.Body.String())
}
for _, line := range strings.Split(strings.TrimSpace(rr.Body.String()), "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
// A streaming failure must be a failed status-update, never `result` and
// `error` set together in one response.
for i, e := range events {
if e.Error != nil {
t.Fatalf("event %d carried an error field (result+error not allowed): %+v", i, e.Error)
}
line = strings.TrimPrefix(line, "data: ")
var event struct {
Result Task `json:"result"`
Error *rpcError `json:"error"`
}
if err := json.Unmarshal([]byte(line), &event); err != nil {
t.Fatalf("decode event %q: %v", line, err)
}
events = append(events, event)
}
if len(events) != 1 {
t.Fatalf("events = %d, want 1; body %s", len(events), rr.Body.String())
if events[0].kind() != "task" || events[0].task(t).Status.State != stateWorking {
t.Fatalf("first event = %s, want working task", string(events[0].Result))
}
event := events[0]
if event.Error == nil || event.Error.Code != errInternal || event.Error.Message != context.Canceled.Error() {
t.Fatalf("error = %+v, want context cancellation", event.Error)
last := events[1]
if last.kind() != "status-update" {
t.Fatalf("last event kind = %q, want status-update", last.kind())
}
if event.Result.Status.State != stateFailed || textOf(event.Result.Artifacts[0].Parts) != "error: context canceled" {
t.Fatalf("failed task = %+v, want context cancellation artifact", event.Result)
su := last.status(t)
if !su.Final || su.Status.State != stateFailed {
t.Fatalf("terminal event = %+v, want final failed", su)
}
got := rpcTaskFromDispatcher(t, d, event.Result.ID)
got := rpcTaskFromDispatcher(t, d, su.TaskID)
if got.Status.State != stateFailed || textOf(got.Artifacts[0].Parts) != "error: context canceled" {
t.Fatalf("stored task = %+v, want failed cancellation", got)
}
@@ -493,33 +599,24 @@ func TestMessageStreamChunksFallsBackWhenUnsupported(t *testing.T) {
if ct := rr.Result().Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/event-stream") {
t.Fatalf("content-type = %q, want text/event-stream", ct)
}
var events []struct {
Result Task `json:"result"`
Error *rpcError `json:"error"`
events := collectSSE(t, rr.Body.String())
// The non-streaming fallback emits a completed Task snapshot then a terminal
// status-update.
if len(events) != 2 {
t.Fatalf("events = %d, want 2; body %s", len(events), rr.Body.String())
}
for _, line := range strings.Split(strings.TrimSpace(rr.Body.String()), "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
for i, e := range events {
if e.Error != nil {
t.Fatalf("fallback event %d error: %+v", i, e.Error)
}
line = strings.TrimPrefix(line, "data: ")
var event struct {
Result Task `json:"result"`
Error *rpcError `json:"error"`
}
if err := json.Unmarshal([]byte(line), &event); err != nil {
t.Fatalf("decode event %q: %v", line, err)
}
events = append(events, event)
}
if len(events) != 1 {
t.Fatalf("events = %d, want 1; body %s", len(events), rr.Body.String())
task := events[0].task(t)
if task.Status.State != stateCompleted || textOf(task.Artifacts[0].Parts) != "pong" {
t.Fatalf("fallback task = %+v, want completed pong", task)
}
if events[0].Error != nil {
t.Fatalf("fallback event error: %+v", events[0].Error)
}
if events[0].Result.Status.State != stateCompleted || textOf(events[0].Result.Artifacts[0].Parts) != "pong" {
t.Fatalf("fallback task = %+v, want completed pong", events[0].Result)
su := events[1].status(t)
if !su.Final || su.Status.State != stateCompleted {
t.Fatalf("terminal event = %+v, want final completed", su)
}
}
@@ -535,30 +632,34 @@ func TestMessageStreamFallbackDoesNotCompleteWithEmptyText(t *testing.T) {
return nil, fmt.Errorf("%w: test provider", ai.ErrStreamingUnsupported)
})
var event struct {
Result Task `json:"result"`
Error *rpcError `json:"error"`
}
for _, line := range strings.Split(strings.TrimSpace(rr.Body.String()), "\n") {
line = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(line), "data: "))
if line == "" {
continue
events := collectSSE(t, rr.Body.String())
var task Task
var foundTask bool
for _, e := range events {
if e.Error != nil {
t.Fatalf("fallback event error: %+v", e.Error)
}
if err := json.Unmarshal([]byte(line), &event); err != nil {
t.Fatalf("decode event %q: %v", line, err)
if e.kind() == "task" {
task = e.task(t)
foundTask = true
}
}
if event.Error != nil {
t.Fatalf("fallback event error: %+v", event.Error)
if !foundTask {
t.Fatalf("no task event in stream; body %s", rr.Body.String())
}
if event.Result.Status.State != stateFailed {
t.Fatalf("fallback state = %q, want failed", event.Result.Status.State)
if task.Status.State != stateFailed {
t.Fatalf("fallback state = %q, want failed", task.Status.State)
}
if got := textOf(event.Result.Artifacts[0].Parts); got == "" {
t.Fatalf("fallback artifact text is empty: %+v", event.Result.Artifacts)
if got := textOf(task.Artifacts[0].Parts); got == "" {
t.Fatalf("fallback artifact text is empty: %+v", task.Artifacts)
}
if got := textOf(event.Result.History[len(event.Result.History)-1].Parts); got == "" {
t.Fatalf("fallback history text is empty: %+v", event.Result.History)
if got := textOf(task.History[len(task.History)-1].Parts); got == "" {
t.Fatalf("fallback history text is empty: %+v", task.History)
}
// The stream still ends with a terminal marker.
last := events[len(events)-1]
if last.kind() != "status-update" || !last.status(t).Final {
t.Fatalf("stream must end with a final status-update; got %s", string(last.Result))
}
}
+80
View File
@@ -1,7 +1,10 @@
package a2a
import (
"context"
"crypto/ed25519"
"encoding/json"
"fmt"
"strings"
"testing"
"time"
@@ -50,6 +53,83 @@ func TestAP2PaymentMandateX402RailReference(t *testing.T) {
}
}
// TestAP2GatewayVerifiesInboundPaymentMandate drives a real A2A message/send
// carrying a signed x402 payment mandate through the gateway and asserts the
// mandate is verified (and the x402 rail carried) into the task a paid path
// consults — and that a tampered mandate is surfaced as unverified.
func TestAP2GatewayVerifiesInboundPaymentMandate(t *testing.T) {
pub, priv := testAP2Key(t)
d := newDispatcher()
d.ap2Verify = func(s AP2SignedMandate, task Task) AP2Verification {
return VerifyAP2ForTask(s, pub, task, nil)
}
invoke := func(context.Context, string) (string, error) { return "fetched", nil }
send := func(t *testing.T, mandate AP2SignedMandate) Task {
t.Helper()
msg := AP2AttachMandate(
Message{Role: "user", Kind: "message", MessageID: "m1", Parts: []Part{{Kind: "text", Text: "pay and fetch"}}},
mandate,
)
params, err := json.Marshal(sendParams{Message: msg})
if err != nil {
t.Fatal(err)
}
body := fmt.Sprintf(`{"jsonrpc":"2.0","id":1,"method":"message/send","params":%s}`, params)
return rpcTaskFromBody(t, d, body, invoke)
}
rail := X402AP2Rail("payreq_777")
good, err := SignAP2Mandate(AP2Mandate{ID: "pay-1", Kind: AP2PaymentMandate, Rail: &rail, IssuedAt: time.Unix(1, 0).UTC()}, "k", priv)
if err != nil {
t.Fatal(err)
}
task := send(t, good)
if len(task.AP2Verifications) != 1 || !task.AP2Verifications[0].Verified {
t.Fatalf("inbound payment mandate not verified: %+v", task.AP2Verifications)
}
if task.AP2Verifications[0].Kind != string(AP2PaymentMandate) {
t.Errorf("verification kind = %q, want payment", task.AP2Verifications[0].Kind)
}
if len(task.AP2Mandates) != 1 || task.AP2Mandates[0].Mandate.Rail == nil ||
task.AP2Mandates[0].Mandate.Rail.Type != "x402" || task.AP2Mandates[0].Mandate.Rail.Reference != "payreq_777" {
t.Fatalf("x402 settlement rail not carried onto task: %+v", task.AP2Mandates)
}
tampered := good
tampered.Mandate.Amount = "999.00"
bad := send(t, tampered)
if len(bad.AP2Verifications) != 1 || bad.AP2Verifications[0].Verified {
t.Fatalf("tampered mandate should be unverified: %+v", bad.AP2Verifications)
}
if !strings.Contains(bad.AP2Verifications[0].Error, "signature") {
t.Errorf("tampered verification error = %q, want signature failure", bad.AP2Verifications[0].Error)
}
}
// TestAP2CarriedUnverifiedWithoutKey confirms the default (no configured key)
// is unchanged: mandates are carried but not verified.
func TestAP2CarriedUnverifiedWithoutKey(t *testing.T) {
_, priv := testAP2Key(t)
d := newDispatcher() // no ap2Verify configured
rail := X402AP2Rail("payreq_1")
signed, err := SignAP2Mandate(AP2Mandate{ID: "pay-1", Kind: AP2PaymentMandate, Rail: &rail, IssuedAt: time.Unix(1, 0).UTC()}, "k", priv)
if err != nil {
t.Fatal(err)
}
msg := AP2AttachMandate(Message{Role: "user", Kind: "message", MessageID: "m1", Parts: []Part{{Kind: "text", Text: "x"}}}, signed)
params, _ := json.Marshal(sendParams{Message: msg})
body := fmt.Sprintf(`{"jsonrpc":"2.0","id":1,"method":"message/send","params":%s}`, params)
task := rpcTaskFromBody(t, d, body, func(context.Context, string) (string, error) { return "ok", nil })
if len(task.AP2Mandates) != 1 {
t.Fatalf("mandate should still be carried: %+v", task.AP2Mandates)
}
if len(task.AP2Verifications) != 0 {
t.Errorf("no verifications without a configured key, got %+v", task.AP2Verifications)
}
}
func TestAP2TamperCasesFailDistinctly(t *testing.T) {
pub, priv := testAP2Key(t)
rail := X402AP2Rail("payreq_123")
+4 -1
View File
@@ -6,6 +6,7 @@ import (
"errors"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
@@ -56,9 +57,11 @@ func TestClientSendAndCard(t *testing.T) {
func TestClientContinuesTaskAndConfiguresPush(t *testing.T) {
card := Card("solo", "http://localhost:4000", "", []string{"task"})
// The push receiver below is a loopback test server; authorize it as a
// deployment would authorize its trusted push receiver.
h := NewAgentHandler(card, func(_ context.Context, text string) (string, error) {
return "echo:" + text, nil
})
}, WithPushURLPolicy(func(*url.URL) error { return nil }))
ts := httptest.NewServer(h)
defer ts.Close()
+131
View File
@@ -0,0 +1,131 @@
package a2a
import (
"fmt"
"net"
"net/http"
"net/url"
"syscall"
"time"
)
// Push-notification callbacks are the one place the A2A gateway makes an
// outbound HTTP request to an address chosen by a (possibly untrusted) caller:
// tasks/pushNotificationConfig/set records a URL and deliverPush POSTs task
// state to it. Without a guard that is a server-side request forgery vector —
// a caller can aim the gateway at loopback, link-local (cloud metadata), or
// private hosts it would otherwise never reach.
//
// The default policy allows only http/https callbacks whose host does not
// resolve to a loopback, private, link-local, or unspecified address, and the
// guarded HTTP client re-checks the *resolved* IP at dial time so a hostname
// that passes validation cannot be rebound to an internal address before the
// connection is made. Operators who need to reach a trusted in-cluster
// receiver set Options.AllowPushURL to take over the policy.
// pushLookupIP resolves a host to IPs; overridable in tests.
var pushLookupIP = net.LookupIP
// defaultPushURLPolicy is the SSRF-safe policy applied when no AllowPushURL is
// configured. It rejects non-http(s) schemes and hosts that resolve to a
// loopback, private, link-local, multicast, or unspecified address.
func defaultPushURLPolicy(u *url.URL) error {
switch u.Scheme {
case "http", "https":
default:
return fmt.Errorf("push callback scheme %q not allowed (want http or https)", u.Scheme)
}
host := u.Hostname()
if host == "" {
return fmt.Errorf("push callback url has no host")
}
ips, err := resolvePushHost(host)
if err != nil {
return fmt.Errorf("push callback host %q: %w", host, err)
}
if len(ips) == 0 {
return fmt.Errorf("push callback host %q did not resolve", host)
}
for _, ip := range ips {
if blockedPushIP(ip) {
return fmt.Errorf("push callback host %q resolves to a blocked address %s", host, ip)
}
}
return nil
}
func resolvePushHost(host string) ([]net.IP, error) {
if ip := net.ParseIP(host); ip != nil {
return []net.IP{ip}, nil
}
return pushLookupIP(host)
}
// blockedPushIP reports whether ip is one an outbound push callback must not
// reach: loopback, private (RFC1918 / ULA), link-local (incl. 169.254.169.254
// cloud metadata), multicast, or the unspecified address.
func blockedPushIP(ip net.IP) bool {
return ip == nil ||
ip.IsLoopback() ||
ip.IsPrivate() ||
ip.IsLinkLocalUnicast() ||
ip.IsLinkLocalMulticast() ||
ip.IsInterfaceLocalMulticast() ||
ip.IsMulticast() ||
ip.IsUnspecified()
}
// pushDialControl runs after DNS resolution, immediately before connect, on the
// resolved address — so it blocks a host that passed URL validation but was
// rebound to an internal IP (DNS rebinding).
func pushDialControl(_, address string, _ syscall.RawConn) error {
host, _, err := net.SplitHostPort(address)
if err != nil {
return err
}
ip := net.ParseIP(host)
if ip == nil {
return fmt.Errorf("push callback: cannot parse dial address %q", address)
}
if blockedPushIP(ip) {
return fmt.Errorf("push callback: refusing to connect to blocked address %s", ip)
}
return nil
}
// pushGuardClient is the HTTP client used for default-policy push delivery. Its
// dialer refuses connections to blocked addresses at connect time.
var pushGuardClient = &http.Client{
Timeout: 10 * time.Second,
Transport: &http.Transport{
Proxy: http.ProxyFromEnvironment,
DialContext: (&net.Dialer{
Timeout: 5 * time.Second,
Control: pushDialControl,
}).DialContext,
},
}
// checkPushURL validates a callback URL against the dispatcher's effective
// policy (Options.AllowPushURL, or the default SSRF-safe policy).
func (d *dispatcher) checkPushURL(raw string) error {
u, err := url.Parse(raw)
if err != nil {
return fmt.Errorf("invalid push callback url: %w", err)
}
policy := d.allowPushURL
if policy == nil {
policy = defaultPushURLPolicy
}
return policy(u)
}
// pushClient is the HTTP client deliverPush uses: the guarded client under the
// default policy, or the default client when an operator has taken over the
// policy via Options.AllowPushURL (they own the trust decision then).
func (d *dispatcher) pushClient() *http.Client {
if d.guardPushDial {
return pushGuardClient
}
return http.DefaultClient
}
+147
View File
@@ -0,0 +1,147 @@
package a2a
import (
"encoding/json"
"net"
"net/http"
"net/http/httptest"
"net/url"
"testing"
)
func TestDefaultPushURLPolicy(t *testing.T) {
// Resolve test hostnames deterministically without real DNS.
orig := pushLookupIP
pushLookupIP = func(host string) ([]net.IP, error) {
switch host {
case "internal.example":
return []net.IP{net.ParseIP("10.1.2.3")}, nil
case "public.example":
return []net.IP{net.ParseIP("93.184.216.34")}, nil
case "rebind.example":
// A host that resolves to both a public and an internal IP must be
// rejected — any blocked address is disqualifying.
return []net.IP{net.ParseIP("93.184.216.34"), net.ParseIP("127.0.0.1")}, nil
}
return nil, &net.DNSError{Err: "no such host", Name: host, IsNotFound: true}
}
defer func() { pushLookupIP = orig }()
blocked := []string{
"http://127.0.0.1/hook", // loopback
"http://169.254.169.254/latest/meta", // cloud metadata (link-local)
"http://10.0.0.5/hook", // RFC1918
"http://[::1]/hook", // IPv6 loopback
"http://[fd00::1]/hook", // IPv6 ULA (private)
"http://0.0.0.0/hook", // unspecified
"http://internal.example/hook", // hostname → private
"http://rebind.example/hook", // one internal IP among many
"ftp://public.example/hook", // non-http(s) scheme
"file:///etc/passwd", // scheme
"http:///nohost", // no host
}
for _, raw := range blocked {
u, err := url.Parse(raw)
if err != nil {
t.Fatalf("parse %q: %v", raw, err)
}
if err := defaultPushURLPolicy(u); err == nil {
t.Errorf("defaultPushURLPolicy(%q) = nil, want blocked", raw)
}
}
allowed := []string{
"http://93.184.216.34/hook", // public literal IP
"https://public.example/hook", // hostname → public
}
for _, raw := range allowed {
u, _ := url.Parse(raw)
if err := defaultPushURLPolicy(u); err != nil {
t.Errorf("defaultPushURLPolicy(%q) = %v, want allowed", raw, err)
}
}
}
func TestPushDialControlBlocksPrivate(t *testing.T) {
blocked := []string{"127.0.0.1:80", "169.254.169.254:80", "10.0.0.1:443", "[::1]:80", "0.0.0.0:80"}
for _, addr := range blocked {
if err := pushDialControl("tcp", addr, nil); err == nil {
t.Errorf("pushDialControl(%q) = nil, want blocked", addr)
}
}
if err := pushDialControl("tcp", "8.8.8.8:443", nil); err != nil {
t.Errorf("pushDialControl(public) = %v, want allowed", err)
}
}
// TestSetPushConfigRejectsSSRFURL: an untrusted caller cannot register a
// callback pointing at an internal address — it is refused and nothing stored.
func TestSetPushConfigRejectsSSRFURL(t *testing.T) {
d := newDispatcher()
d.store(&Task{ID: "t1", ContextID: "c1", Status: TaskStatus{State: stateCompleted}})
params, _ := json.Marshal(map[string]any{
"id": "t1",
"pushNotificationConfig": map[string]any{"url": "http://169.254.169.254/latest/meta-data"},
})
rr := httptest.NewRecorder()
d.setPushConfig(rr, rpcRequest{JSONRPC: "2.0", ID: json.RawMessage("1"), Params: params})
var resp rpcResponse
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v", err)
}
if resp.Error == nil || resp.Error.Code != errInvalidParams {
t.Fatalf("response = %+v, want invalid-params rejection", resp)
}
d.mu.Lock()
_, stored := d.pushConfigs["t1"]
d.mu.Unlock()
if stored {
t.Error("SSRF callback url must not be stored")
}
}
// TestDeliverPushBlocksInternalByDefault: even if a config for an internal URL
// slips into the map, deliverPush must not POST to it under the default policy.
func TestDeliverPushBlocksInternalByDefault(t *testing.T) {
var hit bool
srv := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { hit = true }))
defer srv.Close() // srv.URL is http://127.0.0.1:PORT — loopback, must be blocked
d := newDispatcher()
task := &Task{ID: "t1", Status: TaskStatus{State: stateCompleted}}
d.pushConfigs["t1"] = PushNotificationConfig{URL: srv.URL}
d.deliverPush("t1", task)
if hit {
t.Error("deliverPush reached a loopback callback under the default policy")
}
}
// TestAllowPushURLOverrideDelivers: an operator policy can authorize a trusted
// (here loopback) receiver, and delivery then goes through.
func TestAllowPushURLOverrideDelivers(t *testing.T) {
done := make(chan struct{}, 1)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Content-Type") == "application/json" {
done <- struct{}{}
}
}))
defer srv.Close()
g := New(Options{AllowPushURL: func(*url.URL) error { return nil }})
d := g.disp
if d.guardPushDial {
t.Fatal("custom AllowPushURL should disable the dial guard")
}
task := &Task{ID: "t1", Status: TaskStatus{State: stateCompleted}}
d.pushConfigs["t1"] = PushNotificationConfig{URL: srv.URL}
d.deliverPush("t1", task)
select {
case <-done:
default:
t.Error("operator-authorized callback was not delivered")
}
}
+282
View File
@@ -0,0 +1,282 @@
package mcp
import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
reflectionpb "google.golang.org/grpc/reflection/grpc_reflection_v1alpha"
"google.golang.org/protobuf/encoding/protojson"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/reflect/protodesc"
"google.golang.org/protobuf/reflect/protoreflect"
"google.golang.org/protobuf/reflect/protoregistry"
"google.golang.org/protobuf/types/descriptorpb"
"google.golang.org/protobuf/types/dynamicpb"
)
// ReflectedGRPCTarget describes an external gRPC server whose reflection
// catalog should be exposed as MCP tools. It is intentionally opt-in: teams can
// bridge existing reflected gRPC services without changing their servers or
// registering them in go-micro.
type ReflectedGRPCTarget struct {
// Name prefixes generated tools. When empty, Address is sanitized and used.
Name string
// Address is the host:port of the reflected gRPC server.
Address string
// DialOptions customize the connection. If none are supplied, an insecure
// transport is used for local/dev interoperability.
DialOptions []grpc.DialOption
// Timeout bounds reflection discovery and individual tool calls.
Timeout time.Duration
}
func (s *Server) discoverReflectedGRPC() error {
for _, target := range s.opts.ReflectedGRPCTargets {
if strings.TrimSpace(target.Address) == "" {
continue
}
tools, err := s.reflectedGRPCTools(target)
if err != nil {
return err
}
for _, tool := range tools {
s.tools[tool.Name] = tool
}
}
return nil
}
func (s *Server) reflectedGRPCTools(target ReflectedGRPCTarget) ([]*Tool, error) {
timeout := target.Timeout
if timeout == 0 {
timeout = 10 * time.Second
}
ctx, cancel := context.WithTimeout(s.opts.Context, timeout)
defer cancel()
dialOpts := target.DialOptions
if len(dialOpts) == 0 {
dialOpts = []grpc.DialOption{grpc.WithTransportCredentials(insecure.NewCredentials())}
}
conn, err := grpc.NewClient(target.Address, dialOpts...)
if err != nil {
return nil, fmt.Errorf("connect reflected grpc target %s: %w", target.Address, err)
}
defer conn.Close()
files, services, err := loadReflectedFiles(ctx, conn)
if err != nil {
return nil, fmt.Errorf("reflect grpc target %s: %w", target.Address, err)
}
prefix := target.Name
if prefix == "" {
prefix = sanitizeToolPart(target.Address)
}
var out []*Tool
for _, serviceName := range services {
desc, err := files.FindDescriptorByName(protoreflect.FullName(serviceName))
if err != nil {
continue
}
svc, ok := desc.(protoreflect.ServiceDescriptor)
if !ok {
continue
}
for i := 0; i < svc.Methods().Len(); i++ {
method := svc.Methods().Get(i)
if method.IsStreamingClient() || method.IsStreamingServer() {
continue
}
fullMethod := "/" + string(svc.FullName()) + "/" + string(method.Name())
toolName := prefix + "." + strings.ReplaceAll(string(svc.FullName()), ".", "_") + "." + string(method.Name())
input := method.Input()
out = append(out, &Tool{
Name: toolName,
Description: fmt.Sprintf("Call reflected gRPC method %s on %s", fullMethod, target.Address),
InputSchema: protoMessageSchema(input),
Handler: reflectedGRPCHandler(target, fullMethod, input, method.Output()),
})
}
}
return out, nil
}
func loadReflectedFiles(ctx context.Context, conn *grpc.ClientConn) (*protoregistryFiles, []string, error) {
client := reflectionpb.NewServerReflectionClient(conn)
stream, err := client.ServerReflectionInfo(ctx)
if err != nil {
return nil, nil, err
}
if err := stream.Send(&reflectionpb.ServerReflectionRequest{MessageRequest: &reflectionpb.ServerReflectionRequest_ListServices{ListServices: ""}}); err != nil {
return nil, nil, err
}
resp, err := stream.Recv()
if err != nil {
return nil, nil, err
}
list := resp.GetListServicesResponse()
if list == nil {
return nil, nil, fmt.Errorf("reflection list services returned %T", resp.MessageResponse)
}
set := &descriptorpb.FileDescriptorSet{}
seen := map[string]bool{}
var services []string
for _, svc := range list.Service {
name := svc.Name
if strings.HasPrefix(name, "grpc.reflection.") {
continue
}
services = append(services, name)
if err := requestFileContainingSymbol(ctx, client, name, set, seen); err != nil {
return nil, nil, err
}
}
files, err := newProtoregistryFiles(set)
if err != nil {
return nil, nil, err
}
return files, services, nil
}
func requestFileContainingSymbol(ctx context.Context, client reflectionpb.ServerReflectionClient, symbol string, set *descriptorpb.FileDescriptorSet, seen map[string]bool) error {
stream, err := client.ServerReflectionInfo(ctx)
if err != nil {
return err
}
if err := stream.Send(&reflectionpb.ServerReflectionRequest{MessageRequest: &reflectionpb.ServerReflectionRequest_FileContainingSymbol{FileContainingSymbol: symbol}}); err != nil {
return err
}
resp, err := stream.Recv()
if err != nil {
return err
}
fd := resp.GetFileDescriptorResponse()
if fd == nil {
return fmt.Errorf("reflection lookup for %s returned %T", symbol, resp.MessageResponse)
}
for _, raw := range fd.FileDescriptorProto {
var file descriptorpb.FileDescriptorProto
if err := proto.Unmarshal(raw, &file); err != nil {
return err
}
name := file.GetName()
if !seen[name] {
seen[name] = true
set.File = append(set.File, &file)
}
}
return nil
}
// protoregistryFiles is a narrow wrapper that keeps imports local to this file.
type protoregistryFiles struct{ files *protoregistry.Files }
func newProtoregistryFiles(set *descriptorpb.FileDescriptorSet) (*protoregistryFiles, error) {
files, err := protodesc.NewFiles(set)
if err != nil {
return nil, err
}
return &protoregistryFiles{files: files}, nil
}
func (p *protoregistryFiles) FindDescriptorByName(name protoreflect.FullName) (protoreflect.Descriptor, error) {
return p.files.FindDescriptorByName(name)
}
func reflectedGRPCHandler(target ReflectedGRPCTarget, fullMethod string, input, output protoreflect.MessageDescriptor) func(map[string]interface{}) (interface{}, error) {
return func(args map[string]interface{}) (interface{}, error) {
timeout := target.Timeout
if timeout == 0 {
timeout = 10 * time.Second
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
dialOpts := target.DialOptions
if len(dialOpts) == 0 {
dialOpts = []grpc.DialOption{grpc.WithTransportCredentials(insecure.NewCredentials())}
}
conn, err := grpc.NewClient(target.Address, dialOpts...)
if err != nil {
return nil, err
}
defer conn.Close()
req := dynamicpb.NewMessage(input)
raw, err := json.Marshal(args)
if err != nil {
return nil, err
}
if err := protojson.Unmarshal(raw, req); err != nil {
return nil, err
}
rsp := dynamicpb.NewMessage(output)
if err := conn.Invoke(ctx, fullMethod, req, rsp); err != nil {
return nil, err
}
b, err := protojson.MarshalOptions{UseProtoNames: true, EmitUnpopulated: true}.Marshal(rsp)
if err != nil {
return nil, err
}
var out interface{}
if err := json.Unmarshal(b, &out); err != nil {
return nil, err
}
return out, nil
}
}
func protoMessageSchema(msg protoreflect.MessageDescriptor) map[string]interface{} {
schema := map[string]interface{}{"type": "object", "properties": map[string]interface{}{}}
props := schema["properties"].(map[string]interface{})
fields := msg.Fields()
for i := 0; i < fields.Len(); i++ {
field := fields.Get(i)
props[field.JSONName()] = protoFieldSchema(field)
}
return schema
}
func protoFieldSchema(field protoreflect.FieldDescriptor) map[string]interface{} {
schema := map[string]interface{}{"type": protoJSONType(field)}
if field.IsList() {
schema["items"] = map[string]interface{}{"type": protoJSONType(field)}
}
if field.Kind() == protoreflect.MessageKind || field.Kind() == protoreflect.GroupKind {
schema = protoMessageSchema(field.Message())
}
return schema
}
func protoJSONType(field protoreflect.FieldDescriptor) string {
if field.IsList() {
return "array"
}
switch field.Kind() {
case protoreflect.BoolKind:
return "boolean"
case protoreflect.Int32Kind, protoreflect.Sint32Kind, protoreflect.Sfixed32Kind,
protoreflect.Uint32Kind, protoreflect.Fixed32Kind, protoreflect.Int64Kind,
protoreflect.Sint64Kind, protoreflect.Sfixed64Kind, protoreflect.Uint64Kind,
protoreflect.Fixed64Kind:
return "integer"
case protoreflect.FloatKind, protoreflect.DoubleKind:
return "number"
case protoreflect.MessageKind, protoreflect.GroupKind:
return "object"
default:
return "string"
}
}
func sanitizeToolPart(s string) string {
r := strings.NewReplacer(":", "_", "/", "_", ".", "_", "-", "_")
return r.Replace(s)
}
+62
View File
@@ -0,0 +1,62 @@
package mcp
import (
"context"
"net"
"testing"
"time"
"google.golang.org/grpc"
helloworld "google.golang.org/grpc/examples/helloworld/helloworld"
"google.golang.org/grpc/reflection"
)
type reflectedGreeter struct {
helloworld.UnimplementedGreeterServer
}
func (reflectedGreeter) SayHello(_ context.Context, req *helloworld.HelloRequest) (*helloworld.HelloReply, error) {
return &helloworld.HelloReply{Message: "hello " + req.Name}, nil
}
func TestReflectedGRPCTargetDiscoversAndCallsUnaryTool(t *testing.T) {
lis, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
grpcServer := grpc.NewServer()
helloworld.RegisterGreeterServer(grpcServer, reflectedGreeter{})
reflection.Register(grpcServer)
go grpcServer.Serve(lis)
defer grpcServer.Stop()
s := newTestServer(Options{Context: context.Background()})
tools, err := s.reflectedGRPCTools(ReflectedGRPCTarget{
Name: "demo",
Address: lis.Addr().String(),
Timeout: 3 * time.Second,
})
if err != nil {
t.Fatalf("discover reflected tools: %v", err)
}
if len(tools) != 1 {
t.Fatalf("tools len = %d, want 1", len(tools))
}
tool := tools[0]
if tool.Name != "demo.helloworld_Greeter.SayHello" {
t.Fatalf("tool name = %q", tool.Name)
}
props := tool.InputSchema["properties"].(map[string]interface{})
if _, ok := props["name"]; !ok {
t.Fatalf("input schema missing name: %#v", tool.InputSchema)
}
out, err := tool.Handler(map[string]interface{}{"name": "Ada"})
if err != nil {
t.Fatalf("call reflected tool: %v", err)
}
got := out.(map[string]interface{})["message"]
if got != "hello Ada" {
t.Fatalf("message = %v, want hello Ada", got)
}
}
+9
View File
@@ -157,6 +157,11 @@ type Options struct {
// (the /mcp/call endpoint). Listing tools and health stay free.
// Opt-in: leave nil to disable payments.
Payment *x402.Config
// ReflectedGRPCTargets exposes unary methods from external gRPC servers
// that support server reflection as MCP tools. This bridges existing gRPC
// services into the agent tool catalog without requiring go-micro handlers.
ReflectedGRPCTargets []ReflectedGRPCTarget
}
// Server represents a running MCP gateway
@@ -286,6 +291,10 @@ func (s *Server) discoverServices() error {
s.toolsMu.Lock()
defer s.toolsMu.Unlock()
if err := s.discoverReflectedGRPC(); err != nil {
return err
}
for _, svc := range services {
// Get full service details
fullSvcs, err := s.opts.Registry.GetService(svc.Name)
+5 -19
View File
@@ -294,7 +294,9 @@ func (t *StdioTransport) handleToolsCall(req *JSONRPCRequest) {
AccountID: accountID, ScopesRequired: tool.Scopes,
Allowed: true, Duration: time.Since(start), Error: err.Error(),
})
t.sendError(req.ID, InternalError, "RPC call failed", err.Error())
// A tool-execution failure is reported as an isError result, not a
// JSON-RPC protocol error (per the MCP spec), so the agent can read it.
t.sendResponse(req.ID, mcpToolError(traceID, "tool call failed: "+err.Error()))
return
}
@@ -311,24 +313,8 @@ func (t *StdioTransport) handleToolsCall(req *JSONRPCRequest) {
Allowed: true, Duration: time.Since(start),
})
// Parse response
var result interface{}
if err := json.Unmarshal(rsp.Data, &result); err != nil {
// If unmarshal fails, return raw data
result = map[string]interface{}{
"data": string(rsp.Data),
}
}
t.sendResponse(req.ID, map[string]interface{}{
"content": []interface{}{
map[string]interface{}{
"type": "text",
"text": fmt.Sprintf("%v", result),
},
},
"trace_id": traceID,
})
// The downstream response is JSON — return it as JSON text, not %v.
t.sendResponse(req.ID, mcpToolResult(traceID, rsp.Data))
}
// sendResponse sends a JSON-RPC response
+120
View File
@@ -0,0 +1,120 @@
package mcp
import (
"bufio"
"bytes"
"context"
"encoding/json"
"errors"
"testing"
"go-micro.dev/v6/client"
)
// fakeCallClient overrides Call to return canned data or an error; NewRequest
// and the rest are promoted from the embedded real client.
type fakeCallClient struct {
client.Client
data []byte
err error
}
func (f *fakeCallClient) Call(ctx context.Context, req client.Request, rsp interface{}, opts ...client.CallOption) error {
if f.err != nil {
return f.err
}
if r, ok := rsp.(*struct{ Data []byte }); ok {
r.Data = f.data
}
return nil
}
// isToolError reports whether an MCP tools/call result carries isError:true.
func isToolError(result interface{}) bool {
m, ok := result.(map[string]interface{})
if !ok {
return false
}
b, _ := m["isError"].(bool)
return b
}
// toolResultText extracts the first text content of an MCP tools/call result.
func toolResultText(t *testing.T, result interface{}) string {
t.Helper()
m, ok := result.(map[string]interface{})
if !ok {
t.Fatalf("result is not a map: %#v", result)
}
content, ok := m["content"].([]interface{})
if !ok || len(content) == 0 {
t.Fatalf("result has no content: %#v", result)
}
first, _ := content[0].(map[string]interface{})
text, _ := first["text"].(string)
return text
}
// driveStdio sends one JSON-RPC request through a StdioTransport and returns the
// decoded response, capturing the transport's stdout into a buffer.
func driveStdio(t *testing.T, s *Server, method string, id interface{}, params interface{}) JSONRPCResponse {
t.Helper()
tr := NewStdioTransport(s)
var out bytes.Buffer
tr.writer = bufio.NewWriter(&out)
raw, _ := json.Marshal(params)
tr.handleRequest(&JSONRPCRequest{JSONRPC: "2.0", ID: id, Method: method, Params: raw})
var resp JSONRPCResponse
if err := json.Unmarshal(bytes.TrimSpace(out.Bytes()), &resp); err != nil {
t.Fatalf("decode stdio response: %v (raw=%q)", err, out.String())
}
return resp
}
// The stdio transport is the path an external MCP host (Claude Desktop) uses.
// It must return tool output as JSON text, not fmt.Sprintf("%v", ...) which
// yields Go map-syntax and is unparseable by a real client.
func TestStdio_ToolsCall_ReturnsJSONNotGoSyntax(t *testing.T) {
s := newTestServer(Options{})
s.opts.Client = &fakeCallClient{Client: client.DefaultClient, data: []byte(`{"id":1,"name":"bob"}`)}
s.tools["svc.Echo"] = &Tool{Name: "svc.Echo", Service: "svc", Endpoint: "Echo"}
resp := driveStdio(t, s, "tools/call", 1, map[string]interface{}{
"name": "svc.Echo",
"arguments": map[string]interface{}{"msg": "hi"},
})
if resp.Error != nil {
t.Fatalf("unexpected protocol error: %+v", resp.Error)
}
text := toolResultText(t, resp.Result)
// The bug returned Go map-syntax ("map[id:1 name:bob]"), which fails to parse.
var got map[string]interface{}
if err := json.Unmarshal([]byte(text), &got); err != nil {
t.Fatalf("tool result text is not JSON (the %%v bug): %q", text)
}
if got["name"] != "bob" {
t.Errorf("result = %v, want name=bob", got)
}
}
// A tool-execution failure must be an MCP isError result, not a JSON-RPC
// protocol error, so the agent can read the failure.
func TestStdio_ToolsCall_FailureIsIsErrorResult(t *testing.T) {
s := newTestServer(Options{})
s.opts.Client = &fakeCallClient{Client: client.DefaultClient, err: errors.New("backend down")}
s.tools["svc.Echo"] = &Tool{Name: "svc.Echo", Service: "svc", Endpoint: "Echo"}
resp := driveStdio(t, s, "tools/call", 1, map[string]interface{}{
"name": "svc.Echo",
"arguments": map[string]interface{}{},
})
if resp.Error != nil {
t.Fatalf("tool failure returned a protocol error, want isError result: %+v", resp.Error)
}
if !isToolError(resp.Result) {
t.Fatalf("expected isError result, got %+v", resp.Result)
}
if text := toolResultText(t, resp.Result); text == "" {
t.Error("isError result should carry the error text")
}
}
+32
View File
@@ -0,0 +1,32 @@
package mcp
// MCP tools/call result shaping, shared by the stdio and websocket JSON-RPC
// transports. Kept in one place so both transports produce spec-shaped results.
// mcpToolResult builds a successful MCP tools/call result. The downstream RPC
// response body (data) is JSON, so it is returned as JSON text — NOT
// fmt.Sprintf("%v", ...) of a decoded value, which produces Go map-syntax
// (map[id:1 name:bob]) instead of JSON and is what an external MCP client
// (e.g. Claude Desktop over stdio) would otherwise receive.
func mcpToolResult(traceID string, data []byte) map[string]interface{} {
return map[string]interface{}{
"content": []interface{}{
map[string]interface{}{"type": "text", "text": string(data)},
},
"trace_id": traceID,
}
}
// mcpToolError builds an MCP tools/call result for a tool-EXECUTION failure.
// Per the MCP spec a tool that fails returns a normal result with isError:true
// (the error as text content), NOT a JSON-RPC protocol error — that way the
// agent can read the failure instead of seeing a transport-level error.
func mcpToolError(traceID, msg string) map[string]interface{} {
return map[string]interface{}{
"content": []interface{}{
map[string]interface{}{"type": "text", "text": msg},
},
"isError": true,
"trace_id": traceID,
}
}
+4 -18
View File
@@ -275,7 +275,8 @@ func (wc *wsConn) handleToolsCall(req *JSONRPCRequest) {
AccountID: accountID, ScopesRequired: tool.Scopes,
Allowed: true, Duration: time.Since(start), Error: err.Error(),
})
wc.sendError(req.ID, InternalError, "RPC call failed", err.Error())
// Tool-execution failure → isError result (MCP spec), not a protocol error.
wc.sendResponse(req.ID, mcpToolError(traceID, "tool call failed: "+err.Error()))
return
}
@@ -291,23 +292,8 @@ func (wc *wsConn) handleToolsCall(req *JSONRPCRequest) {
Allowed: true, Duration: time.Since(start),
})
// Parse response
var result interface{}
if err := json.Unmarshal(rsp.Data, &result); err != nil {
result = map[string]interface{}{
"data": string(rsp.Data),
}
}
wc.sendResponse(req.ID, map[string]interface{}{
"content": []interface{}{
map[string]interface{}{
"type": "text",
"text": fmt.Sprintf("%v", result),
},
},
"trace_id": traceID,
})
// The downstream response is JSON — return it as JSON text, not %v.
wc.sendResponse(req.ID, mcpToolResult(traceID, rsp.Data))
}
// sendResponse sends a JSON-RPC success response.
+18 -15
View File
@@ -114,12 +114,13 @@ func TestWebSocket_ToolsCall_NoAuth(t *testing.T) {
"arguments": map[string]interface{}{"msg": "hi"},
})
// RPC will fail (no backend), but auth should pass (no auth configured)
if resp.Error == nil {
t.Fatal("expected RPC error (no backend)")
// No auth required → the tool runs; the RPC fails (no backend), which the
// MCP spec surfaces as an isError result, not a JSON-RPC protocol error.
if resp.Error != nil {
t.Fatalf("expected no protocol error, got %+v", resp.Error)
}
if resp.Error.Code != InternalError {
t.Errorf("error code = %d, want %d", resp.Error.Code, InternalError)
if !isToolError(resp.Result) {
t.Fatalf("expected isError tool result, got %+v", resp.Result)
}
}
@@ -168,12 +169,13 @@ func TestWebSocket_ToolsCall_AuthRequired(t *testing.T) {
"arguments": map[string]interface{}{},
"_token": "valid-token",
})
// Auth passes, RPC fails (no backend)
if resp.Error == nil {
t.Fatal("expected RPC error")
// Auth passes → the tool runs; RPC fails (no backend) → isError result,
// not a JSON-RPC protocol error (which would mean auth failed).
if resp.Error != nil {
t.Fatalf("expected no protocol error (auth passed), got %+v", resp.Error)
}
if resp.Error.Code != InternalError {
t.Errorf("error code = %d, want %d (RPC fail, not auth fail)", resp.Error.Code, InternalError)
if !isToolError(resp.Result) {
t.Fatalf("expected isError tool result, got %+v", resp.Result)
}
})
@@ -185,12 +187,13 @@ func TestWebSocket_ToolsCall_AuthRequired(t *testing.T) {
"name": "svc.Do",
"arguments": map[string]interface{}{},
})
// Auth passes via connection-level header, RPC fails (no backend)
if resp.Error == nil {
t.Fatal("expected RPC error")
// Auth passes via connection-level header → tool runs; RPC fails (no
// backend) → isError result, not a JSON-RPC protocol error.
if resp.Error != nil {
t.Fatalf("expected no protocol error (auth passed), got %+v", resp.Error)
}
if resp.Error.Code != InternalError {
t.Errorf("error code = %d, want %d (RPC fail, not auth fail)", resp.Error.Code, InternalError)
if !isToolError(resp.Result) {
t.Fatalf("expected isError tool result, got %+v", resp.Result)
}
})
}
-68
View File
@@ -1,68 +0,0 @@
# Gap Audit — the integration/exposure surface (MCP, A2A, x402) + foundations
A code-grounded robustness audit of the surfaces the strategy rests on, plus the
two foundations a real app (Mu) discovered it needed. Pair this with the
"requirements discovered from Mu" notes — together they are the roadmap's
evidence base. Each finding is `file:line`, severity, and what "robust in
practice" requires.
## Headline
All four surfaces are **well-built internally and fragile at the edge.** The
strategic spine — **MCP gateway, A2A, x402** — is *demo-robust, not
production-robust*: it works go-micro-to-go-micro and is **unverified or broken
against the real external clients the whole "integration and exposure" strategy
depends on.** Not one test drives a real external MCP host, a real third-party
A2A SDK, or a real x402 facilitator/wallet.
There are two kinds of hardening, and the loop was doing the wrong one: guarding
docs and chasing a weak provider's quirks is grooming; making MCP actually speak
MCP to Claude Desktop, A2A interoperate with a real external agent, and x402
actually settle is **strategic** hardening. The axis is *advances the strategy*
vs *grooms a proxy*, not *capability* vs *hardening*.
## MCP gateway — `gateway/mcp/`
Works as go-micro plumbing; does not speak MCP to the outside world.
- **BLOCKER** `mcp.go:610` — default HTTP transport is bespoke `{tool,input}` REST, not JSON-RPC/MCP. A conformant JSON-RPC handler exists (`httpjsonrpc.go` `NewHandler`) but is **never mounted**. → mount it / implement Streamable HTTP; unify transports behind one pre-call pipeline.
- **BLOCKER** `stdio.go:327`, `websocket.go:306` — tool results are `fmt.Sprintf("%v", result)` → Go map-syntax, not JSON, on the path Claude Desktop uses. **Zero stdio tests.** → marshal JSON; add a stdio round-trip test.
- **BLOCKER** `stdio.go:297`, `websocket.go:278` — downstream errors returned as JSON-RPC protocol errors, not `{isError:true}` results. → wrap as tool-error results.
- **BLOCKER** `websocket.go:20``CheckOrigin` always `true` (DNS-rebinding); and `/mcp/ws` **bypasses payment + circuit breaker** → paid tools free over WS. → origin allowlist; one shared pre-call pipeline.
- **MAJOR** deregistered tools never pruned (`mcp.go:280`); watcher never recovers + no `list_changed` (`mcp.go:564`); unbounded goroutines, no `recover()` (`stdio.go:112`); no HTTP/WS timeouts or body limits; unauthenticated `micro_store_write`/`micro_broker_publish` by default (`mcp.go:474`).
## A2A gateway — `gateway/a2a/`
Clean binding; cross-framework interop unproven.
- **MAJOR** `a2a.go:111` — well-known path is `agent.json`; spec 0.3.0 serves `agent-card.json` → external clients 404. → serve both.
- **MAJOR** `a2a.go:587``message/stream` emits full `Task` snapshots, not `TaskStatusUpdateEvent`/`TaskArtifactUpdateEvent` with `final:true`; `:596` sets JSON-RPC `result`+`error` together (spec violation). → emit discriminated update events.
- **MAJOR** `a2a.go:584` — streaming ignores write errors / client disconnect (burns tokens on a dead socket); `:531` "streaming" is single-shot despite `streaming:true`; `:508` `tasks/cancel` is a stub; `:874` push callbacks SSRF-open + auth ignored; **no gateway auth / no security schemes** (`:342`); in-memory state breaks multi-replica (`:466`).
- **Critical:** no test against a real third-party A2A SDK — all interop claims self-certified.
## x402 — `wrapper/x402/`
Clean, spec-aware scaffold; no real money can move.
- **BLOCKER** — no real wallet `Payer` (no EIP-3009 signer); buyer `Client` wired into nothing — the agent's "spend budget" (`agent/builtin.go:380` `spendWrap`) is bookkeeping that never pays; CDP mainnet settlement unreachable from the CLI (creds never attached). This is the flagship (#4786).
- **MAJOR** `client.go:84``ParseInt` error swallowed → malformed amount parses to `0`, spend-cap check trivially passes while the Payer signs the string amount. **Fix as part of #4786.**
- **MAJOR** `x402.go:225` — verify-only facilitator serves the resource for free; `:198` no replay/idempotency; `:262` non-conformant settlement header; `client.go:83` no network/asset validation before signing.
## Foundations (Mu-discovered)
### In-process dispatch — `client/`, `transport/`
- **MAJOR** `client/rpc_client.go:148` — no in-process fast-path: an in-process `Call` still dials a transport and simulates a network hop; `transport/memory.go:82` double-serializes (gob over a pipe on top of the RPC codec) with ~45 goroutine handoffs. ~64µs/187 allocs confirmed. → a local transport / direct `router.ServeRequest` dispatch (the server already keeps a process-local handler table; the codec already passes `*Frame` bodies through unserialized). **Low-risk; plausibly low-single-digit µs.**
### Durable agentic workflow — `flow/`
`flow/` is genuinely close on the *deterministic* axis (checkpoints, resumes without replaying completed steps, `ParentID`, retry). The gap is the *agentic* axis:
- **BLOCKER** `flow/steps.go:107` — no human-in-the-loop pause (no `waiting` state / `Resume(runID, input)`). Exactly what Mu hand-rolled. → add a `waiting` status + await-input signal + resume-with-input.
- **MAJOR** `flow/steps.go:269` — the agent's dynamic plan→tool→tool loop is one opaque flow step; a crash mid-turn replays every tool call. The durable unit is a fixed step list, not the agent's pausable per-tool-call loop — the convergence thesis is unmet. → checkpoint per tool call.
- **MAJOR** `flow/loop.go:82` `Loop` not per-iteration checkpointed; `flow/steps.go:457` at-least-once, not exactly-once (duplicate side effects on resume); `:158` no run leasing for multi-replica.
## Build order (prioritized)
1. **MCP stdio: real JSON + `isError` results + a round-trip test.** Cheapest, highest-impact — the Claude Desktop path, currently emitting garbage. *(loop-buildable)*
2. **MCP: mount JSON-RPC as the HTTP transport + unify all transports behind one pre-call pipeline.** *(architectural — human-reviewed)*
3. **x402 flagship (#4786) done right:** signing `Payer`, buyer wired into the agent seam, budget-bypass fix, require a real `Settler`. *(mixed — the wiring is human-reviewed; the budget-bypass fix is loop-buildable)*
4. **Durable agentic workflow:** HITL pause + per-tool-call checkpointing in `flow`. *(architectural — human-reviewed)*
5. **In-process dispatch fast-path** (local transport). *(foundational, low-risk)*
6. **A2A external conformance:** well-known path + SSE event shapes, then a test against a real third-party A2A SDK. *(loop-buildable + a test-setup task)*
The architectural items (2, 4, 5) are the "real 1:1 development" work — too central and too ambiguous to hand to an autonomous agent. The well-scoped items (1, 3-budget-fix, 6) are what a forced, well-defined loop task looks like.
+47 -15
View File
@@ -166,7 +166,9 @@ func main() {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
if summary.WorkingEvents == 0 || summary.State != "completed" || !strings.Contains(summary.FinalText, "a2a-stream-ok") {
// Spec-shaped stream: at least one artifact-update carrying the reassembled
// answer, terminating in a completed status-update with final:true.
if summary.ArtifactEvents == 0 || summary.State != "completed" || !summary.Final || !strings.Contains(summary.FinalText, "a2a-stream-ok") {
fmt.Fprintf(os.Stderr, "unexpected stream summary: %+v\npayload:\n%s", summary, summary.Payload)
os.Exit(1)
}
@@ -174,14 +176,16 @@ func main() {
fmt.Fprintf(os.Stderr, "tool=%v runInfo=%v\n", sawTool, sawRunInfo)
os.Exit(1)
}
fmt.Println("\n\033[32m✓ A2A message/stream emitted incremental task updates and preserved tool/run metadata\033[0m")
fmt.Println("\n\033[32m✓ A2A message/stream emitted spec-shaped artifact/status updates and preserved tool/run metadata\033[0m")
}
type streamSummary struct {
Payload string
State string
FinalText string
WorkingEvents int
Payload string
State string
FinalText string
Final bool
ArtifactEvents int
WorkingEvents int
}
func readSSESummary(r io.Reader) (streamSummary, error) {
@@ -197,14 +201,23 @@ func readSSESummary(r io.Reader) (streamSummary, error) {
}
var envelope struct {
Result struct {
Kind string `json:"kind"`
Final bool `json:"final"`
Status struct {
State string `json:"state"`
} `json:"status"`
// Task snapshots carry artifacts (plural)...
Artifacts []struct {
Parts []struct {
Text string `json:"text"`
} `json:"parts"`
} `json:"artifacts"`
// ...artifact-update events carry a single artifact.
Artifact struct {
Parts []struct {
Text string `json:"text"`
} `json:"parts"`
} `json:"artifact"`
} `json:"result"`
Error any `json:"error"`
}
@@ -216,15 +229,34 @@ func readSSESummary(r io.Reader) (streamSummary, error) {
}
seen = true
summary.Payload += data + "\n"
if envelope.Result.Status.State == "working" {
summary.WorkingEvents++
}
if envelope.Result.Status.State != "" {
summary.State = envelope.Result.Status.State
}
for _, artifact := range envelope.Result.Artifacts {
for _, part := range artifact.Parts {
summary.FinalText = part.Text
switch envelope.Result.Kind {
case "artifact-update":
// Incremental deltas: reassemble the streamed answer.
summary.ArtifactEvents++
for _, part := range envelope.Result.Artifact.Parts {
summary.FinalText += part.Text
}
case "status-update":
if envelope.Result.Status.State != "" {
summary.State = envelope.Result.Status.State
}
if envelope.Result.Final {
summary.Final = true
}
default: // "task" snapshot
if envelope.Result.Status.State == "working" {
summary.WorkingEvents++
}
if envelope.Result.Status.State != "" {
summary.State = envelope.Result.Status.State
}
// The non-streaming path carries the full text in the snapshot.
for _, artifact := range envelope.Result.Artifacts {
for _, part := range artifact.Parts {
if part.Text != "" {
summary.FinalText = part.Text
}
}
}
}
return nil
+51
View File
@@ -0,0 +1,51 @@
// Package network is a process-local registry of server dispatchers — the
// neutral seam an in-process client fast-path uses to reach a server running in
// the same process without going over the network transport.
//
// It lives in internal/ and speaks only in transport.Message so neither the
// client nor the server package has to import the other: a running server
// registers a Handler under its service name; an opted-in client looks one up
// and dispatches directly, skipping dial, codec-over-socket, and the transport
// pump. Nothing here runs unless a server registers and a client opts in.
package network
import (
"context"
"sync"
"go-micro.dev/v6/transport"
)
// Handler dispatches one request against a process-local server's handler
// table and returns the reply. req and the returned message carry the same
// codec-encoded body + headers the transport would have carried.
type Handler func(ctx context.Context, req *transport.Message) (*transport.Message, error)
var (
mu sync.RWMutex
reg = map[string]Handler{}
)
// Register makes service reachable in-process via h. A server calls this when
// it starts; calling again replaces the handler.
func Register(service string, h Handler) {
mu.Lock()
reg[service] = h
mu.Unlock()
}
// Deregister removes service's in-process handler. A server calls this when it
// stops, so a later in-process call falls back to the network path.
func Deregister(service string) {
mu.Lock()
delete(reg, service)
mu.Unlock()
}
// Lookup returns the in-process handler for service, if one is registered.
func Lookup(service string) (Handler, bool) {
mu.RLock()
h, ok := reg[service]
mu.RUnlock()
return h, ok
}
+62 -20
View File
@@ -150,29 +150,71 @@ See [Native gRPC Compatibility](grpc-compatibility.md) for a complete guide.
## vs Dapr
### Dapr Approach
- Multi-language via sidecar
- Rich building blocks (state, pub/sub, bindings)
- Cloud-native focused
- Requires running sidecar process
[Dapr](https://dapr.io/) is a distributed application runtime. Its building
blocks cover service invocation, state, pub/sub, bindings, secrets,
configuration, distributed locks, actors, jobs, and workflow, usually accessed
through a sidecar from many languages. [Dapr Agents](https://docs.dapr.io/developing-ai/dapr-agents/)
adds an agent framework on top of those runtime capabilities.
### Go Micro Approach
- Go library, no sidecar
- Direct service-to-service calls
- Simpler deployment
- Lower latency (no extra hop)
Go Micro overlaps with Dapr on distributed-systems primitives, but the product
shape is different: Go Micro is a Go framework where services, agents, tools,
and flows are built from the same runtime. A service endpoint can become an
AI-callable tool, and an agent is itself a registered service with memory,
guardrails, planning, delegation, MCP, and A2A around it.
### When to Choose Dapr
- You have polyglot services (Node, Python, Java, etc)
- You want portable abstractions across clouds
- You're fully on Kubernetes
- You need state management abstractions
### Decision table
### When to Choose Go Micro
- You're building Go services
- You want lower latency
- You prefer libraries over sidecars
- You want simpler deployment (no sidecar management)
| Need | Prefer Go Micro | Prefer Dapr | Use both |
|---|---|---|---|
| **Primary language** | Your core runtime is Go and you want library-native APIs | You run a polyglot estate and want one sidecar API across languages | Go services use Go Micro while non-Go services expose Dapr APIs |
| **Agent model** | Agents should be ordinary services: registered, discoverable, callable by RPC, MCP, and A2A | Agents are primarily Python applications using Dapr Agents | Dapr-hosted agents call Go Micro MCP tools, or Go Micro agents call Dapr-backed services |
| **Tools** | Existing service endpoints should become tools with minimal extra code | Tools are modeled through Dapr components, bindings, or agent framework code | Use Dapr components behind Go Micro services that expose a stable tool surface |
| **Workflows** | Deterministic steps should live beside Go services and agents in the same codebase | You want Dapr Workflow's sidecar-backed orchestration model across languages | Let Dapr own cross-language workflows and let Go Micro own Go-native agent/tool execution |
| **State and pub/sub** | You want Go interfaces and pluggable packages directly in-process | You want component YAML and sidecar portability across backing services | Put portable infrastructure behind Dapr and domain/tool logic in Go Micro |
| **Deployment** | You want a simple Go binary/runtime first, with Kubernetes support as an explicit deployment target | You are already standardized on Dapr sidecars in Kubernetes | Run Go Micro services in clusters that already have Dapr for shared infrastructure |
| **Interop** | MCP and A2A are first-class requirements for exposing services and agents | Dapr's app APIs and agent framework are the integration boundary | Bridge through MCP/A2A at the agent edge and Dapr APIs at the infrastructure edge |
### When to choose Dapr
- You need a **polyglot** runtime contract for Node, Python, Java, .NET, Go, and
other services.
- Your platform team already operates sidecars and component configuration across
Kubernetes clusters.
- You want Dapr's standard building blocks for state, pub/sub, bindings, secrets,
actors, jobs, and workflow more than you want a Go-native service framework.
- You are adopting Dapr Agents and want to stay in its Python-first agent stack.
### When to choose Go Micro
- You are building mostly in Go and want the agent harness to be the same runtime
as your services.
- You want service methods and their comments/examples to become AI-callable tools
without maintaining a separate tool layer.
- You want agents to be deployed, discovered, called, load-balanced, and inspected
like ordinary services.
- You need MCP and A2A at the agent/service boundary, not only an internal
application API.
- You prefer library-native composition and direct Go interfaces over sidecar
component wiring.
### Where Go Micro still needs to prove itself
Dapr has a mature platform narrative and broad deployment footprint. Go Micro's
agent-harness story is sharper for Go teams, but production adoption depends on
keeping the no-secret getting-started path green, documenting durability
semantics clearly, proving MCP/A2A conformance with external clients, and making
Kubernetes deployment first-class.
### Practical migration path
1. Start with one Go Micro service that wraps a real domain capability.
2. Add doc comments and examples so the endpoint is useful as an agent tool.
3. Expose it through MCP for external agents or through A2A if the capability is
itself an agent.
4. If your platform already uses Dapr, keep Dapr components behind the service
boundary and let Go Micro present the agent/tool contract.
5. Move deterministic multi-step work into flows only after the service/tool
boundary is stable.
## vs Agent Frameworks (Google ADK)
+1 -1
View File
@@ -64,7 +64,7 @@ Otherwise continue to read the docs for more information about the framework.
## Advanced
- [Framework Comparison](guides/comparison.html)
- [Framework Comparison](guides/comparison.html) - Including Go Micro vs Dapr for agents, services, and workflows
- [Architecture Decisions](architecture/)
- [Real-World Examples](examples/realworld/)
- [Migration Guides](guides/migration/)
+1
View File
@@ -8,6 +8,7 @@ var Broker = service.Broker
var Cache = service.Cache
var Cmd = service.Cmd
var Client = service.Client
var Local = service.Local
var Context = service.Context
var Handle = service.Handle
var HandleSignal = service.HandleSignal
+119
View File
@@ -0,0 +1,119 @@
package server
import (
"context"
"io"
"go-micro.dev/v6/internal/network"
"go-micro.dev/v6/transport"
"go-micro.dev/v6/transport/headers"
)
// local.go gives a same-process caller a way to reach this server's handlers
// without the network transport. A running server registers a dispatcher in
// internal/network keyed by its name; an opted-in client looks it up and
// calls localDispatch, which serves the request synchronously through the same
// router (so handler wrappers, codecs, and error mapping are identical) over an
// in-memory socket — skipping dial, the transport pump, and the codec-over-pipe
// double serialization. Unary only; streaming and pub/sub keep the normal path.
// localSocket is a transport.Socket that carries exactly one request in and
// captures exactly one reply — no network, no pipe, no gob. Recv delivers the
// request message once (the RPC codec reads it on the first ReadHeader), then
// reports EOF; Send captures the encoded reply.
type localSocket struct {
req *transport.Message
recvd bool
reply *transport.Message
}
func (s *localSocket) Recv(m *transport.Message) error {
if s.recvd || s.req == nil {
return io.EOF
}
s.recvd = true
m.Header = s.req.Header
m.Body = s.req.Body
return nil
}
func (s *localSocket) Send(m *transport.Message) error {
cp := &transport.Message{Header: make(map[string]string, len(m.Header))}
for k, v := range m.Header {
cp.Header[k] = v
}
if len(m.Body) > 0 {
cp.Body = append([]byte(nil), m.Body...)
}
s.reply = cp
return nil
}
func (s *localSocket) Close() error { return nil }
func (s *localSocket) Local() string { return "local" }
func (s *localSocket) Remote() string { return "local" }
// localDispatch serves req against this server's router in-process and returns
// the reply. It mirrors the request/response construction ServeConn does for a
// networked request, so the served path is identical apart from the transport.
func (s *rpcServer) localDispatch(ctx context.Context, req *transport.Message) (*transport.Message, error) {
contentType := req.Header["Content-Type"]
if contentType == "" {
contentType = DefaultContentType
req.Header["Content-Type"] = contentType
}
cf := setupProtocol(req)
if cf == nil {
var err error
if cf, err = s.newCodec(contentType); err != nil {
return nil, err
}
}
sock := &localSocket{req: req}
rcodec := newRPCCodec(req, sock, cf)
request := rpcRequest{
service: getHeader(headers.Request, req.Header),
method: getHeader(headers.Method, req.Header),
endpoint: getHeader(headers.Endpoint, req.Header),
contentType: contentType,
codec: rcodec,
header: req.Header,
body: req.Body,
socket: sock,
}
response := rpcResponse{
header: make(map[string]string),
socket: sock,
codec: rcodec,
}
if err := s.getRouter().ServeRequest(ctx, &request, &response); err != nil {
return nil, err
}
if sock.reply == nil {
// A handler that wrote no body still completed successfully.
return &transport.Message{Header: map[string]string{}}, nil
}
return sock.reply, nil
}
// registerLocal makes this server reachable in-process under its name; called
// on Start. deregisterLocal removes it on Stop.
func (s *rpcServer) registerLocal() {
name := s.Options().Name
if name == "" {
return
}
network.Register(name, s.localDispatch)
}
func (s *rpcServer) deregisterLocal() {
name := s.Options().Name
if name == "" {
return
}
network.Deregister(name)
}
+5
View File
@@ -571,6 +571,9 @@ func (s *rpcServer) Start() error {
// Keep the service registered to registry
go s.registrar(listener, addr, config, exit)
// Make this server reachable in-process for the client fast-path.
s.registerLocal()
s.setStarted(true)
return nil
@@ -581,6 +584,8 @@ func (s *rpcServer) Stop() error {
return nil
}
s.deregisterLocal()
ch := make(chan error)
s.exit <- ch
+12
View File
@@ -111,6 +111,18 @@ func Client(c client.Client) Option {
}
}
// Local enables the in-process fast-path on the service's client: a
// unary call to another service running in the same process — agent tool calls,
// flow dispatch, gateway → service — skips the network transport and dispatches
// straight to that server's handlers (see client.Local). Off by
// default; it falls back to the network path for anything not co-located, so
// it is a pure win for all-in-one binaries and a no-op for distributed ones.
func Local() Option {
return func(o *Options) {
_ = o.Client.Init(client.Local())
}
}
// Context specifies a context for the service.
// Can be used to signal shutdown of the service and for extra option values.
func Context(ctx context.Context) Option {
+14
View File
@@ -0,0 +1,14 @@
package service
import "testing"
func TestLocalOption(t *testing.T) {
// Off by default.
if newOptions().Client.Options().Local {
t.Fatal("Local should be off by default")
}
// Enabled by the option, on the service's own client.
if !newOptions(Local()).Client.Options().Local {
t.Fatal("Local() did not enable the client fast-path")
}
}
+11 -1
View File
@@ -8,6 +8,7 @@ import (
"io"
"net/http"
"strconv"
"strings"
"sync"
)
@@ -81,7 +82,16 @@ func (c *Client) Do(req *http.Request) (*http.Response, error) {
return resp, fmt.Errorf("x402: 402 response carried no requirements")
}
reqd := ch.Accepts[0]
amount, _ := strconv.ParseInt(reqd.MaxAmountRequired, 10, 64)
// The amount governs the whole spend cap, so it must be a real positive
// integer. A swallowed parse error (non-decimal, overflow, empty) would
// yield 0 and pass the budget check trivially, and a negative amount would
// inflate the remaining allowance — either way the cap is defeated. Refuse
// before signing anything.
amount, err := strconv.ParseInt(strings.TrimSpace(reqd.MaxAmountRequired), 10, 64)
if err != nil || amount <= 0 {
return resp, fmt.Errorf("x402: refusing to pay %s: invalid maxAmountRequired %q",
reqd.Resource, reqd.MaxAmountRequired)
}
// Spend cap: reserve before paying so concurrent calls cannot all pass
// the check and overspend the caller's allowance. Roll the reservation
+27
View File
@@ -172,6 +172,33 @@ func TestClientBudgetReservationRollsBackOnPayError(t *testing.T) {
}
}
// A 402 whose maxAmountRequired is not a positive integer must be refused
// before any payment — otherwise a swallowed parse error (0) or a negative
// amount defeats the spend cap. The payer is never called and nothing is spent.
func TestClientRefusesInvalidAmount(t *testing.T) {
for _, amount := range []string{"abc", "-100", "99999999999999999999999999", "0x10", "1.5"} {
srv := paidServer(amount)
payer := &mockPayer{}
c := &Client{Payer: payer, Budget: 1_000_000}
req, _ := http.NewRequest(http.MethodGet, srv.URL, nil)
resp, err := c.Do(req)
if resp != nil {
resp.Body.Close()
}
if err == nil {
t.Errorf("amount %q: expected refusal, got nil error", amount)
}
if payer.calls != 0 {
t.Errorf("amount %q: payer called %d times, want 0", amount, payer.calls)
}
if c.Spent() != 0 {
t.Errorf("amount %q: spent %d, want 0", amount, c.Spent())
}
srv.Close()
}
}
type payerFunc func(context.Context, Requirements) (string, error)
func (f payerFunc) Pay(ctx context.Context, req Requirements) (string, error) {
+13 -1
View File
@@ -126,6 +126,11 @@ type Config struct {
// FacilitatorURL is the verify/settle endpoint used when Facilitator
// is nil (e.g. Coinbase CDP or Alchemy).
FacilitatorURL string `json:"facilitator,omitempty"`
// RequireSettlement fails closed when a paid request cannot be settled:
// if the facilitator only verifies (does not implement Settler), Require
// refuses to serve rather than releasing the resource while no funds move.
// Leave false only for verify-only flows where authorization is enough.
RequireSettlement bool `json:"requireSettlement,omitempty"`
}
func (c Config) network() string {
@@ -222,7 +227,14 @@ func (c Config) Require(w http.ResponseWriter, r *http.Request, amount, resource
}
// Capture the funds when the facilitator can settle. Verify alone only
// authorizes the "exact" transfer; settlement broadcasts it.
if s, ok := fac.(Settler); ok {
s, canSettle := fac.(Settler)
if c.RequireSettlement && !canSettle {
// Fail closed: a paid config must not serve the resource on a
// verify-only facilitator, or it gives the tool away for free.
writeChallenge(w, req, "payment settlement unavailable")
return false
}
if canSettle {
sres, err := s.Settle(r.Context(), payment, req)
if err != nil {
writeChallenge(w, req, "payment settlement failed: "+err.Error())
+54
View File
@@ -159,4 +159,58 @@ func TestCDPAuthorizeAttachesBearer(t *testing.T) {
}
}
// TestRequireSettlementFailsClosed checks that a paid config with
// RequireSettlement refuses to serve when the facilitator only verifies (does
// not settle) — otherwise the resource is released while no funds move.
func TestRequireSettlementFailsClosed(t *testing.T) {
// mockFacilitator implements Verify but not Settler.
cfg := Config{PayTo: "0xpay", Facilitator: mockFacilitator{valid: true}, RequireSettlement: true}
r := httptest.NewRequest(http.MethodGet, "/tool", nil)
r.Header.Set(PaymentHeader, "eyJ4IjoxfQ==")
rec := httptest.NewRecorder()
if cfg.Require(rec, r, "10000", "chat") {
t.Fatal("Require should fail closed when settlement is required but unavailable")
}
if rec.Code != http.StatusPaymentRequired {
t.Errorf("status = %d, want 402", rec.Code)
}
// Without RequireSettlement the verify-only facilitator still serves.
cfg.RequireSettlement = false
rec = httptest.NewRecorder()
r = httptest.NewRequest(http.MethodGet, "/tool", nil)
r.Header.Set(PaymentHeader, "eyJ4IjoxfQ==")
if !cfg.Require(rec, r, "10000", "chat") {
t.Fatalf("verify-only should serve when settlement is not required; body=%s", rec.Body.String())
}
}
// TestRequireSettlementServesWithSettler checks that a paid config with
// RequireSettlement serves when the facilitator can settle.
func TestRequireSettlementServesWithSettler(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/verify":
_ = json.NewEncoder(w).Encode(map[string]any{"isValid": true})
case "/settle":
_ = json.NewEncoder(w).Encode(map[string]any{"success": true, "transaction": "0xabc"})
}
}))
defer srv.Close()
// HTTPFacilitator implements Settler.
cfg := Config{PayTo: "0xpay", FacilitatorURL: srv.URL, RequireSettlement: true}
r := httptest.NewRequest(http.MethodGet, "/tool", nil)
r.Header.Set(PaymentHeader, "eyJ4IjoxfQ==")
rec := httptest.NewRecorder()
if !cfg.Require(rec, r, "10000", "chat") {
t.Fatalf("Require should serve with a settling facilitator; body=%s", rec.Body.String())
}
if got := rec.Header().Get(PaymentResponseHeader); got != "0xabc" {
t.Errorf("settlement header = %q, want 0xabc", got)
}
}
var _ Settler = (*HTTPFacilitator)(nil)