Skip to content

[mcp-2026] Remove JSON-RPC sidecar; one /mcp owner (official mcp 2.x) #22

Closed

Description

@manish-wekan

Context

MCP 2026-07-28 work shipped in #21 and was merged to develop and main.

/mcp is now served twice:

  1. Official mcp 2.x StreamableHTTPSessionManager in nitrostack/transports/http.py (build_http_app).
  2. NitroStack ASGI sidecar wrap_stateless_transport → StatelessTransportMiddleware → StatelessIngressPipeline.handle_post in nitrostack/transports/middleware.py / dispatch.py.

McpApplication.get_combined_app wraps (1) with (2) whenever the HTTP engine is sessionless (auto / modern). The sidecar parses JSON-RPC and answers ping, header-only ping (empty or garbage body + Mcp-Method: ping), server/discover, and era-auto initialize / initialized before mcp sees the request.

There is also a third unused wrapper: SessionlessHttpGuard / wrap_sessionless_http / wrap_modern_handshake_reject. It is not used by get_combined_app. Its discover call is self.discover_handler() with no request (wrong arity vs DiscoverHandler).

Handoff comment (full plan): #21 (comment)

This issue is WP0. Do this before WP1–WP3 and the test rewrite. Do not “fix ping in the sidecar.” Delete sidecar RPC ownership.

Goal

One /mcp owner: official mcp 2.x. NitroStack may keep CORS (see WP1), health, landing, widgets, OAuth stubs, subscription attach. It must not parse JSON-RPC and return protocol results from ASGI middleware.

Probe (do this first)

Work from current develop. Do not invent a parallel HTTP stack.

rg -n "wrap_stateless_transport|wrap_sessionless_http|StatelessIngressPipeline|build_ping_response|is_header_only_ping" nitrostack tests
rg -n "get_combined_app" -A 80 nitrostack/core/app.py

Read:

  • nitrostack/core/app.py — get_combined_app (wrap site)
  • nitrostack/transports/middleware.py — StatelessTransportMiddleware, SessionlessHttpGuard, _replay_receive, _send_session_id_rejected
  • nitrostack/transports/dispatch.py — handle_post, is_header_only_ping
  • nitrostack/transports/http.py — build_http_app (the engine that must remain)
  • nitrostack/protocol/jsonrpc.py — build_ping_response

Confirm _replay_receive loops until http.disconnect after replaying a buffered POST body (keepalive hang risk). Confirm _send_session_id_rejected has no callers.

Implementation

  1. Stop calling wrap_stateless_transport from get_combined_app. Return build_http_app(...) only.
  2. Stop answering JSON-RPC in StatelessIngressPipeline.handle_post (or delete the pipeline from the live path). No ping, no discover intercept, no initialize intercept, no header-only ping.
  3. Delete or unexport from the live path: wrap_sessionless_http, wrap_modern_handshake_reject, SessionlessHttpGuard.
  4. Delete build_ping_response, PING_METHOD, is_header_only_ping from production (tests will break; expected until the test-rewrite issue).
  5. If any body-buffering middleware remains: do not keep _replay_receive as loop-until-disconnect. Preferred: do not buffer the body at all.
  6. Delete dead _send_session_id_rejected if its file still exists.

Do not add more era aliases. Do not reimplement discover/initialize in a “thinner” middleware. Do not rewrite OAuth, widgets, CLI, or Duffel examples.

If you keep a tiny ASGI wrapper, it may only: strip Mcp-Session-Id on sessionless eras, add CORS if WP1 has not unified CORS yet. No JSON-RPC responses.

Test / verify

Existing tests/test_mcp20_stateless_http.py asserts sidecar ping/discover. After this change those tests will fail. Do not rewrite that 3k-line file here (separate issue). For WP0:

  1. Add or temporarily run a small Starlette TestClient against get_combined_app():
    • POST /mcp with {"jsonrpc":"2.0","id":1,"method":"ping"} must not return a sidecar empty result: {} success. Prefer -32601 (method not found) once mcp owns the route. If mcp still implements ping, file a follow-up on WP2 rather than restoring the sidecar.
    • Response must not include Mcp-Session-Id on default auto era.
    • GET /mcp/health still 200.
  2. rg wrap_stateless_transport nitrostack/core/app.py — no wrap call.
  3. rg is_header_only_ping nitrostack/ — gone from production.
  4. Manual: MCP_TRANSPORT_TYPE=http boot, POST /mcp invalid method still gets a JSON-RPC error from mcp (not empty 500).

Review

  • Diff of get_combined_app is “unwrap,” not a new factory.
  • No second JSON-RPC parser on the success path.
  • build_http_app still mounts Streamable HTTP.

Success

  • get_combined_app() does not wrap with wrap_stateless_transport
  • No production build_ping_response / header-only ping
  • Unused third wrapper gone from production imports used by app.py
  • Health + streamable /mcp still work
  • No private new HTTP engine

Related

Depends on nothing. Blocks WP1 (HTTP factory), WP2 (wire in handlers), WP3 (task registration), and the test rewrite.

Activity

  1. manish-wekan commented on Oct 27, 2026

    @manish-wekan
    CollaboratorAuthor

    Interaction with the transform pipeline (PR #88, issues #61 / #62)

    PR #88 adds the transform pipeline (#61) and progressive discovery (#62). It hooks into the tools/list and tools/call handlers on the low-level server, not the sidecar, so removing the sidecar does not bypass it. The pipeline does need a request context that this issue now owns, so this issue should add the items below.

    Verified

    Checked on PR #88 head 69e316a, alone and with PR #34 merged on top. To merge #34 I resolved the dispatch.py / middleware.py modify/delete conflicts by keeping the deletion.

    Check (HTTP POST /mcp via get_combined_app()) #88 #88 + #34
    tools/list returns only search_tools, call_tool (auto, modern, legacy) yes yes
    search_tools → call_tool runs the real tool yes yes
    call_tool on a @use_guards tool is denied yes yes
    ping on auto / modern result: {} (sidecar) −32601
    request_ctx set while a transform runs in tools/list no no
    TransformContext.session_id / .execution_context populated no (always None) no
    Mcp-Session-Id visible to transforms on legacy only inside tools/call only inside tools/call
    Full suite 855 passed 614 passed, 1 failed, 4 collection errors (all sidecar test modules, see #27)

    So the merge order of #88 and #34 is not blocked by behaviour. The gap is the request context the pipeline receives.

    What this issue should add

    1. Set request_ctx for every handler, in one place.
    _list_tools (nitrostack/core/app.py:905 on #88) never calls request_ctx.set(ctx). Only tools/call, resources/read, prompts/get and the task handlers do it (app.py:933, 966, 991, 1781–1855), each in its own copy. With the sidecar gone, the official ServerRequestContext is the only source of request data. Set the contextvar once, around every request handler (tools/list, resources/list, prompts/list included), for example in NitroStackMcpServer's dispatch, rather than adding another per-handler copy.

    2. Define the caller identity on sessionless eras and expose it through one helper.
    After this issue, auto and modern never carry Mcp-Session-Id. A request-scoped key therefore has to be:

    • the Mcp-Session-Id on legacy;
    • the verified token subject (_auth_metadata_from_request_ctx) on auto and modern;
    • otherwise None, documented as such.

    Expose this as one function, for example caller_identity(request_ctx) -> (session_id, subject), and use it for both TransformContext and task ownership (#25). That keeps "unsigned userId is not identity" in a single place. Per-session tool visibility depends on this decision.

    3. Build TransformContext from the request.
    TransformContext() at app.py:916 and TransformContext(_invoker=...) at app.py:1297 leave session_id and execution_context empty. Populate both from item 2 in the tools/list and tools/call handlers. The field names already exist in nitrostack/transforms/transform.py:48-49; only the wiring is missing. If #88 merges first, this is a small follow-up in this issue. If this issue merges first, #88 picks it up on rebase.

    4. Add HTTP-level transform tests to this issue's test file.
    The #88 tests call request_handlers in-process, so they would still pass if a future ASGI layer answered tools/list itself. In tests/test_mcp22_official_http_owner.py, add a TestClient(get_combined_app()) test with transforms=[RegexSearchTransform()] covering:

    • tools/list returns only the meta-tools;
    • search_tools then call_tool returns the real result;
    • call_tool on a guarded tool is denied;
    • a spy transform sees non-None request_ctx in both hooks, and the expected identity per era.

    This proves "one /mcp owner" for the pipeline, not only for ping.

    5. Rebase #34 on current develop.
    #34 still conflicts on nitrostack/transports/dispatch.py and middleware.py (modify/delete). Keep the deletion. The 4 collection errors (test_mcp20_blueprint.py, test_mcp20_deprecated.py, test_mcp20_jsonrpc_wire.py, test_mcp20_stateless_http.py) and the test_mcp20_acceptance.py::...test_mcp20_test_modules_importable failure are the expected sidecar test breakage for #27. List them in the PR so reviewers can tell them apart from real regressions.

    Out of scope here (noted for the owners of those issues)

    Acceptance additions

    • request_ctx is set for every request handler from a single place
    • One caller_identity helper, documented per era, used by transforms and task access
    • TransformContext.session_id / execution_context populated in tools/list and tools/call
    • HTTP-level transform tests in tests/test_mcp22_official_http_owner.py
    • [mcp-2026] Remove JSON-RPC sidecar; one /mcp owner #34 rebased on develop; remaining failures are only the sidecar modules listed above
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions