Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 43 additions & 1 deletion go/adk/pkg/a2a/server/server.go
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
package server

import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"os"
Expand All @@ -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"
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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) {
Expand Down
87 changes: 87 additions & 0 deletions go/adk/pkg/a2a/server/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion go/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions go/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
Loading