Skip to content

Fix: Render MCP-protocol error frame on outbound deny#501

Merged
huang195 merged 2 commits into
rossoctl:mainfrom
kellyaa:fix/ibac-mcp-rejection-frame
Jun 14, 2026
Merged

Fix: Render MCP-protocol error frame on outbound deny#501
huang195 merged 2 commits into
rossoctl:mainfrom
kellyaa:fix/ibac-mcp-rejection-frame

Conversation

@kellyaa

@kellyaa kellyaa commented Jun 12, 2026

Copy link
Copy Markdown
Member

Summary

  • When an outbound gate (IBAC, content policy, etc.) rejects an MCP JSON-RPC request, the proxy now returns HTTP 200 with a JSON-RPC 2.0 error frame echoing the original id instead of a transport-level 4xx/5xx with a non-MCP body. The agent's MCP client surfaces this as one failed tool call rather than a session break.
  • New helpers: httpx.WriteRejectionForRequest (proxy-sidecar) and rejectFromActionForRequest (envoy-sidecar/extproc). Detection is conservative — only rewrites when pctx.Extensions.MCP has a non-empty Method AND a non-nil RPCID. Notifications (no id), non-MCP traffic, and nil pctx fall through to today's shape unchanged.
  • Wired in at the outbound request reject sites only: forwardproxy HTTP + CONNECT paths, extproc header-phase + body-phase outbound. Inbound and response-phase rejects are untouched.

Why

IBAC denies (ibac.blocked, ibac.judge_unavailable, ibac.no_session, ibac.no_intent, etc.) currently surface to MCP clients as 403/503 transport errors. MCP clients treat that as the HTTP transport failing, which can break the whole MCP session — even though the only thing that should fail is the single tools/call request. By rendering a JSON-RPC error frame the way an MCP server would, the failure stays scoped to one tool call.

The error body shape:

{
  "jsonrpc": "2.0",
  "id": <original id>,
  "error": {
    "code": -32000,
    "message": "<violation reason>",
    "data": {
      "error": "ibac.blocked",
      "plugin": "ibac",
      "details": { ... }
    }
  }
}

-32000 is the JSON-RPC 2.0 implementation-defined server-error code; operators read error.data.error (the violation code) and error.message (the human reason) for context.

Test plan

  • go test ./... in authlib/ — all packages pass
  • go build ./... for all three cmd/authbridge-{proxy,envoy,lite} binaries
  • New unit tests in authbridge/authlib/listener/httpx/render_test.go (7 cases: MCP id round-trip for numeric+string ids, notifications fall back, non-MCP falls back, nil pctx safe, 503 still renders as JSON-RPC, plain WriteRejection unchanged)
  • New unit tests in authbridge/authlib/listener/extproc/mcpreject_test.go (3 cases: MCP request renders JSON-RPC, non-MCP falls back, notification falls back)
  • Manual: trigger an IBAC deny against an MCP tool call in a kind cluster and verify the agent's MCP client sees a tool-call failure rather than a session break

Assisted-By: Claude (Anthropic AI) noreply@anthropic.com

Summary by CodeRabbit

  • Refactor

    • Rejection handling now renders MCP JSON-RPC denials as JSON-RPC error frames over HTTP 200, while non-MCP and notification cases fall back to standard HTTP error responses. Applies to outbound, body, and CONNECT paths and adds a request-aware rejection writer.
  • Tests

    • Added comprehensive tests covering MCP JSON-RPC and non-MCP rejection rendering, marshaling fallbacks, notification and nil-context behavior, and preserving JSON-RPC id types.

When an outbound gate (IBAC, etc.) rejects an MCP JSON-RPC request,
the proxy used to emit a transport-level 4xx/5xx with a non-MCP body.
The agent's MCP client sees that as a session break instead of a
single failed tool call.

This change adds an MCP-aware rejection helper for both proxy-sidecar
(httpx.WriteRejectionForRequest) and envoy-sidecar
(rejectFromActionForRequest) listeners. When pctx.Extensions.MCP
carries a real JSON-RPC method + non-nil id, the deny renders as
HTTP 200 with a JSON-RPC 2.0 error frame echoing the original id and
carrying the violation's reason/code as error.message and
error.data.error. JSON-RPC notifications (no id), non-MCP traffic,
and nil pctx all fall through to today's HTTP-level shape unchanged.

Wired in at the outbound request reject sites in forwardproxy
(HTTP and CONNECT) and extproc (header- and body-phase outbound).
Inbound and response-phase rejects keep the existing shape.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Kelly Abuelsaad <kaymar@gmail.com>
@coderabbitai

coderabbitai Bot commented Jun 12, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

This PR adds MCP JSON-RPC aware rejection handling across listener services. When an MCP JSON-RPC request is denied, rejections now render as HTTP 200 responses containing JSON-RPC 2.0 error objects, preserving the request id and mapping violation details into the error frame. Non-MCP and notification requests continue using HTTP-level rejection semantics (403 Forbidden).

Changes

MCP request rejection rendering

Layer / File(s) Summary
Core MCP-aware rejection API and tests
authbridge/authlib/listener/httpx/render.go, authbridge/authlib/listener/httpx/render_test.go
WriteRejectionForRequest detects MCP JSON-RPC requests via pipeline context and renders denials as HTTP 200 with JSON-RPC 2.0 error frames (code -32000, error message/data from Violation); non-MCP and notification requests fall back to HTTP 403. MarshalMCPRejectionBody implements robust marshaling with minimal-frame and fallback behaviors. Tests cover id type preservation, notifications, non-MCP, nil context, 503 case, and marshal fallbacks.
extproc listener rejection integration
authbridge/authlib/listener/extproc/server.go, authbridge/authlib/listener/extproc/mcpreject_test.go
Outbound headers/body reject paths route rejections through rejectFromActionForRequest, producing MCP JSON-RPC error responses when context has non-empty Method and non-nil RPCID, else falling back to plugin-rendered violations. Test helpers and three cases validate MCP (HTTP 200 JSON-RPC), non-MCP (HTTP 403), and notification (HTTP 403) behaviors.
forwardproxy listener rejection integration
authbridge/authlib/listener/forwardproxy/server.go
Outbound HTTP proxy and CONNECT tunnel reject paths call httpx.WriteRejectionForRequest instead of WriteRejection, enabling JSON-RPC error rendering for MCP requests while preserving HTTP semantics for non-MCP.

🎯 3 (Moderate) | ⏱️ ~20 minutes

Suggested reviewers:

  • pdettori
  • mrsabath

"I nibble at bytes and hop the stack,
JSON ids echoed, no transport crack.
Denials wrapped in gentle 200 light,
Rabbity cheers for error frames tonight." 🐰

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title directly and concisely summarizes the main change: rendering MCP-protocol error frames for outbound denials instead of generic HTTP errors.
Docstring Coverage ✅ Passed Docstring coverage is 83.33% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 golangci-lint (2.12.2)

level=error msg="[linters_context] typechecking error: pattern ./...: directory prefix . does not contain main module or its selected dependencies"


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@authbridge/authlib/listener/httpx/render.go`:
- Around line 103-114: The current json.Marshal calls in writeMCPRejection and
mcpRejectionBody ignore errors and may send invalid bodies while returning
http.StatusOK application/json; update both functions to check the marshal
error, and on failure construct a minimal deterministic JSON-RPC error frame
(omit optional "data", set "id" to null if original id is invalid) and try
marshaling that; if marshaling still fails, fall back to a constant, precomputed
valid JSON payload (e.g.
{"jsonrpc":"2.0","id":null,"error":{"code":<server_error>,"message":"internal
error"}}) so the handler (writeMCPRejection) and rejectFromActionForRequest
callers always write a parseable JSON response. Ensure you reference
writeMCPRejection, mcpRejectionBody and rejectFromActionForRequest when making
the changes.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: f9fab18f-ef8a-4f2f-aee6-90a4974b3cbf

📥 Commits

Reviewing files that changed from the base of the PR and between 4695245 and e84fd67.

📒 Files selected for processing (5)
  • authbridge/authlib/listener/extproc/mcpreject_test.go
  • authbridge/authlib/listener/extproc/server.go
  • authbridge/authlib/listener/forwardproxy/server.go
  • authbridge/authlib/listener/httpx/render.go
  • authbridge/authlib/listener/httpx/render_test.go

Comment thread authbridge/authlib/listener/httpx/render.go Outdated
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 rossoctl#501.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Kelly Abuelsaad <kaymar@gmail.com>
@kellyaa kellyaa added ready-for-ai-review Request automated AI code review from clawgenti Ready for Review! labels Jun 13, 2026
@kellyaa kellyaa moved this from New /:ToDo to In review / Needs Testing in Rossoctl Issue Prioritization Jun 13, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
authbridge/authlib/listener/httpx/render.go (1)

100-156: ⚡ Quick win

Past review comment properly addressed with robust 3-tier fallback.

The implementation correctly handles the scenarios flagged in the prior review:

  • Full frame marshal with data (lines 125-135)
  • Minimal frame fallback omitting optional data and using safe id (lines 136-154)
  • Constant precomputed payload as final fallback (line 155)

The id marshalability check (lines 140-144) is correct: it tests whether id can be marshaled independently before including it in the minimal frame, falling back to null per JSON-RPC 2.0 §5.1 when the original id is unmarshalable.

Optional observability enhancement: Consider adding structured logging (slog.Warn) when fallback tiers are triggered, to aid production debugging if marshal failures occur. The current implementation prioritizes reliability (always returns valid JSON) over observability, which is a reasonable trade-off for error paths.

🔍 Optional: Add observability for fallback paths
 	}); err == nil {
 		return body
 	}
+	slog.Warn("httpx: MCP rejection full frame marshal failed, retrying minimal", "error", err)
 	// Full frame failed to marshal — retry without optional data, and
 	// keep the original id only if it survives marshaling on its own
 	// (otherwise drop to null per JSON-RPC 2.0 §5.1).
 	safeID := any(nil)
 	if id != nil {
 		if _, err := json.Marshal(id); err == nil {
 			safeID = id
+		} else {
+			slog.Warn("httpx: MCP rejection id unmarshalable, falling back to null", "error", err)
 		}
 	}
 	if body, err := json.Marshal(map[string]any{
 		"jsonrpc": "2.0",
 		"id":      safeID,
 		"error": map[string]any{
 			"code":    jsonRPCServerError,
 			"message": message,
 		},
 	}); err == nil {
 		return body
 	}
+	slog.Error("httpx: MCP rejection minimal frame marshal failed, using constant fallback", "error", err)
 	return mcpRejectionFallback
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@authbridge/authlib/listener/httpx/render.go` around lines 100 - 156, Add
structured warning logs when the MarshalMCPRejectionBody fallback tiers are
exercised: inside MarshalMCPRejectionBody, log a slog.Warn (including the error
value and context) when the initial full-frame json.Marshal fails, and again
when the minimal-frame json.Marshal (or the id marshalability check) fails and
you must return mcpRejectionFallback; include fields like "stage" ("full" or
"minimal"), the jsonRPCServerError constant, and the id type/value where safe to
do so to aid debugging without exposing sensitive payloads.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@authbridge/authlib/listener/httpx/render.go`:
- Around line 100-156: Add structured warning logs when the
MarshalMCPRejectionBody fallback tiers are exercised: inside
MarshalMCPRejectionBody, log a slog.Warn (including the error value and context)
when the initial full-frame json.Marshal fails, and again when the minimal-frame
json.Marshal (or the id marshalability check) fails and you must return
mcpRejectionFallback; include fields like "stage" ("full" or "minimal"), the
jsonRPCServerError constant, and the id type/value where safe to do so to aid
debugging without exposing sensitive payloads.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: e05e3c5e-f318-480a-91aa-2cde01a219c6

📥 Commits

Reviewing files that changed from the base of the PR and between e84fd67 and c47d1b5.

📒 Files selected for processing (3)
  • authbridge/authlib/listener/extproc/server.go
  • authbridge/authlib/listener/httpx/render.go
  • authbridge/authlib/listener/httpx/render_test.go

@clawgenti clawgenti left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Clawgenti Code Review

Solid fix for a real MCP session-stability problem: outbound IBAC denials now surface as scoped tool-call failures rather than transport breaks. The three-tier marshal fallback (full → minimal → static) is a nice defensive touch. One nit below.


Reviewed by clawgenti using github:pr-review

"error": map[string]any{
"code": jsonRPCServerError,
"message": message,
"data": data,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: "data": data is included unconditionally, so when action.Violation is nil (or all Violation fields are empty and data stays nil), the wire response contains "data":null. JSON-RPC 2.0 §5.1 says the data member SHOULD be omitted when there are no additional details. Consider a conditional include:

errorObj := map[string]any{
    "code":    jsonRPCServerError,
    "message": message,
}
if data != nil {
    errorObj["data"] = data
}

Not a blocker — the current shape is spec-valid — but it avoids a superfluous null field that some strict MCP clients might warn on.

@huang195 huang195 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Clean, well-scoped fix: outbound MCP JSON-RPC rejections now render as an HTTP 200 JSON-RPC 2.0 error frame echoing the original id, so an IBAC/policy deny fails a single tools/call instead of breaking the MCP session. Detection is conservative (Method + non-nil RPCID), and notifications / non-MCP / nil-pctx all fall through to today's shape unchanged. Body rendering (MarshalMCPRejectionBody) is correctly shared across both the extproc and forwardproxy sidecars, with guaranteed-parseable marshal fallbacks. Tests are thorough — id-type round-trip, notification fall-back, non-MCP fall-back, nil-pctx safety, 503-still-200, and marshal-failure fallbacks.

Two minor, non-blocking observations inline (1 suggestion, 1 nit). All CI green, DCO passing.

Assisted-By: Claude Code

// caller's MCP client surfaces this as one failed tool call rather than a
// transport break. All other shapes fall through to rejectFromAction.
func rejectFromActionForRequest(action pipeline.Action, pctx *pipeline.Context) *extprocv3.ProcessingResponse {
if pctx != nil && pctx.Extensions.MCP != nil &&

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion — The MCP-detection predicate here (MCP != nil && Method != "" && RPCID != nil) is reimplemented inline, while httpx.shouldRenderMCPError encodes the identical condition. They're logically equal today but can drift independently. Consider exporting the httpx predicate (e.g. httpx.ShouldRenderMCPError(pctx)) and calling it from both listeners so the "is this an MCP request?" rule lives in one place.

// clients always see a parseable frame on a 200 application/json
// response.
var mcpRejectionFallback = []byte(`{"jsonrpc":"2.0","id":null,"error":{"code":-32000,"message":"request rejected"}}`)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nitmcpRejectionFallback hardcodes -32000 in the raw string literal instead of referencing the jsonRPCServerError const just below. If the const ever changes, this last-resort frame silently diverges. A // keep in sync with jsonRPCServerError comment (or building it via fmt.Sprintf at init) would close the gap.

@huang195
huang195 merged commit 1cd2a12 into rossoctl:main Jun 14, 2026
21 checks passed
@github-project-automation github-project-automation Bot moved this from In review / Needs Testing to Done in Rossoctl Issue Prioritization Jun 14, 2026
araujof added a commit to araujof/kagenti-extensions that referenced this pull request Jun 16, 2026
…ering

After merging PR rossoctl#501 (shared MCP rejection renderer, httpx/render.go), cpex
denials on the HTTP proxies render as an MCP JSON-RPC 2.0 error frame at HTTP 200
for classified MCP requests (code -32000, error.data.error=cpex.<code>), and fall
back to plain 403/502 only for non-MCP requests. The demo and docs still
described the old transport-level 403. Truth them up:

- Chat agent (agent.py): format_tool_response reads the cpex code from
  error.data.error (was error.data.violation, which the renderer never emits).
  Verified live in-container against a real deny frame. Fix a stale '-> 403'
  comment.
- Scenarios 02/05/06/07/08/09: headers + notes now expect HTTP 200 + JSON-RPC
  error frame (error.data.error=cpex.*), not HTTP 403. Re-ran all 9: deny frames
  match.
- README: taint S3 line now says denied via MCP JSON-RPC error frame.
- CHAT-WALKTHROUGH.md: fix all four deny sections (code -32001 -> -32000,
  data.violation -> data.error with the real cpex codes).
- Plugin docs (plugins/cpex/README.md, docs/cpex-plugin.md): the deny/error
  wire shape is now described as listener-dependent (MCP frame at 200 vs plain
  403/502). Also correct the stale 'body modifications aren't re-serialized'
  limitation: re-serialization is implemented; the real limitation is that
  multi-part rewrites fail closed.

Signed-off-by: Frederico Araujo <frederico.araujo@ibm.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Ready for Review! ready-for-ai-review Request automated AI code review from clawgenti

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

4 participants