diff --git a/authbridge/authlib/plugins/a2aparser/plugin.go b/authbridge/authlib/plugins/a2aparser/plugin.go index d9fc8f852..079f855f3 100644 --- a/authbridge/authlib/plugins/a2aparser/plugin.go +++ b/authbridge/authlib/plugins/a2aparser/plugin.go @@ -43,10 +43,19 @@ func (p *A2AParser) OnRequest(_ context.Context, pctx *pipeline.Context) pipelin return pipeline.Action{Type: pipeline.Continue} } + // A top-level non-object body (array, string, number, null) is not + // JSON-RPC; peek the first byte so it takes the calm skip path below + // rather than logging as malformed. + trimmed := bytes.TrimLeft(pctx.Body, " \t\r\n") + isObject := len(trimmed) > 0 && trimmed[0] == '{' + var rpc parsercommon.JSONRPCRequest - if err := json.Unmarshal(pctx.Body, &rpc); err != nil { - slog.Debug("a2a-parser: invalid JSON-RPC", "error", err, "bodyLen", len(pctx.Body)) - return pipeline.Action{Type: pipeline.Continue} + if isObject { + if err := json.Unmarshal(pctx.Body, &rpc); err != nil { + slog.Debug("a2a-parser: invalid JSON-RPC", "error", err, "bodyLen", len(pctx.Body), + "host", pctx.Host, "path", pctx.Path) + return pipeline.Action{Type: pipeline.Continue} + } } // Claim only A2A-namespace methods. Both A2A and MCP ride JSON-RPC 2.0 @@ -58,8 +67,9 @@ func (p *A2AParser) OnRequest(_ context.Context, pctx *pipeline.Context) pipelin // (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)) + if !isObject || !isA2AMethod(rpc.Method) { + slog.Debug("a2a-parser: not an A2A-namespace method, skipping", "method", rpc.Method, + "bodyLen", len(pctx.Body), "host", pctx.Host, "path", pctx.Path) return pipeline.Action{Type: pipeline.Continue} } diff --git a/authbridge/authlib/plugins/a2aparser/plugin_test.go b/authbridge/authlib/plugins/a2aparser/plugin_test.go index 3f750775e..55bb2787c 100644 --- a/authbridge/authlib/plugins/a2aparser/plugin_test.go +++ b/authbridge/authlib/plugins/a2aparser/plugin_test.go @@ -345,6 +345,12 @@ func TestA2AParser_NonJSONRPCBody_NoMatch(t *testing.T) { {"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":{}}`}, + // Non-object JSON: declined on shape, before any method check. The + // array wraps a real A2A method on purpose — it would match + // isA2AMethod if it were an object. + {"top-level array", `[{"jsonrpc":"2.0","id":1,"method":"message/send"}]`}, + {"top-level string", `"hello"`}, + {"top-level number", `42`}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { diff --git a/authbridge/authlib/plugins/mcpparser/plugin.go b/authbridge/authlib/plugins/mcpparser/plugin.go index bb409a98b..95a4cc842 100644 --- a/authbridge/authlib/plugins/mcpparser/plugin.go +++ b/authbridge/authlib/plugins/mcpparser/plugin.go @@ -209,10 +209,19 @@ func (p *MCPParser) OnRequest(_ context.Context, pctx *pipeline.Context) pipelin return pipeline.Action{Type: pipeline.Continue} } + // A top-level non-object body (array, string, number, null) is not + // JSON-RPC; peek the first byte so it takes the calm skip path below + // rather than logging as malformed. + trimmed := bytes.TrimLeft(pctx.Body, " \t\r\n") + isObject := len(trimmed) > 0 && trimmed[0] == '{' + var rpc parsercommon.JSONRPCRequest - if err := json.Unmarshal(pctx.Body, &rpc); err != nil { - slog.Debug("mcp-parser: body is not valid JSON-RPC", "error", err, "bodyLen", len(pctx.Body)) - return pipeline.Action{Type: pipeline.Continue} + if isObject { + if err := json.Unmarshal(pctx.Body, &rpc); err != nil { + slog.Debug("mcp-parser: body is not valid JSON-RPC", "error", err, "bodyLen", len(pctx.Body), + "host", pctx.Host, "path", pctx.Path) + return pipeline.Action{Type: pipeline.Continue} + } } // 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 @@ -223,8 +232,9 @@ func (p *MCPParser) OnRequest(_ context.Context, pctx *pipeline.Context) pipelin // 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)) + if !isObject || !isMCPMethod(rpc.Method) { + slog.Debug("mcp-parser: not an MCP-namespace method, skipping", "method", rpc.Method, + "bodyLen", len(pctx.Body), "host", pctx.Host, "path", pctx.Path) 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 1b93972b3..30c808252 100644 --- a/authbridge/authlib/plugins/mcpparser/plugin_test.go +++ b/authbridge/authlib/plugins/mcpparser/plugin_test.go @@ -186,6 +186,26 @@ func TestMCPParser_SkipsJSONThatIsNotJSONRPC(t *testing.T) { } } +// A top-level JSON array is not JSON-RPC, so it is declined on shape +// before any method check — the body here wraps a real MCP method that +// would match isMCPMethod if it were an object. +func TestMCPParser_TopLevelArray_NoMatch(t *testing.T) { + p := NewMCPParser() + pctx := &pipeline.Context{ + Body: []byte(`[{"jsonrpc":"2.0","id":1,"method":"tools/list"}]`), + } + 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("MCP should remain nil for a top-level array, got %+v", pctx.Extensions.MCP) + } + if pctx.Extensions.Invocations != nil { + t.Errorf("expected no Invocation recorded, got %+v", pctx.Extensions.Invocations) + } +} + // 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/*,