From c6fb31eeacec3c2c4bb1beab93c73f4847131f8e Mon Sep 17 00:00:00 2001 From: Vivien Ramahandry <56304555+vramahandry@users.noreply.github.com> Date: Wed, 19 Aug 2026 16:00:01 +0200 Subject: [PATCH] fix(go-adk): accept standard A2A JSON-RPC method names alongside a2a-go/v2's native ones MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit go/adk's A2A server serves JSON-RPC directly through a2a-go/v2's native dispatcher, which only recognizes its own bare method names (e.g. "SendMessage", "GetTask"). Every public A2A client SDK (e.g. @a2a-js/sdk, used by external A2A bridges) still sends the standard, slash-namespaced method names from the wider A2A protocol spec (e.g. "message/send", "tasks/get") — a2a-go/v2 ships a compatibility shim for exactly this (a2acompat/a2av0) but nothing in kagent wires it in, so any standards- compliant client gets a bare "-32601 method not found" against a raw go/adk agent port. The two naming conventions never collide (native names are bare identifiers; legacy names are always namespaced with a "/"), so a single peek at the request body's "method" field is enough to route correctly between a2a-go/v2's native JSON-RPC handler and the SDK's own a2av0 compatibility handler, both built from the same RequestHandler. Signed-off-by: Vivien Ramahandry <56304555+vramahandry@users.noreply.github.com> --- go/adk/pkg/a2a/server/server.go | 44 +++++++++++++- go/adk/pkg/a2a/server/server_test.go | 87 ++++++++++++++++++++++++++++ go/go.mod | 3 +- go/go.sum | 2 + 4 files changed, 134 insertions(+), 2 deletions(-) diff --git a/go/adk/pkg/a2a/server/server.go b/go/adk/pkg/a2a/server/server.go index 88ceca9b7..1cccb99e3 100644 --- a/go/adk/pkg/a2a/server/server.go +++ b/go/adk/pkg/a2a/server/server.go @@ -1,8 +1,11 @@ package server import ( + "bytes" "context" + "encoding/json" "fmt" + "io" "net" "net/http" "os" @@ -13,6 +16,7 @@ import ( "time" a2atype "github.com/a2aproject/a2a-go/v2/a2a" + "github.com/a2aproject/a2a-go/v2/a2acompat/a2av0" a2agrpc "github.com/a2aproject/a2a-go/v2/a2agrpc/v1" a2apb "github.com/a2aproject/a2a-go/v2/a2apb/v1" "github.com/a2aproject/a2a-go/v2/a2asrv" @@ -51,7 +55,7 @@ type A2AServer struct { // NewA2AServer creates a new A2A server using a2asrv. func NewA2AServer(agentCard a2atype.AgentCard, executor a2asrv.AgentExecutor, logger logr.Logger, config ServerConfig, handlerOpts ...a2asrv.RequestHandlerOption) (*A2AServer, error) { requestHandler := a2asrv.NewHandler(executor, handlerOpts...) - jsonrpcHandler := a2asrv.NewJSONRPCHandler(requestHandler) + jsonrpcHandler := newCompatJSONRPCHandler(requestHandler) if maxContentLength := getMaxContentLength(logger); maxContentLength != nil { jsonrpcHandler = withRequestSizeLimit(jsonrpcHandler, *maxContentLength) } @@ -170,6 +174,44 @@ func getMaxContentLength(logger logr.Logger) *int64 { return &maxContentLength } +// newCompatJSONRPCHandler serves both the native a2a-go/v2 JSON-RPC method +// names (e.g. "SendMessage") and the standard A2A protocol's slash-namespaced +// names (e.g. "message/send") against the same RequestHandler. Every public +// A2A client SDK (e.g. @a2a-js/sdk) still sends the slash-namespaced names; +// a2a-go/v2's own dispatcher only recognizes the newer bare names, with no +// compatibility shim wired in anywhere upstream of this. The two conventions +// never collide — every native v2 method name is a bare identifier, while +// every legacy name is namespaced with a "/" — so a single peek at the +// request body's "method" field is enough to route correctly. +func newCompatJSONRPCHandler(handler a2asrv.RequestHandler) http.Handler { + native := a2asrv.NewJSONRPCHandler(handler) + legacy := a2av0.NewJSONRPCHandler(handler) + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + native.ServeHTTP(w, r) + return + } + body, err := io.ReadAll(r.Body) + if err != nil { + // Let the native handler read (and fail on) the same body again, + // so a size-limited or otherwise broken body still produces the + // usual JSON-RPC error response instead of a bespoke one here. + native.ServeHTTP(w, r) + return + } + r.Body = io.NopCloser(bytes.NewReader(body)) + + var probe struct { + Method string `json:"method"` + } + if json.Unmarshal(body, &probe) == nil && strings.Contains(probe.Method, "/") { + legacy.ServeHTTP(w, r) + return + } + native.ServeHTTP(w, r) + }) +} + func withRequestSizeLimit(next http.Handler, maxContentLength int64) http.Handler { sizeLimitedHandler := http.MaxBytesHandler(next, maxContentLength) return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/go/adk/pkg/a2a/server/server_test.go b/go/adk/pkg/a2a/server/server_test.go index 5ca716016..703d7fb7c 100644 --- a/go/adk/pkg/a2a/server/server_test.go +++ b/go/adk/pkg/a2a/server/server_test.go @@ -240,6 +240,93 @@ func TestNoPreResponseFlushByDefault(t *testing.T) { } } +// sendJSONRPC posts a raw JSON-RPC body to a fresh test server and returns +// the recorded response. +func sendJSONRPC(t *testing.T, body []byte) *httptest.ResponseRecorder { + t.Helper() + + srv, err := NewA2AServer(a2atype.AgentCard{}, substrateExecutor{}, logr.Discard(), ServerConfig{Port: "0"}) + if err != nil { + t.Fatalf("NewA2AServer: %v", err) + } + + req := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + srv.httpServer.Handler.ServeHTTP(rec, req) + return rec +} + +func jsonrpcErrorCode(t *testing.T, rec *httptest.ResponseRecorder) *int { + t.Helper() + + var resp struct { + Error *struct { + Code int `json:"code"` + } `json:"error"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode response: %v: %s", err, rec.Body.String()) + } + if resp.Error == nil { + return nil + } + return &resp.Error.Code +} + +// Every public A2A client SDK (e.g. @a2a-js/sdk) sends the standard +// slash-namespaced JSON-RPC method names (e.g. "message/send"), which +// a2a-go/v2's own dispatcher does not recognize on its own. The server must +// still accept them via the a2acompat/a2av0 shim, alongside the native +// bare-name methods it already handles. +func TestJSONRPCCompatDualDispatch(t *testing.T) { + t.Run("legacy method name dispatches successfully", func(t *testing.T) { + body, err := json.Marshal(map[string]any{ + "jsonrpc": "2.0", + "id": "1", + "method": "message/send", + "params": map[string]any{ + "message": map[string]any{ + "kind": "message", + "messageId": "test-msg-1", + "role": "user", + "parts": []map[string]any{{"kind": "text", "text": "hi"}}, + }, + }, + }) + if err != nil { + t.Fatalf("marshal request: %v", err) + } + + rec := sendJSONRPC(t, body) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d: %s", rec.Code, http.StatusOK, rec.Body.String()) + } + if code := jsonrpcErrorCode(t, rec); code != nil { + t.Fatalf("legacy message/send returned JSON-RPC error %d: %s", *code, rec.Body.String()) + } + }) + + t.Run("unrecognized legacy-shaped method still reports method not found", func(t *testing.T) { + body, err := json.Marshal(map[string]any{ + "jsonrpc": "2.0", + "id": "1", + "method": "tasks/doesNotExist", + "params": map[string]any{}, + }) + if err != nil { + t.Fatalf("marshal request: %v", err) + } + + rec := sendJSONRPC(t, body) + const jsonRPCMethodNotFound = -32601 + code := jsonrpcErrorCode(t, rec) + if code == nil || *code != jsonRPCMethodNotFound { + t.Fatalf("error code = %v, want %d: %s", code, jsonRPCMethodNotFound, rec.Body.String()) + } + }) +} + func TestA2ARequestSizeLimit(t *testing.T) { tests := []struct { name string diff --git a/go/go.mod b/go/go.mod index c128e6e06..c255e99ab 100644 --- a/go/go.mod +++ b/go/go.mod @@ -77,6 +77,7 @@ require ( go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0 go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0 go.opentelemetry.io/otel/sdk/log v0.20.0 + golang.org/x/sync v0.22.0 google.golang.org/grpc v1.83.0 istio.io/istio v0.0.0-20260813103411-08b6897f9095 k8s.io/apiextensions-apiserver v0.36.3 @@ -115,6 +116,7 @@ require ( github.com/Microsoft/go-winio v0.6.2 // indirect github.com/MirrexOne/unqueryvet v1.5.4 // indirect github.com/OpenPeeDeeP/depguard/v2 v2.2.1 // indirect + github.com/a2aproject/a2a-go v0.3.15 // indirect github.com/abiosoft/ishell v2.0.0+incompatible // indirect github.com/abiosoft/readline v0.0.0-20180607040430-155bce2042db // indirect github.com/alecthomas/chroma/v2 v2.24.1 // indirect @@ -446,7 +448,6 @@ require ( golang.org/x/mod v0.37.0 // indirect golang.org/x/net v0.57.0 // indirect golang.org/x/oauth2 v0.36.0 // indirect - golang.org/x/sync v0.22.0 // indirect golang.org/x/sys v0.47.0 // indirect golang.org/x/term v0.45.0 // indirect golang.org/x/time v0.15.0 // indirect diff --git a/go/go.sum b/go/go.sum index 1ad6a738f..d713d245e 100644 --- a/go/go.sum +++ b/go/go.sum @@ -76,6 +76,8 @@ github.com/MirrexOne/unqueryvet v1.5.4 h1:38QOxShO7JmMWT+eCdDMbcUgGCOeJphVkzzRgy github.com/MirrexOne/unqueryvet v1.5.4/go.mod h1:fs9Zq6eh1LRIhsDIsxf9PONVUjYdFHdtkHIgZdJnyPU= github.com/OpenPeeDeeP/depguard/v2 v2.2.1 h1:vckeWVESWp6Qog7UZSARNqfu/cZqvki8zsuj3piCMx4= github.com/OpenPeeDeeP/depguard/v2 v2.2.1/go.mod h1:q4DKzC4UcVaAvcfd41CZh0PWpGgzrVxUYBlgKNGquUo= +github.com/a2aproject/a2a-go v0.3.15 h1:h5YpCiPq3jxQ5rIns7oDjPag3ivP8u817AzdA4F+NiI= +github.com/a2aproject/a2a-go v0.3.15/go.mod h1:I7Cm+a1oL+UT6zMoP+roaRE5vdfUa1iQGVN8aSOuZ0I= github.com/a2aproject/a2a-go/v2 v2.3.1 h1:QWMdOX2UsJ8BJmjs952eo1FRyGsOVl0gFCKeM76AgGE= github.com/a2aproject/a2a-go/v2 v2.3.1/go.mod h1:mkZr8y2bUgAVQsjs/5fHK7xrRlAHDybMEyxWh2tKRC8= github.com/abiosoft/ishell v2.0.0+incompatible h1:zpwIuEHc37EzrsIYah3cpevrIc8Oma7oZPxr03tlmmw=