From f3452845169e384d91ddf0df62f96861661f6b84 Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Mon, 27 Jul 2026 16:00:10 -0400 Subject: [PATCH] Fix: scope a2a/mcp parsers to their own JSON-RPC method namespace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit a2a-parser and mcp-parser both parse JSON-RPC 2.0 request bodies, and each claimed a request whenever the body carried a non-empty "method". But A2A and MCP both ride JSON-RPC 2.0 over HTTP, so "method is non-empty" cannot tell them apart: - a2a-parser attached an A2AExtension + a "matched_" observe to MCP calls (initialize, tools/list, notifications/*, ...) and to inference /v1/messages bodies (which unmarshal into JSONRPCRequest with an empty method). - mcp-parser symmetrically claimed A2A traffic (message/send, tasks/*). In abctl this showed up as a phantom second plugin on every MCP and inference request (e.g. an inference row labeled "2 plugins" = inference-parser + a2a-parser). The method namespace is the only in-body signal that distinguishes the two protocols, and the namespaces are disjoint: - A2A: message/*, tasks/*, agent/* - MCP: initialize, ping, notifications/*, tools/*, resources/*, prompts/*, completion/*, logging/*, sampling/*, roots/*, elicitation/* Gate each parser on its own namespace (isA2AMethod / isMCPMethod) instead of "non-empty method". Each stays forward-compatible within its own namespace design, and an empty method (non-JSON-RPC body) now matches neither. mcp-parser's body-less transport detection ($transport/stream, $transport/terminate) is unchanged. IBAC classification interaction: for a JSON-RPC body whose method falls outside both namespaces, Context.Classification() now reports "unclassified" (no extension attached) instead of a recorded bypass. Under the default unclassified_policy (passthrough) this is observability-only — a skip/protocol_mechanics row is no longer recorded. Under "judge" such traffic is now judged rather than silently skipped, which matches that policy's "judge the unknowns" intent (the old phantom bypass let unknown JSON-RPC skip the judge). The IBAC demo is unaffected: its exfil body is text/plain and was already declined by mcp-parser's pre-existing non-JSON-RPC guard. Add regression tests: each parser declines the other's methods and non-JSON-RPC bodies without attaching an extension or recording an invocation, and Classification() reports unclassified in that case. Assisted-By: Claude (Anthropic AI) Signed-off-by: Hai Huang --- .../authlib/plugins/a2aparser/plugin.go | 31 +++++++ .../authlib/plugins/a2aparser/plugin_test.go | 84 +++++++++++++++++++ .../authlib/plugins/mcpparser/plugin.go | 45 ++++++++-- .../authlib/plugins/mcpparser/plugin_test.go | 43 ++++++++++ 4 files changed, 196 insertions(+), 7 deletions(-) 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")}