Skip to content

fix: refuse standalone GET on the MCP endpoint with 405 (#136) - #140

Merged
hellices merged 2 commits into
mainfrom
fix/mcp-get-sse-shutdown
Aug 1, 2026
Merged

fix: refuse standalone GET on the MCP endpoint with 405 (#136)#140
hellices merged 2 commits into
mainfrom
fix/mcp-get-sse-shutdown

Conversation

@hellices

@hellices hellices commented Aug 1, 2026

Copy link
Copy Markdown
Owner

Fixes #136

Problem

Quitting korvid while an external MCP host held a standalone GET /mcp connection printed two ERROR tracebacks over the terminal ("Exception in ASGI application" + a lifespan CancelledError).

Chain (reproduced deterministically):

  1. The mcp SDK (1.28.x) serves GET as an infinite standalone SSE stream even in stateless mode with json_response=True — a stream this server never sends anything on (no server-initiated messages exist by construction).
  2. korvid neutralizes uvicorn's signal capture (the TUI owns signals), so sse-starlette's shutdown watcher (signal-based) never fires and the stream never closes on should_exit.
  3. uvicorn's graceful shutdown (timeout_graceful_shutdown=None) waits forever on the open connection, MCPController.shutdown()'s 5 s deadline falls back to a hard task.cancel(), and the CancelledError escapes the SSE task group and the lifespan.

Fix

Answer anything but POST with 405 Method Not Allowed in the ASGI wrapper, before the SDK session manager. Spec-sanctioned:

The server MUST either return Content-Type: text/event-stream in response to this HTTP GET, or else return HTTP 405 Method Not Allowed, indicating that the server does not offer an SSE stream at this endpoint.

The TypeScript SDK's stateless mode does exactly this (405 for non-POST before any transport is constructed); upstream python-sdk PR modelcontextprotocol/python-sdk#3129 addresses only the stateful pre-session GET, so the stateless path needs this server-side guard.

Tests

  • test_get_is_refused_with_405_instead_of_an_sse_stream — GET gets 405, not an SSE stream.
  • test_shutdown_completes_while_a_client_holds_a_get_connection — regression: graceful shutdown completes within the controller deadline (previously hung forever, then hard cancel, then tracebacks).

Full gate (make check) green: ruff, mypy, 2794 passed / 21 skipped, tach.

The mcp SDK (1.28.x) serves GET as an infinite standalone SSE stream even
in stateless mode with JSON responses — a stream korvid can never send
anything on. Because korvid neutralizes uvicorn's signal capture (the TUI
owns signals), sse-starlette's shutdown watcher never fires, uvicorn's
graceful shutdown waits forever on the open connection, and the
controller's 5s deadline falls back to a hard cancel that tears the
stream down mid-request: 'Exception in ASGI application' (CancelledError)
plus a lifespan traceback land on the terminal the TUI is drawing on.

The MCP spec allows a server that offers no SSE stream to answer GET with
405 Method Not Allowed (the TypeScript SDK's stateless mode does exactly
this), so refuse everything but POST in the ASGI wrapper before it
reaches the SDK session manager. No connection is ever held open, and
graceful shutdown completes within the controller's deadline.

Fixes #136

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings August 1, 2026 04:25

Copilot AI 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.

Pull request overview

Refuses non-POST MCP requests to prevent infinite GET SSE streams from blocking shutdown.

Changes:

  • Returns HTTP 405 with Allow: POST for non-POST requests.
  • Adds GET rejection and shutdown regression tests.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.

File Description
src/korvid/mcp/server.py Adds the HTTP method guard.
tests/mcp/test_server.py Adds GET and shutdown coverage.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/korvid/mcp/server.py
Comment thread tests/mcp/test_server.py
…n-connection shutdown regression

Review round 1 on #140:

- The early 405 bypassed the DNS-rebinding Host/Origin validation for
  non-POST requests. Run the same TransportSecurityMiddleware check the
  session manager applies before answering 405, so a hostile Origin on a
  GET is refused (403), never acknowledged. New test:
  test_hostile_origin_get_is_rejected_not_answered_405.

- The shutdown regression test awaited the GET to completion before
  requesting shutdown, so it never exercised shutdown with an open
  connection. It now synchronizes on response start and requests
  shutdown while the GET is held open — verified to fail (timeout, then
  CancelledError tracebacks) against the pre-fix server.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot AI 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.

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated no new comments.

@my-reviewer-agent my-reviewer-agent 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.

issue #136의 근본 원인(stateless+json_response 모드에서 SDK가 GET을 무한 standalone SSE 스트림으로 서빙 → uvicorn graceful shutdown이 영원히 대기 → hard cancel → ASGI 예외 트레이스백)을 정확히 짚고, 스펙이 허용하는 405 거부를 세션 매니저 앞단 ASGI 래퍼에서 처리한 깔끔한 수정입니다.

특히 좋은 점:

  • 보안 순서 보존: 405 응답 전에 TransportSecurityMiddleware.validate_request로 동일한 DNS-rebinding Host/Origin 검증을 먼저 수행 — 악성 origin은 405로 응답받는 대신 403으로 거부되며, test_hostile_origin_get_is_rejected_not_answered_405가 이 순서를 고정합니다.
  • 회귀 테스트의 실질성: test_shutdown_completes_while_a_client_holds_a_get_connection은 GET 연결을 실제로 열어둔 채(response_started 이벤트로 헤더 수신 확인) shutdown을 요청하고 hard-cancel 폴백 데드라인(5s) 안에 graceful 완료를 단언하므로, 수정 전 코드에서는 타임아웃으로 실패하는 진짜 RED 테스트입니다.
  • scope["type"] == "http" 가드로 lifespan/websocket scope는 건드리지 않고, Allow: POST 헤더도 스펙대로 포함.

사소한 참고(비차단): 405 경로가 GET뿐 아니라 DELETE(stateful 세션 종료용 메서드)도 거부하지만, 이 서버는 stateless라 세션 종료 개념 자체가 없으므로 올바른 동작입니다.

APPROVE

@hellices
hellices merged commit ac5bbf8 into main Aug 1, 2026
15 of 17 checks passed
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.

MCP server: shutdown with an open GET SSE stream crashes with "Exception in ASGI application" (CancelledError)

2 participants