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
92 changes: 71 additions & 21 deletions authbridge/authlib/plugins/ibac/plugin.go
Original file line number Diff line number Diff line change
Expand Up @@ -303,20 +303,33 @@ func (p *IBAC) OnRequest(ctx context.Context, pctx *pipeline.Context) pipeline.A
return pipeline.Action{Type: pipeline.Continue}
}

// 5b. Transport-stream bypass. Body-less retrieval-shaped requests
// carry no action payload to evaluate — typical patterns: MCP
// Streamable HTTP's server→client SSE channel (GET), agent-
// card fetches (GET), OAuth metadata probes (GET), CORS
// preflights (OPTIONS), HEAD probes for cache validation /
// existence checks. Sending these to the judge is a category
// error: there's nothing to judge, and the LLM either denies
// for lack of context or returns a non-deterministic verdict
// that depends on noise in the prompt.
// 5b. Transport-stream bypass. Two patterns of requests carry no
// user-meaningful action and must skip the judge:
//
// Threat model: an attacker can't smuggle a payload through a
// body-less GET/HEAD/OPTIONS — there's no request body to put
// the action in. Side-effect HTTP methods (POST/PUT/DELETE/
// PATCH) always reach the judge, body-having or not.
// - Body-less GET/HEAD/OPTIONS: retrieval-shaped calls
// (MCP Streamable HTTP server→client SSE channel-open,
// agent-card fetches, OAuth metadata probes, CORS
// preflights, HEAD existence/cache-validation probes).
//
// - Body-less DELETE with the Mcp-Session-Id header: MCP
// Streamable HTTP session termination, sent by the MCP
// client SDK at end-of-conversation to release server-
// side session state. The header is set by the SDK, not
// user input, so it's a precise distinguisher from a
// "DELETE /api/users/42" real-action call.
//
// Sending either to the judge is a category error: there's
// nothing to judge, and the LLM either denies for lack of
// context or misinterprets the verb (e.g. correctly noting
// "DELETE involves deleting data" without knowing it's
// protocol session cleanup).
//
// Threat model: an attacker can't smuggle a payload through
// a body-less request — there's no body to put it in. Side-
// effect HTTP methods (POST/PUT/DELETE/PATCH) always reach
// the judge when they carry a body. Body-less DELETE without
// the Mcp-Session-Id header — a real "delete this resource"
// call by URL path — also reaches the judge.
//
// Caveat: servers that handle side-effect operations through
// GET query strings (e.g. ?action=delete&id=42) violate REST
Expand All @@ -325,7 +338,7 @@ func (p *IBAC) OnRequest(ctx context.Context, pctx *pipeline.Context) pipeline.A
// plugin trusts HTTP method semantics as a proxy for "is this
// an action?", and a server that breaks that convention is
// making its own authorization promises that IBAC can't see.
if isTransportRetrieval(pctx.Method) && len(pctx.Body) == 0 {
if isTransportShaped(pctx) {
pctx.Skip("transport_stream")
return pipeline.Action{Type: pipeline.Continue}
}
Expand Down Expand Up @@ -572,13 +585,21 @@ func formatBodyExcerpt(body []byte, n int) string {
return fmt.Sprintf("%q", string(body))
}

// isMCPHousekeeping reports whether an MCP method is connection-setup
// or capability-discovery traffic that carries no user-actionable
// intent. These methods fire before any user turn (e.g. an agent's
// startup `initialize` against its MCP tool server) and judging them
// would either panic on a nil session or deny on no_intent — both
// break agents that open MCP connections at startup. Only side-effect
// methods (tools/call, prompts/get, resources/read) reach the judge.
// isMCPHousekeeping reports whether an MCP method is connection-setup,
// capability-discovery, or subscription-management traffic that
// carries no user-actionable intent. These methods fire before, after,
// or alongside user turns as protocol mechanics — judging them would
// either panic on a nil session or deny on no_intent, breaking agents
// that open MCP connections at startup or maintain resource
// subscriptions. Only side-effect methods (tools/call, prompts/get,
// resources/read) reach the judge.
//
// resources/subscribe and resources/unsubscribe are subscription-
// state management — the client tells the server "notify me when
// this resource changes" / "stop notifying me." They go through POST
// with a JSON-RPC body (so the body-less transport_stream bypass
// doesn't apply), but they're conceptually identical to *list calls:
// protocol bookkeeping, not user-meaningful actions.
//
// JSON-RPC notifications (any method starting with `notifications/`)
// are also bypassed: they're one-way protocol signals, never tied to
Expand All @@ -591,6 +612,8 @@ func isMCPHousekeeping(method string) bool {
"prompts/list",
"resources/list",
"resources/templates/list",
"resources/subscribe",
"resources/unsubscribe",
"completion/complete",
"logging/setLevel":
return true
Expand All @@ -614,6 +637,33 @@ func isTransportRetrieval(method string) bool {
return false
}

// isTransportShaped reports whether a request matches a transport-
// layer pattern that carries no user-meaningful action — should be
// skipped before the intent check. Combines two body-less shapes:
//
// - retrieval (GET / HEAD / OPTIONS): see isTransportRetrieval.
// - MCP Streamable HTTP session termination: DELETE with the
// Mcp-Session-Id header. The MCP spec defines this as the way
// a client releases server-side session state at end-of-
// conversation; the header is set by the client SDK, not user
// input, so it's a precise distinguisher from a real
// "DELETE /api/resource" action call.
//
// A request with a non-empty body is always treated as an action,
// regardless of method.
func isTransportShaped(pctx *pipeline.Context) bool {
if len(pctx.Body) > 0 {
return false
}
if isTransportRetrieval(pctx.Method) {
return true
}
if pctx.Method == "DELETE" && pctx.Headers.Get("Mcp-Session-Id") != "" {
return true
}
return false
}
Comment on lines +654 to +665

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

MCP Streamable HTTP Mcp-Session-Id header specification

💡 Result:

The Model Context Protocol (MCP) has undergone a significant architectural change regarding session management. As of the current date (May 2026), the Mcp-Session-Id header has been deprecated and removed from the protocol specification [1][2][3]. Historical Context and Specification: Prior to this change (notably in specifications from 2025 and early 2026), the Streamable HTTP transport utilized the Mcp-Session-Id header to maintain session state [2][3][4]. Under that previous model: 1. Initialization: The client performed an initialize request [2][3]. 2. Session Assignment: The server generated a unique session ID and returned it in the Mcp-Session-Id response header [5][6][7]. 3. Session Persistence: Clients were required to include this Mcp-Session-Id header in all subsequent HTTP requests [5][8][4]. The server used this identifier to associate requests with specific session state, capabilities, and negotiated protocol versions [3][7]. Current Status (Sessionless Architecture): The protocol has shifted to a stateless architecture [1][2]. The updated specification removes both the initialize/initialized handshake and the Mcp-Session-Id header entirely [2][3]. Requests are now designed to be independent and self-contained; any state required by the server must be managed explicitly by the client and included within request parameters (e.g., using explicit state handles) [1][3]. Consequently, any existing reliance on Mcp-Session-Id for routing or stateful behavior is no longer supported in the current MCP specification [2][3].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

FILE="authbridge/authlib/plugins/ibac/plugin.go"

# Print the exact lines around the helper for context
sed -n '620,675p' "$FILE"

Repository: kagenti/kagenti-extensions

Length of output: 1940


🌐 Web query:

site:modelcontextprotocol.io Mcp-Session-Id streamable HTTP

💡 Result:

In the Model Context Protocol (MCP), Mcp-Session-Id is an HTTP header used specifically within the Streamable HTTP transport to manage session state between a client and server [1][2][3]. Key details regarding its usage include: Initialization: A server MAY assign a session ID during initialization by including the Mcp-Session-Id header in the HTTP response that contains the InitializeResult [1][3]. Client Requirements: If a server provides an Mcp-Session-Id during initialization, the client MUST include this header in all subsequent HTTP requests made to that server for the duration of the session [1][3]. Validation: Servers that require a session ID SHOULD return an HTTP 400 Bad Request status for any non-initialization requests that do not include the header [1][3]. If a server receives a request with an invalid or expired session ID, it MUST respond with HTTP 404 Not Found, prompting the client to re-initialize [1][3]. Termination: Clients can explicitly terminate a session by sending an HTTP DELETE request to the MCP endpoint, including the Mcp-Session-Id header [1][4][3]. This header facilitates stateful communication in environments where multiple clients may connect to the same server, allowing the server to maintain distinct sessions [5][6]. Note that in some newer protocol revisions, the Streamable HTTP transport architecture has evolved, and users should always refer to the specific version of the specification they are implementing [7].

Citations:


🌐 Web query:

modelcontextprotocol.io transports streamable HTTP sessionless Mcp-Session-Id

💡 Result:

In MCP Streamable HTTP, the header is indeed Mcp-Session-Id, but whether it’s required depends on which Streamable HTTP protocol revision you’re targeting. 1) Streamable HTTP revisions that use Mcp-Session-Id (stateful protocol layer) - In the older Streamable HTTP revisions (e.g., protocol version 2025-03-26), servers MAY assign a session ID by including an Mcp-Session-Id header on the HTTP response containing the InitializeResult. [1] - If the server returns an Mcp-Session-Id during initialization, clients using Streamable HTTP MUST include that same Mcp-Session-Id header on all subsequent HTTP requests to that server. [2][1] - Session termination/behavior is also defined: servers can terminate sessions; clients receiving 404 for a request with Mcp-Session-Id must start a new session by sending a new InitializeRequest without a session ID; and clients SHOULD send an HTTP DELETE with Mcp-Session-Id to explicitly terminate. [2][1] 2) 2026-07-28 revision (“stateless” protocol layer): Mcp-Session-Id removed - The MCP Transport documentation’s draft “Transports” page describes a 2026-07-28 change where Streamable HTTP behavior was revised and specifically notes removal of “protocol-level sessions” and that requests/streams resumability mechanisms from earlier revisions are no longer part of this revision. [3] - The MCP release candidate blog post for 2026-07-28 states both that the Mcp-Session-Id header and the protocol-level session are removed, and that with both gone, any MCP request can go to any server instance (i.e., no sticky routing / shared session store needed at the protocol layer). [4] - SEP-2567 (“Sessionless MCP via Explicit State Handles”) further explains the intent: it removes the protocol-level session concept and the Mcp-Session-Id header, making the protocol sessionless. [5] So, for “modelcontextprotocol.io transports streamable HTTP sessionless Mcp-Session-Id”: - If you’re building against the sessionful Streamable HTTP revision, you must propagate Mcp-Session-Id on subsequent requests after it’s returned with InitializeResult. [2][1] - If you’re building against the 2026-07-28 (sessionless) revision, the protocol removes Mcp-Session-Id; you should not rely on it for request routing or continuity—use explicit state handles (per SEP-2567) or whatever mechanism the newer spec provides for carrying state. [3][5][4]

Citations:


Validate MCP Streamable HTTP spec revision for Mcp-Session-Id termination
In authbridge/authlib/plugins/ibac/plugin.go (lines 644-655), the header name Mcp-Session-Id matches the Streamable HTTP transport in the sessionful protocol revisions (used to scope session state and used for explicit termination via DELETE). If this code is meant to target the sessionless 2026-07-28/SEP-2567 revision, Mcp-Session-Id is removed from the protocol, so the DELETE + Mcp-Session-Id check should be updated accordingly.

🤖 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/plugins/ibac/plugin.go` around lines 644 - 655, The DELETE
+ Mcp-Session-Id header check in isTransportShaped(pctx *pipeline.Context) uses
a sessionful header that was removed in the sessionless 2026-07-28/SEP-2567
revision; update isTransportShaped to either remove the pctx.Method == "DELETE"
&& pctx.Headers.Get("Mcp-Session-Id") != "" branch entirely if this service
targets the sessionless spec, or replace it with the correct termination
mechanism from the intended spec (e.g., a different header or transport-revision
flag). Locate the isTransportShaped function and modify the DELETE conditional
accordingly so it no longer relies on the removed Mcp-Session-Id header, and add
a comment referencing the protocol revision used to avoid regressions.


// extractMCPToolName pulls the tool name from a tools/call request's
// params. Returns "" for non-tools/call methods or missing field.
func extractMCPToolName(mcp *pipeline.MCPExtension) string {
Expand Down
98 changes: 94 additions & 4 deletions authbridge/authlib/plugins/ibac/plugin_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -420,9 +420,11 @@ func TestOnRequest_NilSession_PolicyAllow_Bypasses(t *testing.T) {
}

// MCP protocol-housekeeping methods carry no user-actionable intent
// (they're connection setup or capability discovery, not side
// effects). IBAC must skip them; otherwise every agent that opens an
// MCP connection at startup gets blocked before any user turn.
// (they're connection setup, capability discovery, or subscription
// management — not side effects). IBAC must skip them; otherwise
// every agent that opens an MCP connection at startup gets blocked
// before any user turn, and any agent that maintains resource
// subscriptions sees its bookkeeping calls denied mid-conversation.
func TestOnRequest_MCPHousekeepingBypass(t *testing.T) {
cases := []string{
"initialize",
Expand All @@ -431,6 +433,8 @@ func TestOnRequest_MCPHousekeepingBypass(t *testing.T) {
"prompts/list",
"resources/list",
"resources/templates/list",
"resources/subscribe",
"resources/unsubscribe",
"completion/complete",
"logging/setLevel",
"notifications/initialized",
Expand Down Expand Up @@ -540,7 +544,9 @@ func TestOnRequest_TransportStreamBypass_GETWithBodyIsJudged(t *testing.T) {
// Body-less POST/PUT/DELETE/PATCH must NOT bypass — the absence of
// body alone isn't enough; the method must signal "retrieval" too.
// A body-less POST is unusual but legal (semantic "do action") and
// IBAC should still judge it.
// IBAC should still judge it. (Body-less DELETE *with* the
// Mcp-Session-Id header is its own special case — see
// TestOnRequest_TransportStream_MCPSessionTerminate.)
func TestOnRequest_TransportStreamBypass_BodylessPOSTIsJudged(t *testing.T) {
for _, method := range []string{"POST", "PUT", "DELETE", "PATCH"} {
t.Run(method, func(t *testing.T) {
Expand All @@ -561,6 +567,90 @@ func TestOnRequest_TransportStreamBypass_BodylessPOSTIsJudged(t *testing.T) {
}
}

// MCP Streamable HTTP session termination: DELETE to the MCP
// endpoint with the Mcp-Session-Id header. Per spec, MCP clients
// send this at end-of-conversation to release server-side session
// state. It carries no payload and no user-actionable intent —
// must bypass the judge with reason "transport_stream".
//
// Without this bypass, the judge sees "DELETE http://...mcp" with
// an empty body and reasonably (but wrongly) responds along the
// lines of "Action involves deleting data, which is not strictly
// necessary for user intent" — a 403 on routine protocol cleanup
// at end-of-conversation.
func TestOnRequest_TransportStream_MCPSessionTerminate(t *testing.T) {
fj := &fakeJudge{}
p := newConfiguredIBAC(t, fj)

pctx := makePCtx(t)
pctx.Method = "DELETE"
pctx.Host = "exgentic-mcp-gsm8k-mcp:8000"
pctx.Path = "/mcp"
pctx.Body = nil
pctx.Headers.Set("Mcp-Session-Id", "abc-123")
action := invokeOnRequest(p, pctx)

if action.Type != pipeline.Continue {
t.Errorf("got %v, want Continue for MCP session terminate", action.Type)
}
if fj.calls != 0 {
t.Errorf("judge calls = %d, want 0 (session terminate must not reach judge)", fj.calls)
}
inv := lastInvocation(t, pctx)
if inv.Action != pipeline.ActionSkip {
t.Errorf("Invocation action = %v, want ActionSkip", inv.Action)
}
if inv.Reason != "transport_stream" {
t.Errorf("Invocation reason = %q, want 'transport_stream'", inv.Reason)
}
}

// Body-less DELETE WITHOUT the Mcp-Session-Id header must still be
// judged. A real "delete this resource" call (e.g. DELETE /api/
// users/42) carries no MCP session header and is exactly the kind
// of side-effect action IBAC exists to evaluate. The header is the
// load-bearing distinguisher between transport cleanup and a user-
// meaningful delete.
func TestOnRequest_TransportStream_BodylessDELETEWithoutHeaderIsJudged(t *testing.T) {
fj := &fakeJudge{verdict: "allow"}
p := newConfiguredIBAC(t, fj)

pctx := makePCtx(t)
pctx.Method = "DELETE"
pctx.Host = "api.example.com"
pctx.Path = "/api/users/42"
pctx.Body = nil
// no Mcp-Session-Id header
_ = invokeOnRequest(p, pctx)

if fj.calls != 1 {
t.Errorf("judge calls = %d, want 1 (real DELETE without MCP header must be judged)", fj.calls)
}
}

// DELETE + Mcp-Session-Id header + non-empty body must still be
// judged. The header alone isn't sufficient — the body-first guard
// in isTransportShaped is what keeps an attacker (or a misbehaving
// SDK) from smuggling action payload through what looks like
// transport cleanup. Locks in the same "header + body → not
// bypassed" invariant that body-having GETs already cover.
func TestOnRequest_TransportStream_MCPSessionTerminate_WithBodyIsJudged(t *testing.T) {
fj := &fakeJudge{verdict: "allow"}
p := newConfiguredIBAC(t, fj)

pctx := makePCtx(t)
pctx.Method = "DELETE"
pctx.Host = "exgentic-mcp-gsm8k-mcp:8000"
pctx.Path = "/mcp"
pctx.Body = []byte(`{"unexpected":"payload"}`)
pctx.Headers.Set("Mcp-Session-Id", "abc-123")
_ = invokeOnRequest(p, pctx)

if fj.calls != 1 {
t.Errorf("judge calls = %d, want 1 (DELETE+Mcp-Session-Id with body must be judged, not bypassed)", fj.calls)
}
}

// describeAction's first non-empty token must be the HTTP method, with
// no leading whitespace. This locks in the listener-side pctx.Method
// wiring contract: if any listener regresses to leaving Method empty,
Expand Down
Loading