Skip to content

Commit c47d1b5

Browse files
committed
fix(authbridge): Handle json.Marshal failures in MCP rejection rendering
The MCP JSON-RPC rejection helpers ignored marshal errors but always returned HTTP 200 with Content-Type application/json. If a plugin populated Violation.Details with an unmarshalable value (e.g. a channel) or the request id failed to marshal, the MCP client would receive an empty body on a 200 transport — a parse error rather than a properly framed JSON-RPC error. Both call sites now share httpx.MarshalMCPRejectionBody, which: 1. tries the full frame, 2. on marshal error, retries with a minimal frame (drops optional data; falls back to id=null per JSON-RPC 2.0 §5.1 if the id itself is unmarshalable), 3. on further error, returns a constant precomputed parseable payload. Addresses CodeRabbit review feedback on PR #501. Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com> Signed-off-by: Kelly Abuelsaad <kaymar@gmail.com>
1 parent e84fd67 commit c47d1b5

3 files changed

Lines changed: 109 additions & 47 deletions

File tree

authbridge/authlib/listener/extproc/server.go

Lines changed: 2 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import (
2222
"google.golang.org/grpc/status"
2323

2424
"github.com/kagenti/kagenti-extensions/authbridge/authlib/auth"
25+
"github.com/kagenti/kagenti-extensions/authbridge/authlib/listener/httpx"
2526
"github.com/kagenti/kagenti-extensions/authbridge/authlib/listener/internal/sseframe"
2627
"github.com/kagenti/kagenti-extensions/authbridge/authlib/listener/skiphost"
2728
"github.com/kagenti/kagenti-extensions/authbridge/authlib/pipeline"
@@ -859,7 +860,7 @@ func replaceTokenResponse(token string) *extprocv3.ProcessingResponse {
859860
func rejectFromActionForRequest(action pipeline.Action, pctx *pipeline.Context) *extprocv3.ProcessingResponse {
860861
if pctx != nil && pctx.Extensions.MCP != nil &&
861862
pctx.Extensions.MCP.Method != "" && pctx.Extensions.MCP.RPCID != nil {
862-
body := mcpRejectionBody(action, pctx.Extensions.MCP.RPCID)
863+
body := httpx.MarshalMCPRejectionBody(action, pctx.Extensions.MCP.RPCID)
863864
return &extprocv3.ProcessingResponse{
864865
Response: &extprocv3.ProcessingResponse_ImmediateResponse{
865866
ImmediateResponse: &extprocv3.ImmediateResponse{
@@ -875,47 +876,6 @@ func rejectFromActionForRequest(action pipeline.Action, pctx *pipeline.Context)
875876
return rejectFromAction(action)
876877
}
877878

878-
// JSON-RPC 2.0 server-error code; see authlib/listener/httpx/render.go for
879-
// rationale on -32000 specifically.
880-
const jsonRPCServerError = -32000
881-
882-
func mcpRejectionBody(action pipeline.Action, id any) []byte {
883-
v := action.Violation
884-
message := "request rejected"
885-
var data map[string]any
886-
if v != nil {
887-
if v.Reason != "" {
888-
message = v.Reason
889-
}
890-
data = map[string]any{}
891-
if v.Code != "" {
892-
data["error"] = v.Code
893-
}
894-
if v.PluginName != "" {
895-
data["plugin"] = v.PluginName
896-
}
897-
if v.Description != "" {
898-
data["description"] = v.Description
899-
}
900-
if len(v.Details) > 0 {
901-
data["details"] = v.Details
902-
}
903-
if len(data) == 0 {
904-
data = nil
905-
}
906-
}
907-
body, _ := json.Marshal(map[string]any{
908-
"jsonrpc": "2.0",
909-
"id": id,
910-
"error": map[string]any{
911-
"code": jsonRPCServerError,
912-
"message": message,
913-
"data": data,
914-
},
915-
})
916-
return body
917-
}
918-
919879
// rejectFromAction turns a pipeline Reject into an Envoy ImmediateResponse,
920880
// preserving the plugin's status/headers/body. Replaces the old
921881
// denyResponse helper which hardcoded {"error":...,"message":...} at each

authbridge/authlib/listener/httpx/render.go

Lines changed: 46 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,28 @@ func shouldRenderMCPError(pctx *pipeline.Context) bool {
7676
const jsonRPCServerError = -32000
7777

7878
func writeMCPRejection(w http.ResponseWriter, action pipeline.Action, id any) {
79+
body := MarshalMCPRejectionBody(action, id)
80+
w.Header().Set("Content-Type", "application/json")
81+
w.WriteHeader(http.StatusOK)
82+
_, _ = w.Write(body)
83+
}
84+
85+
// mcpRejectionFallback is a precomputed JSON-RPC 2.0 error frame used as
86+
// the last-resort body when both the full and minimal marshal attempts
87+
// fail. It is guaranteed to round-trip through json.Unmarshal so MCP
88+
// clients always see a parseable frame on a 200 application/json
89+
// response.
90+
var mcpRejectionFallback = []byte(`{"jsonrpc":"2.0","id":null,"error":{"code":-32000,"message":"request rejected"}}`)
91+
92+
// MarshalMCPRejectionBody renders a Reject Action as a JSON-RPC 2.0
93+
// error frame body. It is guaranteed to return a non-empty, parseable
94+
// JSON byte slice so callers can safely send it on a 200
95+
// application/json response: marshal the full frame first; on error
96+
// (e.g. a plugin populated Violation.Details with an unmarshalable
97+
// value, or id is unmarshalable), retry with a minimal frame that omits
98+
// optional data and falls back to id=null; on further error, return a
99+
// constant precomputed frame.
100+
func MarshalMCPRejectionBody(action pipeline.Action, id any) []byte {
79101
v := action.Violation
80102
message := "request rejected"
81103
var data map[string]any
@@ -100,16 +122,35 @@ func writeMCPRejection(w http.ResponseWriter, action pipeline.Action, id any) {
100122
data = nil
101123
}
102124
}
103-
body, _ := json.Marshal(map[string]any{
125+
if body, err := json.Marshal(map[string]any{
104126
"jsonrpc": "2.0",
105127
"id": id,
106128
"error": map[string]any{
107129
"code": jsonRPCServerError,
108130
"message": message,
109131
"data": data,
110132
},
111-
})
112-
w.Header().Set("Content-Type", "application/json")
113-
w.WriteHeader(http.StatusOK)
114-
_, _ = w.Write(body)
133+
}); err == nil {
134+
return body
135+
}
136+
// Full frame failed to marshal — retry without optional data, and
137+
// keep the original id only if it survives marshaling on its own
138+
// (otherwise drop to null per JSON-RPC 2.0 §5.1).
139+
safeID := any(nil)
140+
if id != nil {
141+
if _, err := json.Marshal(id); err == nil {
142+
safeID = id
143+
}
144+
}
145+
if body, err := json.Marshal(map[string]any{
146+
"jsonrpc": "2.0",
147+
"id": safeID,
148+
"error": map[string]any{
149+
"code": jsonRPCServerError,
150+
"message": message,
151+
},
152+
}); err == nil {
153+
return body
154+
}
155+
return mcpRejectionFallback
115156
}

authbridge/authlib/listener/httpx/render_test.go

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,3 +184,64 @@ func TestWriteRejection_Unchanged(t *testing.T) {
184184
t.Errorf("error = %v, want ibac.blocked", body["error"])
185185
}
186186
}
187+
188+
// TestMarshalMCPRejectionBody_BadDetailsFallsBackToMinimalFrame: a
189+
// plugin can populate Violation.Details with anything (it's
190+
// map[string]any). If the value is unmarshalable (e.g. a channel),
191+
// the full frame fails. The body MUST still be a parseable JSON-RPC
192+
// 2.0 error frame so the MCP client surfaces this as a tool-call
193+
// failure rather than a parse error on a 200 application/json
194+
// response. The fallback drops optional data; everything else
195+
// (jsonrpc version, id, error.code, error.message) survives.
196+
func TestMarshalMCPRejectionBody_BadDetailsFallsBackToMinimalFrame(t *testing.T) {
197+
action := pipeline.DenyWithDetails("ibac.blocked", "intent does not align", map[string]any{
198+
"unmarshalable": make(chan int), // json.Marshal returns UnsupportedTypeError
199+
})
200+
body := MarshalMCPRejectionBody(action, "call-1")
201+
202+
var parsed map[string]any
203+
if err := json.Unmarshal(body, &parsed); err != nil {
204+
t.Fatalf("body must round-trip even when details fail to marshal: %v\n%s", err, body)
205+
}
206+
if parsed["jsonrpc"] != "2.0" {
207+
t.Errorf("jsonrpc = %v, want 2.0", parsed["jsonrpc"])
208+
}
209+
if parsed["id"] != "call-1" {
210+
t.Errorf("id = %v, want call-1 (id alone marshals fine, must be preserved)", parsed["id"])
211+
}
212+
errObj, ok := parsed["error"].(map[string]any)
213+
if !ok {
214+
t.Fatalf("error field missing or wrong type: %v", parsed["error"])
215+
}
216+
if errObj["code"] != float64(-32000) {
217+
t.Errorf("error.code = %v, want -32000", errObj["code"])
218+
}
219+
if errObj["message"] != "intent does not align" {
220+
t.Errorf("error.message = %v, want IBAC reason", errObj["message"])
221+
}
222+
if _, hasData := errObj["data"]; hasData {
223+
t.Errorf("error.data must be omitted on fallback; got %v", errObj["data"])
224+
}
225+
}
226+
227+
// TestMarshalMCPRejectionBody_BadIDFallsBackToNullID: if the request
228+
// id itself can't be marshaled (defensive — mcp-parser only stores
229+
// string/number/null today, but RPCID is `any`), we fall back to
230+
// id=null per JSON-RPC 2.0 §5.1, which permits null when the
231+
// original id can't be detected. The body must still be a valid
232+
// JSON-RPC error frame.
233+
func TestMarshalMCPRejectionBody_BadIDFallsBackToNullID(t *testing.T) {
234+
body := MarshalMCPRejectionBody(mcpAction(), make(chan int))
235+
236+
var parsed map[string]any
237+
if err := json.Unmarshal(body, &parsed); err != nil {
238+
t.Fatalf("body must round-trip even when id fails to marshal: %v\n%s", err, body)
239+
}
240+
if parsed["id"] != nil {
241+
t.Errorf("id = %v, want null (unmarshalable id must drop to null)", parsed["id"])
242+
}
243+
errObj, _ := parsed["error"].(map[string]any)
244+
if errObj["message"] != "intent does not align" {
245+
t.Errorf("error.message = %v, want IBAC reason (preserved across fallback)", errObj["message"])
246+
}
247+
}

0 commit comments

Comments
 (0)