chore: import upstream snapshot with attribution
Deploy Docs / deploy-docs (push) Failing after 1s
Conformance Tests / client-conformance (push) Failing after 3s
Conformance Tests / server-conformance (push) Failing after 1s
GitHub Actions Security Analysis / zizmor (push) Failing after 1s
CI / checks (push) Failing after 59m20s
CI / all-green (push) Waiting to run
Deploy Docs / deploy-docs (push) Failing after 1s
Conformance Tests / client-conformance (push) Failing after 3s
Conformance Tests / server-conformance (push) Failing after 1s
GitHub Actions Security Analysis / zizmor (push) Failing after 1s
CI / checks (push) Failing after 59m20s
CI / all-green (push) Waiting to run
This commit is contained in:
@@ -0,0 +1,70 @@
|
||||
# json-response
|
||||
|
||||
`streamable_http_app(json_response=True)` — one `application/json` body per
|
||||
request instead of an SSE stream. Useful for serverless / edge runtimes that
|
||||
can't hold a stream open. The 2026-07-28 path is stateless and JSON-only today
|
||||
regardless of the flag; setting it makes the legacy (2025-era) branch on the
|
||||
same endpoint behave the same way.
|
||||
|
||||
## Run it
|
||||
|
||||
```bash
|
||||
# HTTP — the client self-hosts the app on a free port, runs the high-level
|
||||
# Client + raw-envelope probe, then tears it down
|
||||
uv run python -m stories.json_response.client --http
|
||||
# same, against the lowlevel-API server variant
|
||||
uv run python -m stories.json_response.client --http --server server_lowlevel
|
||||
|
||||
# against a server you run yourself (real uvicorn on :8000)
|
||||
uv run python -m stories.json_response.server --port 8000 &
|
||||
SERVER_PID=$!
|
||||
uv run python -m stories.json_response.client --http http://127.0.0.1:8000/mcp
|
||||
|
||||
# or POST the raw envelope yourself
|
||||
curl -s http://127.0.0.1:8000/mcp \
|
||||
-H 'content-type: application/json' \
|
||||
-H 'accept: application/json, text/event-stream' \
|
||||
-H 'mcp-protocol-version: 2026-07-28' \
|
||||
-H 'mcp-method: tools/list' \
|
||||
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientInfo":{"name":"curl","version":"0"},"io.modelcontextprotocol/clientCapabilities":{}}}}'
|
||||
kill "$SERVER_PID"
|
||||
```
|
||||
|
||||
## What to look at
|
||||
|
||||
- `client.py` `main` — `async with Client(target, mode=mode) as client:` is an
|
||||
ordinary high-level client; nothing about JSON mode is visible from this side.
|
||||
The same `main` also takes the raw `httpx.AsyncClient` so it can prove what
|
||||
the wire looks like underneath.
|
||||
- `client.py` `RAW_ENVELOPE_BODY` / `MODERN_HEADERS` — the exact 2026 wire
|
||||
shape: three `io.modelcontextprotocol/*` `_meta` keys replace the initialize
|
||||
handshake; `MCP-Protocol-Version` + `Mcp-Method` headers mirror the body so
|
||||
gateways can route without parsing JSON. `main` posts it by hand and asserts
|
||||
a single `application/json` response with no `Mcp-Session-Id`.
|
||||
- `server.py` `greet` calls `ctx.report_progress(0.5)` — and `main` proves the
|
||||
client's `progress_callback` is **never invoked**: JSON mode has no
|
||||
back-channel for mid-call notifications (the `progress_seen == []` assertion
|
||||
flips to `== [0.5]` once SSE buffering lands for the modern path).
|
||||
- `server_lowlevel.py` — same ASGI app built from `lowlevel.Server`; the
|
||||
`json_response=` / `transport_security=` knobs live on `streamable_http_app`,
|
||||
not the server class.
|
||||
|
||||
## Caveats
|
||||
|
||||
- DNS-rebinding protection is on by default; the harness disables it via
|
||||
`NO_DNS_REBIND` because the in-process httpx client sends no `Origin` header.
|
||||
- The `streamable_http_app()` call shape here will move when the free-function
|
||||
entry lands (see `_hosting.py`).
|
||||
- `Mcp-Name` is omitted for `tools/list` because the SDK only emits it on
|
||||
`tools/call` today.
|
||||
|
||||
## Spec
|
||||
|
||||
[Streamable HTTP — 2026-07-28](https://modelcontextprotocol.io/specification/draft/basic/transports/streamable-http)
|
||||
· [SEP-2243 standard headers](https://modelcontextprotocol.io/specification/draft/basic/transports/streamable-http#standard-request-headers)
|
||||
|
||||
## See also
|
||||
|
||||
`stateless_legacy/` (the one-liner `stateless_http=True` deploy),
|
||||
`legacy_routing/` (route by era at the entry), `streaming/` (progress that *is*
|
||||
delivered — over stdio/SSE).
|
||||
@@ -0,0 +1,69 @@
|
||||
"""Plain ``Client`` against a JSON-only server: mid-call progress drops. HTTP-only — ``main`` also takes ``http``.
|
||||
|
||||
``RAW_ENVELOPE_BODY`` / ``MODERN_HEADERS`` are the exact wire shape a 2026-era client
|
||||
sends — this is the only story that shows it. ``main`` posts that body by hand and
|
||||
asserts the response is a single ``application/json`` body with no session id.
|
||||
"""
|
||||
|
||||
import httpx
|
||||
from mcp_types import TextContent
|
||||
from mcp_types.version import LATEST_MODERN_VERSION
|
||||
|
||||
from mcp.client import Client
|
||||
from stories._harness import Target, run_client
|
||||
|
||||
# The raw 2026-07-28 POST envelope: per-request `_meta` replaces the initialize handshake.
|
||||
# The key/header strings are spelled out on purpose — this is the raw-wire story. In code
|
||||
# use the named constants instead: `mcp_types.PROTOCOL_VERSION_META_KEY` /
|
||||
# `CLIENT_INFO_META_KEY` / `CLIENT_CAPABILITIES_META_KEY` and
|
||||
# `mcp.shared.inbound.MCP_PROTOCOL_VERSION_HEADER` (`legacy_routing/` shows that form).
|
||||
RAW_ENVELOPE_BODY: dict[str, object] = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "tools/list",
|
||||
"params": {
|
||||
"_meta": {
|
||||
"io.modelcontextprotocol/protocolVersion": LATEST_MODERN_VERSION,
|
||||
"io.modelcontextprotocol/clientInfo": {"name": "raw-probe", "version": "0.0.0"},
|
||||
"io.modelcontextprotocol/clientCapabilities": {},
|
||||
}
|
||||
},
|
||||
}
|
||||
MODERN_HEADERS: dict[str, str] = {
|
||||
"accept": "application/json, text/event-stream",
|
||||
"content-type": "application/json",
|
||||
"mcp-protocol-version": LATEST_MODERN_VERSION,
|
||||
"mcp-method": "tools/list",
|
||||
}
|
||||
|
||||
|
||||
async def main(target: Target, *, mode: str = "auto", http: httpx.AsyncClient) -> None:
|
||||
async with Client(target, mode=mode) as client:
|
||||
assert client.protocol_version == LATEST_MODERN_VERSION
|
||||
|
||||
progress_seen: list[float] = []
|
||||
|
||||
async def on_progress(progress: float, total: float | None, message: str | None) -> None:
|
||||
progress_seen.append(progress)
|
||||
|
||||
result = await client.call_tool("greet", {"name": "json"}, progress_callback=on_progress)
|
||||
assert isinstance(result.content[0], TextContent)
|
||||
assert result.content[0].text == "Hello, json!"
|
||||
assert result.structured_content == {"result": "Hello, json!"}, result
|
||||
|
||||
# The tool called report_progress(0.5) but the modern HTTP JSON path has no
|
||||
# back-channel for mid-call notifications, so the callback is never invoked.
|
||||
assert progress_seen == [], f"expected progress to be dropped, got {progress_seen}"
|
||||
|
||||
# Hand-craft a 2026 POST and assert it comes back as a single JSON body, no session.
|
||||
response = await http.post("/mcp", json=RAW_ENVELOPE_BODY, headers=MODERN_HEADERS)
|
||||
assert response.status_code == 200, response.text
|
||||
assert response.headers["content-type"].split(";", 1)[0] == "application/json"
|
||||
assert "mcp-session-id" not in response.headers
|
||||
payload = response.json()
|
||||
assert payload["id"] == 1
|
||||
assert [t["name"] for t in payload["result"]["tools"]] == ["greet"]
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_client(main)
|
||||
@@ -0,0 +1,27 @@
|
||||
"""Serve over Streamable HTTP with JSON responses (no SSE stream); HTTP-only, so this exports ``build_app()``.
|
||||
|
||||
The 2026-07-28 path is stateless and JSON-only by construction today; the
|
||||
``json_response=True`` flag also forces JSON for the legacy (2025-era) branch on
|
||||
the same endpoint. Mid-call notifications are dropped.
|
||||
"""
|
||||
|
||||
from starlette.applications import Starlette
|
||||
|
||||
from mcp.server.mcpserver import Context, MCPServer
|
||||
from stories._hosting import NO_DNS_REBIND, run_app_from_args
|
||||
|
||||
|
||||
def build_app() -> Starlette:
|
||||
mcp = MCPServer("json-response-example")
|
||||
|
||||
@mcp.tool()
|
||||
async def greet(name: str, ctx: Context) -> str:
|
||||
"""Report progress mid-call, then return a greeting."""
|
||||
await ctx.report_progress(0.5, total=1.0, message="halfway")
|
||||
return f"Hello, {name}!"
|
||||
|
||||
return mcp.streamable_http_app(json_response=True, transport_security=NO_DNS_REBIND)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_app_from_args(build_app)
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Serve over Streamable HTTP with JSON responses (lowlevel API)."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
import mcp_types as types
|
||||
from starlette.applications import Starlette
|
||||
|
||||
from mcp.server.context import ServerRequestContext
|
||||
from mcp.server.lowlevel import Server
|
||||
from stories._hosting import NO_DNS_REBIND, run_app_from_args
|
||||
|
||||
GREET_INPUT_SCHEMA: dict[str, Any] = {
|
||||
"type": "object",
|
||||
"properties": {"name": {"type": "string"}},
|
||||
"required": ["name"],
|
||||
}
|
||||
|
||||
|
||||
def build_app() -> Starlette:
|
||||
async def list_tools(
|
||||
ctx: ServerRequestContext[Any], params: types.PaginatedRequestParams | None
|
||||
) -> types.ListToolsResult:
|
||||
return types.ListToolsResult(
|
||||
tools=[
|
||||
types.Tool(
|
||||
name="greet",
|
||||
description="Report progress mid-call, then return a greeting.",
|
||||
input_schema=GREET_INPUT_SCHEMA,
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
async def call_tool(ctx: ServerRequestContext[Any], params: types.CallToolRequestParams) -> types.CallToolResult:
|
||||
assert params.name == "greet" and params.arguments is not None
|
||||
await ctx.session.report_progress(0.5, total=1.0, message="halfway")
|
||||
text = f"Hello, {params.arguments['name']}!"
|
||||
return types.CallToolResult(content=[types.TextContent(text=text)], structured_content={"result": text})
|
||||
|
||||
server = Server("json-response-example", on_list_tools=list_tools, on_call_tool=call_tool)
|
||||
return server.streamable_http_app(json_response=True, transport_security=NO_DNS_REBIND)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_app_from_args(build_app)
|
||||
Reference in New Issue
Block a user