diff --git a/authbridge/authlib/plugins/a2aparser/plugin.go b/authbridge/authlib/plugins/a2aparser/plugin.go index 6e012753..d9fc8f85 100644 --- a/authbridge/authlib/plugins/a2aparser/plugin.go +++ b/authbridge/authlib/plugins/a2aparser/plugin.go @@ -5,6 +5,7 @@ import ( "context" "encoding/json" "log/slog" + "strings" "github.com/rossoctl/cortex/authbridge/authlib/pipeline" "github.com/rossoctl/cortex/authbridge/authlib/plugins" @@ -48,6 +49,20 @@ func (p *A2AParser) OnRequest(_ context.Context, pctx *pipeline.Context) pipelin return pipeline.Action{Type: pipeline.Continue} } + // Claim only A2A-namespace methods. Both A2A and MCP ride JSON-RPC 2.0 + // over HTTP, so "the method is non-empty" cannot tell them apart — the + // method namespace is the only in-body signal that does. Gating here + // keeps this parser from claiming traffic that is JSON-RPC but belongs + // to another protocol — MCP (initialize, tools/list, notifications/*, + // ...) — or a non-JSON-RPC body such as an inference /v1/messages call + // (which unmarshals into JSONRPCRequest with an empty Method). Without + // it, abctl showed a phantom a2a-parser match on both MCP and inference + // traffic. mcp-parser has the mirror guard for its own namespace. + if !isA2AMethod(rpc.Method) { + slog.Debug("a2a-parser: not an A2A-namespace method, skipping", "method", rpc.Method, "bodyLen", len(pctx.Body)) + return pipeline.Action{Type: pipeline.Continue} + } + ext := &pipeline.A2AExtension{ Method: rpc.Method, RPCID: rpc.ID, @@ -432,6 +447,22 @@ func parseA2AParts(rawParts []any) []pipeline.A2APart { return parts } +// isA2AMethod reports whether a JSON-RPC method name belongs to the A2A +// protocol namespace. A2A methods are slash-namespaced under message/, +// tasks/, and agent/ (per the A2A spec: message/send, message/stream, +// tasks/get, tasks/list, tasks/cancel, tasks/resubscribe, +// tasks/pushNotificationConfig/*, agent/getAuthenticatedExtendedCard). +// This positively identifies A2A traffic — declining MCP JSON-RPC and +// non-JSON-RPC bodies (empty method) that also unmarshal into a +// JSONRPCRequest. It stays forward-compatible within A2A's own namespace +// design: future message/*, tasks/*, and agent/* methods match without a +// code change. +func isA2AMethod(method string) bool { + return strings.HasPrefix(method, "message/") || + strings.HasPrefix(method, "tasks/") || + strings.HasPrefix(method, "agent/") +} + // isA2AAction reports whether an A2A JSON-RPC method name names a // user-meaningful agent-to-agent call that guardrails should judge. // Only methods that carry a user message into the agent (or out to diff --git a/authbridge/authlib/plugins/a2aparser/plugin_test.go b/authbridge/authlib/plugins/a2aparser/plugin_test.go index d926d4c5..3f750775 100644 --- a/authbridge/authlib/plugins/a2aparser/plugin_test.go +++ b/authbridge/authlib/plugins/a2aparser/plugin_test.go @@ -330,6 +330,90 @@ func TestA2AParser_InvalidJSON(t *testing.T) { } } +// A JSON body with no JSON-RPC "method" is not A2A traffic. Inference +// payloads (Anthropic /v1/messages, OpenAI /v1/chat/completions) parse +// cleanly into JSONRPCRequest with a zero-value Method, so the parser +// must NOT attach an A2AExtension or record a "matched_" observe for +// them — otherwise abctl shows a phantom a2a-parser match on every +// inference call. Regression for the empty-method guard. +func TestA2AParser_NonJSONRPCBody_NoMatch(t *testing.T) { + cases := []struct { + name string + body string + }{ + {"anthropic messages", `{"model":"claude-opus-4-8","max_tokens":1024,"messages":[{"role":"user","content":"hello"}]}`}, + {"openai chat completions", `{"model":"gpt-4","messages":[{"role":"user","content":"hi"}],"stream":true}`}, + {"empty object", `{}`}, + {"explicit empty method", `{"jsonrpc":"2.0","id":1,"method":"","params":{}}`}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + p := NewA2AParser() + pctx := &pipeline.Context{Body: []byte(tc.body)} + + action := p.OnRequest(context.Background(), pctx) + if action.Type != pipeline.Continue { + t.Fatalf("expected Continue, got %v", action.Type) + } + if pctx.Extensions.A2A != nil { + t.Errorf("Extensions.A2A should be nil for non-JSON-RPC body, got %+v", pctx.Extensions.A2A) + } + // No Invocation should be recorded — the parser didn't claim + // this request, so abctl shows no a2a-parser row for it. + if pctx.Extensions.Invocations != nil { + t.Errorf("expected no Invocation recorded, got %+v", pctx.Extensions.Invocations) + } + }) + } +} + +// A2A and MCP both ride JSON-RPC 2.0, so a2a-parser must NOT claim MCP +// methods just because they carry a non-empty JSON-RPC method. Gating on +// the A2A namespace (message/, tasks/, agent/) makes it decline MCP +// traffic (initialize, tools/list, notifications/*, ...) — which is why +// an MCP request should show no a2a-parser row in abctl. Mirror of +// mcp-parser's own-namespace guard. +func TestA2AParser_ForeignNamespaceMethods_Declined(t *testing.T) { + mcpMethods := []string{ + "initialize", + "ping", + "tools/list", + "tools/call", + "resources/read", + "prompts/get", + "notifications/initialized", + } + for _, method := range mcpMethods { + t.Run(method, func(t *testing.T) { + p := NewA2AParser() + pctx := &pipeline.Context{ + Body: []byte(`{"jsonrpc":"2.0","id":1,"method":"` + method + `","params":{}}`), + } + + action := p.OnRequest(context.Background(), pctx) + if action.Type != pipeline.Continue { + t.Fatalf("expected Continue, got %v", action.Type) + } + if pctx.Extensions.A2A != nil { + t.Errorf("Extensions.A2A should be nil for non-A2A method %q, got %+v", method, pctx.Extensions.A2A) + } + if pctx.Extensions.Invocations != nil { + t.Errorf("expected no Invocation for non-A2A method %q, got %+v", method, pctx.Extensions.Invocations) + } + // Declining (no extension attached) means Classification() reports + // "unclassified" — (anyAction=false, anyBypass=false) — NOT a + // recorded bypass. IBAC then applies unclassified_policy (default + // passthrough). Locks the interaction raised in review: scoping the + // parser to its namespace moves out-of-namespace JSON-RPC from + // "bypass" to "unclassified". + if anyAction, anyBypass := pctx.Classification(); anyAction || anyBypass { + t.Errorf("Classification() = (action=%v, bypass=%v) for non-A2A method %q; want (false, false) unclassified", + anyAction, anyBypass, method) + } + }) + } +} + func TestA2AParser_MissingMessage(t *testing.T) { p := NewA2AParser() pctx := &pipeline.Context{ diff --git a/authbridge/authlib/plugins/mcpparser/plugin.go b/authbridge/authlib/plugins/mcpparser/plugin.go index 596b8c0b..bc5aacbb 100644 --- a/authbridge/authlib/plugins/mcpparser/plugin.go +++ b/authbridge/authlib/plugins/mcpparser/plugin.go @@ -6,6 +6,7 @@ import ( "encoding/json" "fmt" "log/slog" + "strings" "github.com/rossoctl/cortex/authbridge/authlib/bypass" "github.com/rossoctl/cortex/authbridge/authlib/pipeline" @@ -136,6 +137,32 @@ func isMCPAction(method string) bool { return false } +// isMCPMethod reports whether a JSON-RPC method name belongs to the MCP +// protocol namespace (spec revision 2025-06-18, Streamable HTTP). MCP +// methods are either the bare handshake verbs initialize/ping or are +// slash-namespaced under notifications/, tools/, resources/, prompts/, +// completion/, logging/, sampling/, roots/, and elicitation/. An empty +// method (non-JSON-RPC body) matches nothing and is declined. Gating on +// the namespace is what distinguishes MCP from A2A traffic, which also +// rides JSON-RPC 2.0 but under message/, tasks/, and agent/. When a +// future MCP revision adds a top-level namespace, extend this set (the +// spec-revision string above is the audit anchor). +func isMCPMethod(method string) bool { + switch method { + case "initialize", "ping": + return true + } + return strings.HasPrefix(method, "notifications/") || + strings.HasPrefix(method, "tools/") || + strings.HasPrefix(method, "resources/") || + strings.HasPrefix(method, "prompts/") || + strings.HasPrefix(method, "completion/") || + strings.HasPrefix(method, "logging/") || + strings.HasPrefix(method, "sampling/") || + strings.HasPrefix(method, "roots/") || + strings.HasPrefix(method, "elicitation/") +} + func (p *MCPParser) OnRequest(_ context.Context, pctx *pipeline.Context) pipeline.Action { // Body-less transport-layer detection. Scoped to the configured // MCP-endpoint paths because there's no protocol payload to @@ -187,13 +214,17 @@ func (p *MCPParser) OnRequest(_ context.Context, pctx *pipeline.Context) pipelin slog.Debug("mcp-parser: body is not valid JSON-RPC", "error", err, "bodyLen", len(pctx.Body)) return pipeline.Action{Type: pipeline.Continue} } - // Empty method → body parses as JSON but isn't a JSON-RPC request - // (e.g. an OpenAI chat/completions body also unmarshals into - // JSONRPCRequest with zero-value fields). Don't attach a useless - // MCPExtension to non-MCP traffic — downstream consumers shouldn't - // see a phantom "mcp: {}" on every inference event. - if rpc.Method == "" { - slog.Debug("mcp-parser: body is JSON but not JSON-RPC, skipping", "bodyLen", len(pctx.Body)) + // Claim only MCP-namespace methods. Both MCP and A2A ride JSON-RPC 2.0 + // over HTTP, so "the method is non-empty" cannot tell them apart — the + // method namespace is the only in-body signal that does. Gating here + // keeps mcp-parser from attaching an MCPExtension to A2A traffic + // (message/send, tasks/get, agent/*, ...) or to a non-JSON-RPC body + // such as an inference /v1/messages call (which unmarshals into + // JSONRPCRequest with an empty Method) — which would otherwise show a + // phantom "mcp: {}" on every such event. a2a-parser has the mirror + // guard for its own namespace. + if !isMCPMethod(rpc.Method) { + slog.Debug("mcp-parser: not an MCP-namespace method, skipping", "method", rpc.Method, "bodyLen", len(pctx.Body)) return pipeline.Action{Type: pipeline.Continue} } diff --git a/authbridge/authlib/plugins/mcpparser/plugin_test.go b/authbridge/authlib/plugins/mcpparser/plugin_test.go index 5f47e2a3..104aaf9d 100644 --- a/authbridge/authlib/plugins/mcpparser/plugin_test.go +++ b/authbridge/authlib/plugins/mcpparser/plugin_test.go @@ -186,6 +186,49 @@ func TestMCPParser_SkipsJSONThatIsNotJSONRPC(t *testing.T) { } } +// MCP and A2A both ride JSON-RPC 2.0, so mcp-parser must NOT claim A2A +// methods just because they carry a non-empty JSON-RPC method. Gating on +// the MCP namespace makes it decline A2A traffic (message/*, tasks/*, +// agent/*) — so an A2A request shows no mcp-parser row in abctl. Mirror +// of a2a-parser's own-namespace guard. +func TestMCPParser_ForeignNamespaceMethods_Declined(t *testing.T) { + a2aMethods := []string{ + "message/send", + "message/stream", + "tasks/get", + "tasks/cancel", + "agent/getAuthenticatedExtendedCard", + } + for _, method := range a2aMethods { + t.Run(method, func(t *testing.T) { + p := NewMCPParser() + pctx := &pipeline.Context{ + Body: []byte(`{"jsonrpc":"2.0","id":1,"method":"` + method + `","params":{}}`), + } + action := p.OnRequest(context.Background(), pctx) + if action.Type != pipeline.Continue { + t.Fatalf("expected Continue, got %v", action.Type) + } + if pctx.Extensions.MCP != nil { + t.Errorf("Extensions.MCP should be nil for non-MCP method %q, got %+v", method, pctx.Extensions.MCP) + } + if pctx.Extensions.Invocations != nil { + t.Errorf("expected no Invocation for non-MCP method %q, got %+v", method, pctx.Extensions.Invocations) + } + // Declining (no extension attached) means Classification() reports + // "unclassified" — (anyAction=false, anyBypass=false) — NOT a + // recorded bypass. IBAC then applies unclassified_policy (default + // passthrough). Locks the interaction raised in review: scoping the + // parser to its namespace moves out-of-namespace JSON-RPC from + // "bypass" to "unclassified". + if anyAction, anyBypass := pctx.Classification(); anyAction || anyBypass { + t.Errorf("Classification() = (action=%v, bypass=%v) for non-MCP method %q; want (false, false) unclassified", + anyAction, anyBypass, method) + } + }) + } +} + func TestMCPParser_InvalidJSON(t *testing.T) { p := NewMCPParser() pctx := &pipeline.Context{Body: []byte("not json")}