Skip to content

Commit 066df09

Browse files
committed
Let client.py self-host its server for HTTP runs
`python -m stories.<story>.client --http` with no URL now starts the sibling server on a port it owns, waits for it to listen, runs the scenario, and tears it down. The two-variant run recipe becomes two commands with nothing to background, kill, or collide; `--http <url>` still targets a server you run yourself. The smoke test reuses the same path instead of carrying its own spawn/poll copy, so it now exercises exactly the command the READMEs print. Auth stories keep their pinned :8000 via a manifest `fixed_port`.
1 parent fabf732 commit 066df09

30 files changed

Lines changed: 284 additions & 210 deletions

File tree

examples/stories/README.md

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -64,11 +64,30 @@ From the repository root:
6464
# stdio (default — the client spawns the server as a subprocess)
6565
uv run python -m stories.tools.client
6666

67-
# against a running HTTP server
68-
uv run python -m stories.tools.server --http --port 8000 &
67+
# HTTP, self-hosted — the client spawns the server on a real uvicorn socket on a
68+
# port it owns, waits for it, runs, then terminates it. Nothing to background or kill.
69+
uv run python -m stories.tools.client --http
70+
71+
# the same self-hosted run against the story's lowlevel-API server variant
72+
uv run python -m stories.tools.client --http --server server_lowlevel
73+
74+
# HTTP against a server you run yourself
75+
uv run python -m stories.tools.server --http --port 8000 # separate terminal
6976
uv run python -m stories.tools.client --http http://127.0.0.1:8000/mcp
7077
```
7178

79+
`--http` takes two forms. Bare `--http` is the canonical HTTP run — it is
80+
complete on its own, and it is what every per-story README shows. `--http
81+
<url>` connects to a server you started yourself; the per-story READMEs spell
82+
that out only where hosting is the lesson (the HTTP-hosting and auth stories).
83+
`--server <stem>` swaps in a sibling server module on stdio and on the
84+
self-hosted `--http` run; with `--http <url>` you already picked the server
85+
when you started it. The auth stories (`bearer_auth/`, `oauth/`,
86+
`oauth_client_credentials/`) self-host on their fixed `:8000` instead of a
87+
free port because their issuer/PRM metadata bake it in — `:8000` must be
88+
free, and the run refuses to start (rather than silently testing whatever is
89+
there) if it is not.
90+
7291
The full matrix (every story × transport × era × server-variant) runs under
7392
pytest:
7493

@@ -84,8 +103,9 @@ and variants; `tests/examples/` expands it.
84103

85104
`_hosting.py` adapts a story's `build_server()` / `build_app()` to argv (stdio
86105
vs `--http` serving); `_harness.py` is the client-side mirror — it picks the
87-
`target` that `main()` connects to (a stdio subprocess by default, a URL under
88-
`--http`). They isolate the parts of the SDK's hosting surface
106+
`target` that `main()` connects to (a stdio subprocess by default, a self-hosted
107+
HTTP subprocess under bare `--http`, your URL under `--http <url>`). They
108+
isolate the parts of the SDK's hosting surface
89109
that are still moving — **don't copy them into your own project**; copy the
90110
`server.py` / `client.py` bodies instead. `_shared/` holds an in-process OAuth
91111
authorization server reused by the auth stories.

examples/stories/_harness.py

Lines changed: 86 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,11 @@
88

99
from __future__ import annotations
1010

11+
import socket
1112
import sys
1213
import traceback
13-
from collections.abc import Awaitable, Callable
14+
from collections.abc import AsyncIterator, Awaitable, Callable
15+
from contextlib import AsyncExitStack, asynccontextmanager
1416
from pathlib import Path
1517
from typing import Any, TypeAlias
1618
from urllib.parse import urlsplit
@@ -50,22 +52,75 @@ def argv_after(flag: str, *, default: str | None = None) -> str:
5052
return default
5153

5254

53-
def target_from_args(file: str) -> TargetFactory:
54-
"""Build a ``TargetFactory`` for the sibling server over the argv-selected transport.
55+
def target_from_args(file: str, url: str | None) -> TargetFactory:
56+
"""Build a ``TargetFactory`` for the sibling server of the ``client.py`` at ``file``.
5557
56-
``--http <url>`` targets that streamable-HTTP URL; ``--stdio`` (the default) spawns
57-
the sibling ``server.py`` as a fresh subprocess on each call. ``--server <stem>``
58-
selects ``<stem>.py`` (e.g. ``server_lowlevel``). ``file`` is the caller's ``__file__``.
58+
``url`` (already resolved by ``run_client``) targets that streamable-HTTP endpoint; ``None``
59+
spawns ``<stem>.py`` over stdio per call, ``<stem>`` from ``--server`` (default ``server``).
5960
"""
60-
if "--http" in sys.argv:
61-
url = argv_after("--http")
61+
if url is not None:
6262
return lambda: url
6363
# stdio is legacy-only until serve_stdio() lands; the modern arm is --http only for now.
6464
server = Path(file).parent / f"{argv_after('--server', default='server')}.py"
6565
params = StdioServerParameters(command=sys.executable, args=[str(server)])
6666
return lambda: stdio_client(params) # becomes Client(params) once that overload lands
6767

6868

69+
def _explicit_http_url() -> str | None:
70+
"""The URL token after ``--http``, or ``None`` when the flag stands alone (self-host)."""
71+
rest = sys.argv[sys.argv.index("--http") + 1 :]
72+
return rest[0] if rest and not rest[0].startswith("-") else None
73+
74+
75+
def _free_port() -> int:
76+
"""An OS-assigned free TCP port, released for the server subprocess to re-bind."""
77+
with socket.socket() as sock:
78+
sock.bind(("127.0.0.1", 0))
79+
return sock.getsockname()[1]
80+
81+
82+
async def _accepting(port: int) -> bool:
83+
"""Whether something accepts a TCP connect on ``127.0.0.1:port`` right now."""
84+
try:
85+
stream = await anyio.connect_tcp("127.0.0.1", port)
86+
except OSError:
87+
return False
88+
await stream.aclose()
89+
return True
90+
91+
92+
@asynccontextmanager
93+
async def _self_hosted(name: str, cfg: dict[str, Any]) -> AsyncIterator[str]:
94+
"""Serve the story's sibling server from a subprocess on a port this process owns; yield its URL.
95+
96+
Readiness is the first accepted TCP connect (bounded by ``run_client``'s
97+
``anyio.fail_after``); exiting terminates the subprocess. Nothing to background or kill.
98+
A subprocess that dies before serving, or a ``fixed_port`` someone else already holds,
99+
is a loud ``SystemExit`` rather than a hang or a run against the wrong server.
100+
"""
101+
port: int = cfg["fixed_port"] or _free_port()
102+
if cfg["fixed_port"] and await _accepting(port):
103+
# The readiness probe below can't tell our child from a server already on the
104+
# story's pinned port, so a foreign listener would be tested in its place.
105+
raise SystemExit(
106+
f"{name} self-hosts on :{port} but something is already serving there; "
107+
f"stop it, or connect to it with --http <url>"
108+
)
109+
module = f"stories.{name}.{argv_after('--server', default='server')}"
110+
serve = ["--http"] if cfg["server_export"] == "factory" else []
111+
argv = [sys.executable, "-m", module, *serve, "--port", str(port)]
112+
async with await anyio.open_process(argv, stdout=None, stderr=None) as server:
113+
try:
114+
while server.returncode is None and not await _accepting(port):
115+
await anyio.sleep(0.05)
116+
if server.returncode is not None:
117+
raise SystemExit(f"{module} exited {server.returncode} before serving on :{port}")
118+
yield f"http://127.0.0.1:{port}{cfg['mcp_path']}"
119+
finally:
120+
if server.returncode is None:
121+
server.terminate()
122+
123+
69124
def _story_cfg(name: str) -> dict[str, Any]:
70125
"""The manifest entry for the story ``name`` with ``[defaults]`` applied."""
71126
manifest: dict[str, Any] = tomllib.loads((Path(__file__).parent / "manifest.toml").read_text())
@@ -80,23 +135,24 @@ def _authed_targets(url: str, http: httpx.AsyncClient) -> TargetFactory:
80135
def run_client(main: Callable[..., Awaitable[None]]) -> None:
81136
"""Entry point for ``if __name__ == "__main__"`` in every ``client.py``.
82137
83-
Builds the argv-selected target(s) for the story that defines ``main``, picks the
84-
era from argv, and calls ``main`` with an explicit ``mode=``. If the story module
85-
exports ``build_auth``, the ``--http`` target is routed through an ``httpx.AsyncClient``
86-
that carries the returned ``httpx.Auth``. Prints ``OK:``/``FAIL:`` to stderr, exits 0/1.
138+
Resolves the argv target — stdio (the default), ``--http <url>`` for a server you run, or
139+
bare ``--http`` to self-host the sibling server in a subprocess it owns — and calls ``main``
140+
with an explicit ``mode=``. A ``build_auth`` export auths the HTTP target. ``OK``/``FAIL``, exit 0/1.
87141
"""
88142
globals_ = getattr(main, "__globals__", {})
89143
file = str(globals_.get("__file__", "<unknown>"))
90144
name = Path(file).parent.name
91145
cfg = _story_cfg(name)
92-
targets = target_from_args(file)
93146
build_auth: AuthBuilder | None = globals_.get("build_auth")
94147
transport = "http" if "--http" in sys.argv else "stdio"
95148
if cfg["server_export"] == "app" and transport != "http":
96149
raise SystemExit(
97-
f"{name} exports an ASGI app (no stdio entry point); start its server, then run:\n"
98-
f" python -m stories.{name}.client --http http://127.0.0.1:8000{cfg['mcp_path']}"
150+
f"{name} exports an ASGI app (no stdio entry point); self-host it over HTTP:\n"
151+
f" python -m stories.{name}.client --http"
99152
)
153+
if cfg["needs_http"] and transport != "http":
154+
raise SystemExit(f"{name} asserts on raw HTTP responses; run it with --http")
155+
explicit_url = _explicit_http_url() if transport == "http" else None
100156
# The era is an axis of the story matrix, so ``mode=`` is always passed explicitly
101157
# even though it often matches the ``Client`` default of "auto". stdio is legacy-only
102158
# until the SDK's stdio entry can negotiate the era, so only --http gets a modern arm.
@@ -110,18 +166,21 @@ def run_client(main: Callable[..., Awaitable[None]]) -> None:
110166

111167
async def _run() -> None:
112168
with anyio.fail_after(cfg["timeout_s"]):
113-
if not cfg["needs_http"] and (build_auth is None or transport != "http"):
114-
await main(targets if cfg["multi_connection"] else targets(), mode=mode)
115-
return
116-
# Auth and needs_http stories want the raw httpx client underneath the transport:
117-
# build_auth threads an httpx.Auth onto it (Client(url, auth=...) doesn't exist
118-
# yet), and needs_http stories assert on raw responses, so root the client at the
119-
# server origin and relative paths like "/mcp" resolve.
120-
if transport != "http":
121-
raise SystemExit(f"{name} asserts on raw HTTP responses; run it with --http <url>")
122-
url = argv_after("--http")
123-
parts = urlsplit(url)
124-
async with httpx.AsyncClient(base_url=f"{parts.scheme}://{parts.netloc}") as http:
169+
async with AsyncExitStack() as stack:
170+
url = explicit_url
171+
if transport == "http" and url is None:
172+
url = await stack.enter_async_context(_self_hosted(name, cfg))
173+
targets = target_from_args(file, url)
174+
if url is None or (build_auth is None and not cfg["needs_http"]):
175+
await main(targets if cfg["multi_connection"] else targets(), mode=mode)
176+
return
177+
# Auth and needs_http stories want the raw httpx client underneath the transport:
178+
# build_auth threads an httpx.Auth onto it (Client(url, auth=...) doesn't exist
179+
# yet), and needs_http stories assert on raw responses, so root the client at the
180+
# server origin and relative paths like "/mcp" resolve.
181+
parts = urlsplit(url)
182+
base = f"{parts.scheme}://{parts.netloc}"
183+
http = await stack.enter_async_context(httpx.AsyncClient(base_url=base))
125184
make = targets
126185
if build_auth is not None:
127186
http.auth = build_auth(http)

examples/stories/bearer_auth/README.md

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,16 +13,18 @@ authorization server; see `../oauth/` for the full grant flow.
1313
## Run it
1414

1515
```bash
16-
# start the bearer-gated server (real uvicorn on :8000)
16+
# HTTP — the client self-hosts the bearer-gated app, connects with the demo
17+
# bearer token, then tears it down. Self-hosting uses this story's fixed :8000
18+
# (the issuer/PRM metadata pin it), so :8000 must be free.
19+
uv run python -m stories.bearer_auth.client --http
20+
# same, against the lowlevel-API server variant
21+
uv run python -m stories.bearer_auth.client --http --server server_lowlevel
22+
23+
# against a server you run yourself (real uvicorn on :8000). The next section's
24+
# curl probes use it too and `kill` it when done. While it is up it owns :8000,
25+
# so the two self-host lines above refuse to run rather than test it by mistake.
1726
uv run python -m stories.bearer_auth.server --port 8000 &
1827
SERVER_PID=$!
19-
20-
# connect with the demo bearer token
21-
uv run python -m stories.bearer_auth.client --http http://127.0.0.1:8000/mcp
22-
23-
# lowlevel server variant — same port, so stop the first server
24-
kill "$SERVER_PID"
25-
uv run python -m stories.bearer_auth.server_lowlevel --port 8000 &
2628
uv run python -m stories.bearer_auth.client --http http://127.0.0.1:8000/mcp
2729
```
2830

@@ -42,6 +44,9 @@ curl -i -X POST http://127.0.0.1:8000/mcp \
4244

4345
# the RFC 9728 protected-resource-metadata document
4446
curl -s http://127.0.0.1:8000/.well-known/oauth-protected-resource/mcp | jq
47+
48+
# done with the server you started in "Run it"
49+
kill "$SERVER_PID"
4550
```
4651

4752
## What to look at

examples/stories/custom_methods/README.md

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,8 @@ it.
1212
# stdio (default — the client spawns the server as a subprocess)
1313
uv run python -m stories.custom_methods.client
1414

15-
# against a running HTTP server
16-
uv run python -m stories.custom_methods.server --http --port 8000 &
17-
uv run python -m stories.custom_methods.client --http http://127.0.0.1:8000/mcp
15+
# HTTP — the client self-hosts the server on a free port, runs, then tears it down
16+
uv run python -m stories.custom_methods.client --http
1817
```
1918

2019
## What to look at

examples/stories/dual_era/README.md

Lines changed: 5 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,15 +10,11 @@ stays era-agnostic.
1010
## Run it
1111

1212
```bash
13-
# over HTTP — the same /mcp endpoint serves both eras
14-
uv run python -m stories.dual_era.server --http --port 8000 &
15-
SERVER_PID=$!
16-
uv run python -m stories.dual_era.client --http http://127.0.0.1:8000/mcp
17-
18-
# lowlevel server variant — same port, so stop the first server
19-
kill "$SERVER_PID"
20-
uv run python -m stories.dual_era.server_lowlevel --http --port 8000 &
21-
uv run python -m stories.dual_era.client --http http://127.0.0.1:8000/mcp
13+
# over HTTP — the same /mcp endpoint serves both eras; the client self-hosts
14+
# the server on a free port, runs, then tears it down
15+
uv run python -m stories.dual_era.client --http
16+
# same, against the lowlevel-API server variant
17+
uv run python -m stories.dual_era.client --http --server server_lowlevel
2218
```
2319

2420
The bare stdio invocation (`uv run python -m stories.dual_era.client`) is

examples/stories/error_handling/README.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,10 @@ client tells them apart.
1414
# stdio (default — the client spawns the server as a subprocess)
1515
uv run python -m stories.error_handling.client
1616

17-
# against a running HTTP server
18-
uv run python -m stories.error_handling.server --http --port 8000 &
19-
uv run python -m stories.error_handling.client --http http://127.0.0.1:8000/mcp
17+
# HTTP — the client self-hosts the server on a free port, runs, then tears it down
18+
uv run python -m stories.error_handling.client --http
19+
# same, against the lowlevel-API server variant
20+
uv run python -m stories.error_handling.client --http --server server_lowlevel
2021
```
2122

2223
## What to look at

examples/stories/json_response/README.md

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,15 @@ same endpoint behave the same way.
99
## Run it
1010

1111
```bash
12-
# start the server (real uvicorn)
13-
uv run python -m stories.json_response.server --port 8000 &
12+
# HTTP — the client self-hosts the app on a free port, runs the high-level
13+
# Client + raw-envelope probe, then tears it down
14+
uv run python -m stories.json_response.client --http
15+
# same, against the lowlevel-API server variant
16+
uv run python -m stories.json_response.client --http --server server_lowlevel
1417

15-
# high-level Client + raw-envelope probe against it
18+
# against a server you run yourself (real uvicorn on :8000)
19+
uv run python -m stories.json_response.server --port 8000 &
20+
SERVER_PID=$!
1621
uv run python -m stories.json_response.client --http http://127.0.0.1:8000/mcp
1722

1823
# or POST the raw envelope yourself
@@ -22,6 +27,7 @@ curl -s http://127.0.0.1:8000/mcp \
2227
-H 'mcp-protocol-version: 2026-07-28' \
2328
-H 'mcp-method: tools/list' \
2429
-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":{}}}}'
30+
kill "$SERVER_PID"
2531
```
2632

2733
## What to look at

examples/stories/legacy_elicitation/README.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,11 @@ flow finishes).
2424
# stdio (default — the client spawns the server as a subprocess)
2525
uv run python -m stories.legacy_elicitation.client
2626

27-
# against a running HTTP server (--legacy: the push request needs the handshake era)
28-
uv run python -m stories.legacy_elicitation.server --http --port 8000 &
29-
uv run python -m stories.legacy_elicitation.client --http http://127.0.0.1:8000/mcp --legacy
27+
# HTTP — the client self-hosts the server on a free port, runs, then tears it
28+
# down (--legacy: the push request needs the handshake era)
29+
uv run python -m stories.legacy_elicitation.client --http --legacy
30+
# same, against the lowlevel-API server variant
31+
uv run python -m stories.legacy_elicitation.client --http --legacy --server server_lowlevel
3032
```
3133

3234
## What to look at

examples/stories/legacy_routing/README.md

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,15 +15,17 @@ browser-based MCP clients need.
1515
## Run it
1616

1717
```bash
18-
# HTTP only — the predicate is an HTTP-transport concern
18+
# HTTP only — the predicate is an HTTP-transport concern. The client
19+
# self-hosts the app on a free port, runs, then tears it down.
20+
uv run python -m stories.legacy_routing.client --http
21+
# same, against the lowlevel-API server variant
22+
uv run python -m stories.legacy_routing.client --http --server server_lowlevel
23+
24+
# against a server you run yourself (real uvicorn on :8000)
1925
uv run python -m stories.legacy_routing.server --port 8000 &
2026
SERVER_PID=$!
2127
uv run python -m stories.legacy_routing.client --http http://127.0.0.1:8000/mcp
22-
23-
# lowlevel server variant — same port, so stop the first server
2428
kill "$SERVER_PID"
25-
uv run python -m stories.legacy_routing.server_lowlevel --port 8000 &
26-
uv run python -m stories.legacy_routing.client --http http://127.0.0.1:8000/mcp
2729
```
2830

2931
## What to look at

examples/stories/lifespan/README.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,10 @@ the injected `Context` — no module-level globals.
1111
# stdio (default — the client spawns the server as a subprocess)
1212
uv run python -m stories.lifespan.client
1313

14-
# against a running HTTP server
15-
uv run python -m stories.lifespan.server --http --port 8000 &
16-
uv run python -m stories.lifespan.client --http http://127.0.0.1:8000/mcp
14+
# HTTP — the client self-hosts the server on a free port, runs, then tears it down
15+
uv run python -m stories.lifespan.client --http
16+
# same, against the lowlevel-API server variant
17+
uv run python -m stories.lifespan.client --http --server server_lowlevel
1718
```
1819

1920
## What to look at

0 commit comments

Comments
 (0)