Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ All notable changes to Toolgraph are documented here.
streamable-http and SSE hand them to the SDK by different mechanisms and
neither had ever been run with a non-empty value. The fixture server can now
reject tokenless requests, so a dropped header fails a test instead of
quietly crawling a smaller tool set.
quietly crawling a smaller tool set. Both transports are covered at the
lockfile version and at the declared SDK floor.

## 0.1.0 - 2026-09-10

Expand Down
58 changes: 41 additions & 17 deletions scripts/verify_mcp_floor.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,18 @@ def _rejections(port: int) -> int:
return int(response.read())


def _rejections_or_none(port: int) -> int | None:
"""`_rejections`, but None while the server is not answering yet.

Called from inside the startup retry loop, where "connection refused" is
the normal case and must not be mistaken for a verdict.
"""
try:
return _rejections(port)
except OSError:
return None


def _free_port() -> int:
with socket.socket() as s:
s.bind(("127.0.0.1", 0))
Expand Down Expand Up @@ -174,15 +186,15 @@ async def http_transport(mode: str, transport: str, path: str) -> None:
proc.wait(timeout=10)


async def header_forwarding() -> None:
async def header_forwarding(mode: str, transport: str, path: str) -> None:
"""Auth headers must actually reach the server at the declared floor.

Only streamable-http is driven here. SSE hands `headers=` straight to
`sse_client`, but streamable-http cannot take that kwarg, so we build a
client with `create_mcp_http_client(headers=...)` and pass `http_client=` --
a 2.x-specific spelling, and the one that can silently stop carrying
credentials. `tests/test_crawler.py` covers both transports; this leg is
here so the floor cannot regress it unnoticed.
Both HTTP transports are driven, because they hand headers to the SDK by
different spellings: SSE passes `headers=` straight to `sse_client`, while
streamable-http cannot take that kwarg and needs a client built with
`create_mcp_http_client(headers=...)` and passed as `http_client=`. Either
can stop carrying credentials without the other noticing, and credentials
are what a private server's crawl depends on.
"""
sys.path.insert(0, str(FIXTURE.parent))
from sample_server import AUTH_HEADER, AUTH_TOKEN
Expand All @@ -191,10 +203,10 @@ async def header_forwarding() -> None:
from toolgraph.models import ServerSpec

port = _free_port()
proc = subprocess.Popen([sys.executable, str(FIXTURE), "http-auth", str(port)])
proc = subprocess.Popen([sys.executable, str(FIXTURE), mode, str(port)])
try:
url = f"http://127.0.0.1:{port}/mcp"
authorized = ServerSpec(name="floor", transport="streamable-http", url=url,
url = f"http://127.0.0.1:{port}{path}"
authorized = ServerSpec(name="floor", transport=transport, url=url,
headers={AUTH_HEADER: AUTH_TOKEN})
deadline = time.monotonic() + 30
while True:
Expand All @@ -208,11 +220,19 @@ async def header_forwarding() -> None:
async with open_session(authorized) as session:
await session.initialize()
listed = await session.list_tools()
check("headers reach the server",
check(f"{transport} headers reach the server",
{t.name for t in listed.tools}
== {"read_file", "write_file"})
break
except Exception:
# The retry loop is here to wait out server STARTUP. A rejection
# in the tally means the server is already up and turning us
# away, so retrying just burns the deadline and buries the
# cause under a transport traceback. Fail now, and say why.
if _rejections_or_none(port):
check(f"{transport} headers reach the server", False,
"the server refused an authorized request -- "
"headers are not reaching it")
if time.monotonic() > deadline:
raise
await asyncio.sleep(0.5)
Expand All @@ -221,11 +241,12 @@ async def header_forwarding() -> None:
# check above would pass even if the header were dropped entirely.
#
# The evidence is the SERVER's rejection tally, not the exception the
# client sees. "Some exception was raised" would be vacuous: a timeout
# or a dead port would satisfy it, and the SDK maps both a 401 and a
# 500 onto the same generic error for streamable-http, so the exception
# cannot distinguish "refused" from "fell over".
anonymous = ServerSpec(name="floor", transport="streamable-http", url=url)
# client sees. "Some exception was raised" would be vacuous -- a
# timeout or a dead port satisfies it too. Nor is the exception's shape
# dependable across the two transports this runs for: SSE surfaces the
# 401 itself, while streamable-http funnels a 401 and a 500 into the
# same generic error. The tally answers the same way for both.
anonymous = ServerSpec(name="floor", transport=transport, url=url)
before = _rejections(port)
# The authorized loop above retries, so pin that none of its attempts
# were turned away. Without this the comparison below could start from
Expand Down Expand Up @@ -267,7 +288,10 @@ async def main() -> int:
("pagination", pagination),
("streamable-http", lambda: http_transport("http", "streamable-http", "/mcp")),
("sse", lambda: http_transport("sse", "sse", "/sse")),
("header forwarding", header_forwarding),
("header forwarding (streamable-http)",
lambda: header_forwarding("http-auth", "streamable-http", "/mcp")),
("header forwarding (sse)",
lambda: header_forwarding("sse-auth", "sse", "/sse")),
):
print(f"{name}:")
await make_coro()
Expand Down
2 changes: 1 addition & 1 deletion toolgraph/crawler/transports.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
SDK's ``create_mcp_http_client`` when auth headers are supplied. The two
spellings mean one can stop carrying credentials without the other noticing, so
``test_crawl_forwards_headers`` drives both against a fixture that rejects
tokenless requests, and the floor script pins the streamable-http one.
tokenless requests, and the floor script pins both at the declared SDK floor.
"""

from __future__ import annotations
Expand Down