Skip to content

Commit bf9ff91

Browse files
Return HTTP 405 for GET and DELETE on @mcp/protocol
Streamable HTTP clients treat 404 as a terminated session. The MCP spec requires 405 when the server does not offer a GET SSE stream. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 83a4a46 commit bf9ff91

4 files changed

Lines changed: 78 additions & 1 deletion

File tree

CHANGELOG.rst

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ CHANGELOG
1212
[rboixaderg]
1313
- Chore: restrict MCP version for Python 3.10 and above to ensure compatibility
1414
[rboixaderg]
15+
- MCP: return HTTP 405 on GET and DELETE ``@mcp/protocol`` so Streamable HTTP
16+
clients treat the endpoint as JSON-only instead of a missing session.
17+
[rboixaderg]
1518

1619

1720
7.1.3 (2026-06-24)

docs/source/contrib/mcp.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,8 @@ Accept: application/json, text/event-stream
6464

6565
Without the `Accept` header, the transport may respond with **406 Not Acceptable**.
6666

67+
The endpoint is JSON-only and does not open a GET SSE stream. Per the MCP Streamable HTTP spec, `GET` and `DELETE` on `@mcp/protocol` return **405 Method Not Allowed** with `Allow: POST`, so clients continue with POST/JSON instead of treating a **404** as a dead session.
68+
6769
Example: list tools via JSON-RPC:
6870

6971
```bash

guillotina/contrib/mcp/services.py

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,26 @@
44
from guillotina.contrib.mcp.interfaces import IMCPToolRegistry
55
from guillotina.contrib.mcp.security import require_access_content
66
from guillotina.interfaces import IResource
7-
from guillotina.response import HTTPNotFound, HTTPServiceUnavailable, Response
7+
from guillotina.response import HTTPMethodNotAllowed, HTTPNotFound, HTTPServiceUnavailable, Response
8+
9+
10+
_MCP_PROTOCOL_POST_METHODS = ["POST"]
11+
12+
13+
def _reject_non_post_protocol(context, request, method: str):
14+
action = request.matchdict.get("action", "")
15+
if action != "protocol":
16+
raise HTTPNotFound(content={"reason": f"Unknown MCP {method} action: {action}"})
17+
require_access_content(context)
18+
if method == "DELETE":
19+
reason = "MCP endpoint does not support session termination"
20+
else:
21+
reason = "MCP endpoint does not offer an SSE stream"
22+
raise HTTPMethodNotAllowed(
23+
method,
24+
_MCP_PROTOCOL_POST_METHODS,
25+
content={"reason": reason},
26+
)
827

928

1029
def _get_registry():
@@ -72,3 +91,29 @@ async def _handle_protocol(self):
7291
resp._prepared = True
7392
resp._eof_sent = True
7493
return resp
94+
95+
96+
@configure.service(
97+
method="GET",
98+
context=IResource,
99+
name="@mcp/{action}",
100+
permission="guillotina.MCPExecute",
101+
summary="MCP Streamable HTTP GET is not offered (JSON-only)",
102+
allow_access=True,
103+
)
104+
class MCPActionGetService(Service):
105+
async def __call__(self):
106+
_reject_non_post_protocol(self.context, self.request, "GET")
107+
108+
109+
@configure.service(
110+
method="DELETE",
111+
context=IResource,
112+
name="@mcp/{action}",
113+
permission="guillotina.MCPExecute",
114+
summary="MCP Streamable HTTP session termination is not offered",
115+
allow_access=True,
116+
)
117+
class MCPActionDeleteService(Service):
118+
async def __call__(self):
119+
_reject_non_post_protocol(self.context, self.request, "DELETE")

guillotina/tests/mcp/test_mcp.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -899,6 +899,33 @@ async def test_protocol_unknown_action_returns_404(container_requester):
899899
assert status == 404
900900

901901

902+
def _assert_protocol_method_not_allowed(status, headers):
903+
assert status == 405
904+
allow = headers.get("Allow") or headers.get("allow") or ""
905+
assert "POST" in allow.upper()
906+
907+
908+
@pytest.mark.app_settings(MCP_SETTINGS)
909+
async def test_protocol_get_returns_405_without_sse(container_requester):
910+
async with container_requester as requester:
911+
_, status, headers = await requester.make_request(
912+
"GET",
913+
"/db/guillotina/@mcp/protocol",
914+
accept="text/event-stream",
915+
)
916+
_assert_protocol_method_not_allowed(status, headers)
917+
918+
919+
@pytest.mark.app_settings(MCP_SETTINGS)
920+
async def test_protocol_delete_returns_405(container_requester):
921+
async with container_requester as requester:
922+
_, status, headers = await requester.make_request(
923+
"DELETE",
924+
"/db/guillotina/@mcp/protocol",
925+
)
926+
_assert_protocol_method_not_allowed(status, headers)
927+
928+
902929
@pytest.mark.app_settings(MCP_SETTINGS)
903930
async def test_protocol_resource_registry_matches_defaults(container_requester):
904931
async with container_requester as requester:

0 commit comments

Comments
 (0)