Skip to content

Fix injection of server_id in internally-forwarded streamable HTTP requests#3024

Merged
crivetimihai merged 8 commits intomainfrom
3018_server_id_injection_fix
Feb 20, 2026
Merged

Fix injection of server_id in internally-forwarded streamable HTTP requests#3024
crivetimihai merged 8 commits intomainfrom
3018_server_id_injection_fix

Conversation

@kevalmahajan
Copy link
Copy Markdown
Member

@kevalmahajan kevalmahajan commented Feb 18, 2026

🐛 Bug-fix PR

Closes #3018

📌 Summary

When a Streamable HTTP POST request is forwarded between workers via the internal affinity mechanism (x-forwarded-internally=true), the server_id from the URL path (/servers/{id}/mcp) was not injected into the JSON-RPC params before posting to /rpc. This caused /rpc routing to fall into the unscoped list_tools path instead of the server-scoped list_server_tools path, breaking multi-tenant isolation in multi-worker deployments.

Why this matters: In production deployments with multiple workers and session affinity enabled, clients would receive tools from ALL servers instead of just the requested server, violating tenant isolation.

🔁 Reproduction Steps

Prerequisites:

  • Multi-worker deployment with MCPGATEWAY_SESSION_AFFINITY_ENABLED=true
  • At least 2 registered MCP servers with different tools

Steps:

  1. Establish a stateful MCP session on Worker A via /servers/{server_id}/mcp/
  2. Send a tools/list request that gets routed to Worker B (cross-worker forward)
  3. Worker B forwards the request internally with x-forwarded-internally: true
  4. The forwarded request posts to /rpc without params.server_id
  5. /rpc routes to unscoped handler → returns tools from all servers instead of just {server_id}

Expected: Only tools from the requested server
Actual: Tools from all servers (multi-tenant isolation broken)

🐞 Root Cause

The internally-forwarded branch (lines 1850-1873) was missing the server_id injection logic that was added to the local-owner branch in PR #2974.
Location: mcpgateway/transports/streamablehttp_transport.py:1850-1873
The match variable (containing the extracted server_id from the URL path) was available but not used in this branch.

💡 Fix Description

Added the same server_id injection logic to the internally-forwarded branch that already existed in the local-owner branch.

Key changes:

Inject server_id from URL path (lines 1861-1868):

# Inject server_id from URL path into params for /rpc routing
if match:
    server_id = match.group("server_id")
    if "params" not in json_body:
        json_body["params"] = {}
    if server_id:
        json_body["params"]["server_id"] = server_id
        # Re-serialize body with injected server_id
        body = orjson.dumps(json_body)
        logger.debug(
            f"[HTTP_AFFINITY_FORWARDED] Injected server_id {server_id} into /rpc params"
        )

Added comprehensive test coverage in test_streamablehttp_transport.py:

test_forwarded_post_injects_server_id_from_url - Verifies the fix
test_forwarded_post_no_server_id_in_url_no_injection - Edge case without server_id
test_forwarded_post_notification_no_server_id_injection - Notification handling
test_local_affinity_post_injects_server_id_regression - Regression test

Design points:

Mirrors the existing pattern in the local-owner branch (lines 2003-2010) for consistency
Uses the existing match variable captured at line 1776
Placed after notification handling (line 1859) and before httpx client call (line 1869)
Added debug logging for observability
All 251 transport tests pass with no regressions
Impact: Restores proper server-scoped routing and multi-tenant isolation in multi-worker deployments.

🧪 Verification

Check Command Status
Lint suite make lint
Unit tests make test
Coverage ≥ 80 % make coverage
Manual regression no longer fails steps / screenshots

📐 MCP Compliance (if relevant)

  • Matches current MCP spec
  • No breaking change to MCP clients

✅ Checklist

  • Code formatted (make black isort pre-commit)
  • No secrets/credentials committed

@kevalmahajan kevalmahajan marked this pull request as draft February 18, 2026 07:38
@kevalmahajan kevalmahajan marked this pull request as ready for review February 18, 2026 08:06
crivetimihai
crivetimihai previously approved these changes Feb 19, 2026
Copy link
Copy Markdown
Member

@crivetimihai crivetimihai left a comment

Choose a reason for hiding this comment

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

Clean fix. The server_id injection from the URL path into JSON-RPC params is correctly guarded (if match and if server_id), and creates the params dict when missing. Comprehensive test coverage: injection with missing params, no-injection for non-server URLs, no-injection for notifications (202), and regression test for the local-owner path.

@crivetimihai crivetimihai self-assigned this Feb 20, 2026
kevalmahajan and others added 7 commits February 20, 2026 00:41
Signed-off-by: Keval Mahajan <mahajankeval23@gmail.com>
Signed-off-by: Keval Mahajan <mahajankeval23@gmail.com>
Signed-off-by: Keval Mahajan <mahajankeval23@gmail.com>
Signed-off-by: Keval Mahajan <mahajankeval23@gmail.com>
Signed-off-by: Keval Mahajan <mahajankeval23@gmail.com>
Add test_forwarded_post_injects_server_id_with_existing_params to verify
that server_id is properly merged into an existing params dict without
overwriting other keys. Fix misleading docstring on related test.

Signed-off-by: Mihai Criveti <crivetimihai@gmail.com>
…-owner branch

The regex [a-fA-F0-9\-]+ guarantees server_id is always non-empty when
match is truthy, making the `if server_id:` guard dead code. Removing it
aligns the internally-forwarded branch with the structurally identical
local-owner branch and eliminates a misleading code path where params
dict creation would not be serialized back into body.

Signed-off-by: Mihai Criveti <crivetimihai@gmail.com>
Replace `if "params" not in json_body` with `if not isinstance(... dict)`
in both the internally-forwarded and local-owner injection branches.

When params is null or a list (valid JSON-RPC but unusual for MCP), the
key-existence check passes but dict subscript assignment throws TypeError,
causing the request to silently fall through to the SDK path instead of
routing to /rpc — defeating the server-scoped routing fix.

Add parametrized tests for both branches covering params:null and
params:[] to prevent regression.

Signed-off-by: Mihai Criveti <crivetimihai@gmail.com>
@crivetimihai crivetimihai merged commit bf49faa into main Feb 20, 2026
54 checks passed
@crivetimihai crivetimihai deleted the 3018_server_id_injection_fix branch February 20, 2026 11:13
Copy link
Copy Markdown
Member

@crivetimihai crivetimihai left a comment

Choose a reason for hiding this comment

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

Re-reviewed after the latest commits. The hardening looks good:

  • isinstance(json_body.get("params"), dict) correctly handles null, [], and missing params — all coerced to a fresh dict before injection. Better than the previous "params" not in json_body check.
  • The fix is now applied to both injection paths (forwarded branch + local-owner branch) — good consistency.
  • 7 new tests with solid coverage: missing params, existing params, non-dict params (null/list), no-match URL, notification, and regression for local-owner path.

LGTM.

vishu-bh pushed a commit that referenced this pull request Feb 24, 2026
…quests (#3024)

* inject server_id for internally forwarded request

Signed-off-by: Keval Mahajan <mahajankeval23@gmail.com>

* added test cases for server_id injection

Signed-off-by: Keval Mahajan <mahajankeval23@gmail.com>

* lint

Signed-off-by: Keval Mahajan <mahajankeval23@gmail.com>

* lint

Signed-off-by: Keval Mahajan <mahajankeval23@gmail.com>

* increase cov

Signed-off-by: Keval Mahajan <mahajankeval23@gmail.com>

* fix(test): add missing test for server_id injection with existing params

Add test_forwarded_post_injects_server_id_with_existing_params to verify
that server_id is properly merged into an existing params dict without
overwriting other keys. Fix misleading docstring on related test.

Signed-off-by: Mihai Criveti <crivetimihai@gmail.com>

* refactor: remove redundant server_id guard for consistency with local-owner branch

The regex [a-fA-F0-9\-]+ guarantees server_id is always non-empty when
match is truthy, making the `if server_id:` guard dead code. Removing it
aligns the internally-forwarded branch with the structurally identical
local-owner branch and eliminates a misleading code path where params
dict creation would not be serialized back into body.

Signed-off-by: Mihai Criveti <crivetimihai@gmail.com>

* fix: guard server_id injection against non-dict params (null, list)

Replace `if "params" not in json_body` with `if not isinstance(... dict)`
in both the internally-forwarded and local-owner injection branches.

When params is null or a list (valid JSON-RPC but unusual for MCP), the
key-existence check passes but dict subscript assignment throws TypeError,
causing the request to silently fall through to the SDK path instead of
routing to /rpc — defeating the server-scoped routing fix.

Add parametrized tests for both branches covering params:null and
params:[] to prevent regression.

Signed-off-by: Mihai Criveti <crivetimihai@gmail.com>

---------

Signed-off-by: Keval Mahajan <mahajankeval23@gmail.com>
Signed-off-by: Mihai Criveti <crivetimihai@gmail.com>
Co-authored-by: Mihai Criveti <crivetimihai@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[BUG][TRANSPORT]: server_id not injected for internally-forwarded Streamable HTTP requests

2 participants