Skip to content

Stateless - #21

Merged
manish-wekan merged 60 commits into
developfrom
stateless
Sep 9, 2026
Merged

Stateless#21
manish-wekan merged 60 commits into
developfrom
stateless

Conversation

@manish-wekan

Copy link
Copy Markdown
Collaborator

No description provided.

Bring in PR 17 (Duffel API v2) and PR 18 (agent skills on init/upgrade).
pallavt93 and others added 22 commits September 8, 2026 20:22
feat: implement MCP 2026-07-28 stateless protocol, task management, CIMD/OAuth 2.1 hardening, and MRTR support
@manish-wekan

Copy link
Copy Markdown
Collaborator Author

Verdict

Do not merge this PR as-is. It is the same change as #20 (stateless @ d2ad3a70, +15156/−650): official mcp 2.x plus a NitroStack JSON-RPC sidecar (StatelessIngressPipeline / wrap_stateless_transport) that still speaks 2025 (ping, initialize, tasks/result, tasks/list) and does not fully speak 2026-07-28.

This comment is an implementation handoff. Dual-era as a product (legacy / auto / modern) is fine. Copying official SDK internals is not required. NitroStack may keep modules, DI, @tool, TaskManager, CIMD in auth/. The wire on /mcp must be one engine.


Goal

One /mcp owner: official mcp 2.x (StreamableHTTPSessionManager already mounted in nitrostack/transports/http.py). NitroStack HTTP code may:

  • add CORS, /mcp/health, landing, widget preview, OAuth well-known, /subscriptions/listen attach
  • strip Mcp-Session-Id on sessionless eras
  • stamp NitroStack-specific result _meta (serverInfo, cache hints) inside handlers, not by intercepting JSON-RPC in ASGI middleware

NitroStack HTTP code must not:

  • parse JSON-RPC and return ping / discover / initialize / method-not-found itself
  • maintain a second method-contract table that disagrees with the handlers registered on NitroStackMcpServer

Non-goals

  • Do not port official SDK DX (no requirement to match their class layout).
  • Do not add more era aliases (both, dual-spec, latest, …). Keep legacy | auto | modern.
  • Do not implement Tasks on the 2026 wire until tasks/update + -32021 exist. Until then: do not advertise the tasks extension; do not register tasks/list / tasks/result on modern/auto.
  • Do not keep test_mcp20_stateless_http.py at ~3300 lines of pipeline unit tests. Replace with a small TestClient matrix against get_combined_app.
  • Do not bump PyPI version unless release asks. mcp>=2,<3 stays.
  • Do not rewrite OAuth token validation, widgets, CLI, or Duffel examples except where the HTTP wrap forces a compile/import break.

Assumptions (do not silently change)

  1. Default era stays auto (2025 clients can still handshake; 2026 clients use discover). modern is 2026-only. legacy is sessionful 2025.
  2. MCP_STATELESS as an era override is a footgun. After this work, era comes from NITRO_MCP_PROTOCOL_VERSION then ServerConfig.protocol_era then auto. If MCP_STATELESS must remain for one release, document it as deprecated and map truemodern, falselegacy only — do not invent a third path.
  3. Internal TaskManager / @tool(task_support=...) may stay for in-process and 2025. They must not be advertised on 2026 discover until WP3 is done.
  4. CIMD stays in nitrostack/auth/. Do not move it into protocol/.

Delete first (WP0)

Delete or stop calling these. Do not “fix” them in place.

Remove from the live HTTP path Why
wrap_stateless_transport in McpApplication.get_combined_app Second JSON-RPC server in front of mcp
StatelessIngressPipeline.handle_post answering RPCs ping, discover, initialize, header-only ping
SessionlessHttpGuard / wrap_sessionless_http / wrap_modern_handshake_reject Third unused wrapper; discover_handler() called with wrong arity
build_ping_response, PING_METHOD, is_header_only_ping Spec removed ping
runtime/acceptance.py + runtime/conformance.py as “proof” They assert file layout and ping; they do not assert the spec
Duplicate looks_like_cimd_url / resolve_cimd_sync in auth/cimd.py (lines 374–392) Copy-paste
StatelessTransportMiddleware._send_session_id_rejected Dead
tasks/notify.py or wire it (WP3). Prefer delete if Tasks are not on the wire TaskStatusRouter has no production callers

After WP0, get_combined_app should return build_http_app(...) only.

Verify: rg wrap_stateless_transport nitrostack/ has no call from app.py. rg build_ping_response is gone from production (tests may fail; that is expected until WP5).


WP1 — One HTTP engine (transports/http.py, core/app.py, core/mcp_server.py)

Keep build_http_app as the only ASGI factory.

  1. CORS once. Starlette CORSMiddleware in build_http_app is enough. Do not add a second CORS layer in ASGI middleware. Default * is existing behavior; leave it unless you already have MCP_CORS_ALLOWED_ORIGINS.
  2. Slash rewrite (ExactEndpointSlashMiddleware) stays — Inspector depends on it.
  3. HeaderCompatMiddleware: keep Accept rewrite for Inspector if still needed; keep drop_session_headers on sessionless. Do not strip MCP-Protocol-Version.
  4. Era → manager flag: legacyStreamableHTTPSessionManager(stateless=False). auto and modernstateless=True. resolve_http_engine already does this; stop overwriting caller stateless in surprising ways — derive it only from era.
  5. NitroStackMcpServer.get_capabilities: caps.resources.subscribe must match discover (False on sessionless 2026; True only if you actually implement resources/subscribe on that era). Today discover says subscribe: false and the server sets True (mcp_server.py ~70). Pick one: False for auto/modern.
  6. has_task_support: do not set True unconditionally in _register_task_handlers. Gate on _advertise_tasks_extension() and WP3.
  7. Stdio: nitrostack/transports/stdio.py must not import mcp.server.runner._serve_modern_stream (private). Use public server.run / serve_loop only. If modern-reject-initialize is required, do it via mcp public era APIs or by not registering handshake handlers — not a private import.
  8. Fix _replay_receive if any body-buffering middleware remains. The current loop-until-http.disconnect can hang keepalive POSTs. Preferred: do not buffer the body. If you must buffer (you should not after WP0), replay body once then forward subsequent receive() messages unchanged.

Verify: one process, MCP_TRANSPORT_TYPE=http, TestClient POST /mcp reaches the mcp manager (missing method → mcp error, not sidecar). Response has no Mcp-Session-Id on auto/modern.


WP2 — 2026 wire in handlers (not in ASGI)

Work in core/app.py _setup_handlers, protocol/discovery.py, protocol/meta.py, protocol/errors.py, protocol/method_contract.py.

2.1 Errors

Add to JsonRpcErrorCode:

  • -32021 Missing Required Client Capability

Keep -32020 / -32022 only if mcp emits them or you add a thin header check that does not parse the full method table. Prefer letting mcp 2.x own header/version errors. If you keep a header check, it must be: missing/mismatch MCP-Protocol-Version or Mcp-Method → those codes, then return to mcp. No ping fast path.

2.2 Kill ping

  • Remove from MODERN_METHOD_CONTRACTS
  • Remove handle_post / tests that expect 200 + empty result for method: ping or Mcp-Method: ping with garbage body
  • Modern/auto: unknown ping-32601

2.3 Discover

Single builder: build_discover_result used by handle_server_discover and by server.add_request_handler("server/discover", ...). No ASGI intercept.

Required result shape (2026):

  • Business fields: capabilities, supportedVersions / protocolVersion as spec requires
  • serverInfo (name, version) on result _meta, not only as a top-level discover field. If you also keep a top-level serverInfo for older clients on era auto, document it; modern must match 2026 _meta rules
  • resultType on the discover result
  • capabilities.resources.subscribe consistent with handlers
  • Advertise io.modelcontextprotocol/tasks only if WP3 is implemented and at least one tool has task_support optional/required
  • Advertise io.modelcontextprotocol/app only when widget tools exist (already)

Era modern: do not answer initialize / notifications/initialized (method-not-found). Era auto: mcp 2.x dual-era loop may still answer 2025 initialize — do not reimplement that in NitroStack middleware. Delete build_sessionless_initialize_result from the sidecar path; if mcp needs a NitroStack payload, pass it via initialize_handler on the Server instance only (NitroStackMcpServer.initialize_handler), not ASGI.

2.4 Headers (modern)

On era modern, POST /mcp JSON-RPC MUST send MCP-Protocol-Version: 2026-07-28 and Mcp-Method matching the body. tools/call / resources/read / prompts/get MUST send Mcp-Name matching name/uri.

Era auto: do not require 2026 headers on 2025 handshake; do require them on server/discover and 2026 methods if mcp does.

Delete requires_method_header=False on tools/call for modern. Delete the dual validators (validate_header_body_method vs validate_required_mcp_method) — one function: if required and missing/mismatch → -32020.

2.5 resultType and _meta on all 2026 results

Stamp in the NitroStack handlers that already build mcp types:

  • tools/list, resources/list, prompts/list: cache hint _meta (already) + resultType if spec requires it on those results
  • tools/call success/error: resultType on structured/result as spec; widget _meta stays
  • resources/read, prompts/get: same
  • Do not only set resultType on discover / task / MRTR

2.6 Deprecated 2025 methods on modern

Reject as -32601 (or spec’s retired-method code if different): tasks/result, tasks/list, resources/subscribe, logging/setLevel, ping, initialize (modern only).

Do this by not registering those handlers on modern, or by registering a single not-found handler — not a sidecar that returns None so mcp still runs them.

_subscribe_resource is still registered in _setup_handlers (~913). On auto/modern, do not register it. On legacy, keep if 2025 needs it.

2.7 Method table

MODERN_METHOD_CONTRACTS must not list ping, tasks/result, tasks/list as live modern methods. Add tasks/update only in WP3. If the table exists only for tests, delete the table and test via HTTP.

Verify:

# modern era
POST /mcp  {"method":"ping"}           → -32601
POST /mcp  {"method":"server/discover"} without headers → -32020 or mcp equivalent
POST /mcp  discover with headers        → 200, serverInfo in result _meta, no Mcp-Session-Id
POST /mcp  {"method":"initialize"}      → -32601

WP3 — Tasks: hide or finish (pick hide unless product demands 2026 tasks)

Official Python SDK skipped Tasks. NitroStack already has TaskManager. Default for this PR: hide on 2026 wire.

Hide (default)

  1. Do not set server.has_task_support = True unless era is legacy and a tool opts in (product call). For auto/modern: False.
  2. Do not register tasks/list, tasks/result, tasks/get, tasks/cancel on auto/modern.
  3. tools/call with params.task on modern → -32021 or invalid-params (“server does not support tasks”), not a CreateTaskResult.
  4. Keep TaskManager for in-process tests / 2025 if still used.
  5. Delete or leave unused: protocol/tasks.py wire helpers that duplicate mcp types; tasks/notify.py if unhooked.

Finish (only if product requires 2026 tasks)

Must have all of:

  • tasks/get, tasks/update, tasks/cancel (no tasks/result, no tasks/list on modern)
  • -32021 when client lacks the tasks capability or tool forbids/requires task augmentation incorrectly
  • One task model: stop duplicating core/task.py TaskStatus (expired) vs tasks/types.py (completed|failed|cancelled). Drop expired from the wire enum or map it to failed
  • Hook TaskStatusRouter into complete_task / fail_task / update_progress or delete notify.py
  • Wire notifications/cancelled to InFlightRegistry.cancel or delete the registry; today register/discard never sees cancel
  • InMemoryTaskStore: asyncio lock around dict ops
  • Task ids: full UUID, not task_ + 12 hex chars
  • Advertise tasks extension only when a tool has task_support optional/required

Do not ship half of Finish plus Hide. One or the other.


WP4 — Auth / CIMD leftovers (small, keep the good parts)

Keep: HTTPS-only CIMD, DNS pin, RFC 6890 blocks, no redirects, 5 KiB cap, client_id == fetch URL, trusted-proxy for X-Forwarded-Host.

Fix:

  1. Delete the duplicate functions at the bottom of auth/cimd.py.
  2. apply_cimd_to_registration_body: if _cimd_host_matches_request is False, raise CimdValidationError — do not return the document with a flag.
  3. cimd_peer_is_acceptable: outbound fetch peer must equal pinned IP. Do not treat TRUSTED_PROXIES as an exception on outbound CIMD sockets.
  4. verify_bearer_payload: do not except Exception around DI lookup. Missing JWTService → no identity. JWT verify failure → no identity. Programming errors should surface.
  5. Envelope _meta Bearer remains a stdio fallback after HTTP Authorization. Do not use unsigned userId/tenantId (already correct — keep tests).

Verify: existing CIMD unit tests still pass; add one test that host mismatch raises; add one test that duplicate symbols do not exist (looks_like_cimd_url defined once).


WP5 — Tests (replace, don’t pile on)

Delete or gut:

  • tests/test_mcp20_acceptance.py layout/count assertions
  • tests/test_mcp20_blueprint.py “module can be imported” as success
  • Most of tests/test_mcp20_stateless_http.py (pipeline internals, header-only ping)

Keep / rewrite as HTTP integration against McpApplication.get_combined_app() (Starlette TestClient):

Case Era Expect
POST /mcp discover + required headers modern, auto 200, _meta.serverInfo, no session header
POST /mcp ping modern, auto -32601
POST /mcp initialize modern -32601
POST /mcp initialize auto mcp 2.x 2025 handshake or documented auto behavior; no NitroStack sidecar
POST /mcp tools/call without Mcp-Method modern -32020
POST /mcp tools/call unknown tool any tool isError or -32601 — pick one and test it
GET /mcp/health auto protocolEra present
CIMD blocked IP / redirect / oversize / client_id mismatch existing tests
Cross-tenant tasks/get only if WP3 Finish -32602 / not found

Do not test StatelessIngressPipeline as a unit if it no longer answers RPCs.

Concurrency: if InFlightRegistry is deleted, delete test_mcp20_concurrency.py cases that poke app._in_flight. If kept, cancel must be wired to a real notification.


WP6 — Public API cleanup

  • nitrostack/__init__.py / protocol/__init__.py: stop exporting build_ping_response, wrap_stateless_transport, StatelessIngressPipeline if deleted.
  • protocol/method_contract.py: shrink or delete once handlers are the source of truth.
  • runtime/layers.py (RuntimeLayer enum): unused — delete only if this PR introduced it (it did). Same for unused DispatchStage if the pipeline dies.
  • /json/version in http.py still reports "Protocol-Version": "2025-06-18" — set from protocol_version_for_era.
  • pyproject.toml version 0.3.2 — leave unless release.

Order of work (agent)

WP0 delete sidecar from get_combined_app     → app starts, mcp handles POST /mcp
WP1 subscribe flag + stdio public API        → discover vs capabilities agree
WP2 ping gone, discover _meta, headers       → TestClient table in WP5 starts green
WP3 Hide tasks on 2026                       → no tasks/* on modern/auto
WP4 CIMD deny on host mismatch + dedupe      → oauth_cimd tests
WP5 rewrite tests, delete 3k-line file       → pytest tests/test_mcp20_*.py focused
WP6 exports                                  → rg ping / wrap_stateless in nitrostack/ empty

Do not start WP3 Finish. Do not add era tokens. Do not keep a “thin” handle_post that still returns ping “for Inspector.”


Known bugs to fix if the code they live in survives

These are from the line-by-line review of this PR. If WP0 deletes the file, skip.

  • auth/cimd.py: duplicate looks_like_cimd_url / resolve_cimd_sync
  • tasks/authorization.py: uses Any without importing it (ok with from __future__ import annotations, still fix if you touch the file)
  • middleware.py _replay_receive: hang on keepalive (must not remain)
  • SessionlessHttpGuard: discover_handler() vs (request)
  • ttl_ms_to_seconds: max(1, int(ttl_ms/1000)) turns 0ms/500ms into 1s
  • check_task_access(context=None) bypasses isolation — HTTP must always pass a context object (empty is OK)
  • cache decorator skips ExecutionContext in the key — do not expand cache work in this PR (pre-existing); do not use it to implement SEP-2549

Success criteria (PR is mergeable when)

  1. get_combined_app() does not wrap with wrap_stateless_transport.
  2. No production function named build_ping_response; ping-32601.
  3. Discover is handled only by NitroStackMcpServer / handle_server_discover; result _meta contains serverInfo.
  4. resources.subscribe is the same boolean on discover and get_capabilities for a given era.
  5. Modern POST without MCP-Protocol-Version / Mcp-Method fails closed.
  6. Auto/modern responses never emit Mcp-Session-Id.
  7. Tasks extension absent on modern/auto unless WP3 Finish is complete (it should not be).
  8. CIMD host mismatch is an error.
  9. pytest on the remaining mcp20 tests + existing tests/test_transport_http.py / tests/test_lifecycle_http.py / tests/test_tasks.py pass.
  10. No new private imports from mcp.server.runner._*.

Out of scope leftovers (mention, don’t fix here)

  • @cache skipping auth in the cache key
  • Widget preview hardcoded pizza/flight defaults in http.py
  • OAuth fail-open (OAUTH_REQUIRED) behavior
  • Package version bump / changelog / README feature list for 2026

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.

2 participants