Skip to content
Merged
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
20 changes: 15 additions & 5 deletions authbridge/authlib/plugins/a2aparser/plugin.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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}
}

Expand Down
6 changes: 6 additions & 0 deletions authbridge/authlib/plugins/a2aparser/plugin_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
20 changes: 15 additions & 5 deletions authbridge/authlib/plugins/mcpparser/plugin.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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}
}

Expand Down
20 changes: 20 additions & 0 deletions authbridge/authlib/plugins/mcpparser/plugin_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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/*,
Expand Down
Loading