302 lines
12 KiB
Python
302 lines
12 KiB
Python
"""Tests for the Streamable HTTP transport on the MCP server (issue #1143).
|
|
|
|
These exercise the ASGI wiring in-process (no uvicorn, no real socket) via
|
|
Starlette's TestClient, so they stay fast and offline. The stdio path is
|
|
unchanged and covered elsewhere.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
pytest.importorskip("mcp")
|
|
pytest.importorskip("starlette")
|
|
|
|
from starlette.testclient import TestClient # noqa: E402
|
|
|
|
from graphify import serve as serve_mod # noqa: E402
|
|
|
|
SAMPLE_GRAPH = {
|
|
"directed": True,
|
|
"nodes": [
|
|
{"id": "a", "label": "Alpha", "community": 0},
|
|
{"id": "b", "label": "Beta", "community": 0},
|
|
],
|
|
"edges": [
|
|
{"source": "a", "target": "b", "relation": "calls", "confidence": "EXTRACTED"},
|
|
],
|
|
}
|
|
|
|
_INIT_BODY = {
|
|
"jsonrpc": "2.0",
|
|
"id": 1,
|
|
"method": "initialize",
|
|
"params": {
|
|
"protocolVersion": "2025-03-26",
|
|
"capabilities": {},
|
|
"clientInfo": {"name": "test", "version": "0"},
|
|
},
|
|
}
|
|
|
|
_MCP_HEADERS = {
|
|
"Content-Type": "application/json",
|
|
"Accept": "application/json, text/event-stream",
|
|
}
|
|
|
|
|
|
def _graph_file(tmp_path: Path) -> str:
|
|
p = tmp_path / "graph.json"
|
|
p.write_text(json.dumps(SAMPLE_GRAPH), encoding="utf-8")
|
|
return str(p)
|
|
|
|
|
|
def _client(app) -> TestClient:
|
|
# Default host is 127.0.0.1, so the DNS-rebinding guard only accepts that
|
|
# Host header (TestClient otherwise sends the disallowed "testserver").
|
|
return TestClient(app, base_url="http://127.0.0.1")
|
|
|
|
|
|
def test_app_builds_and_initialize_succeeds(tmp_path):
|
|
app = serve_mod._build_http_app(_graph_file(tmp_path), json_response=True)
|
|
with _client(app) as client:
|
|
resp = client.post("/mcp", headers=_MCP_HEADERS, json=_INIT_BODY)
|
|
assert resp.status_code == 200
|
|
# json_response=True returns a single JSON-RPC envelope.
|
|
payload = resp.json()
|
|
assert payload["jsonrpc"] == "2.0"
|
|
assert payload["result"]["serverInfo"]["name"] == "graphify"
|
|
|
|
|
|
def test_unknown_path_is_404(tmp_path):
|
|
app = serve_mod._build_http_app(_graph_file(tmp_path), json_response=True)
|
|
with _client(app) as client:
|
|
resp = client.post("/nope", headers=_MCP_HEADERS, json=_INIT_BODY)
|
|
assert resp.status_code == 404
|
|
|
|
|
|
def test_api_key_missing_is_401(tmp_path):
|
|
app = serve_mod._build_http_app(_graph_file(tmp_path), api_key="s3cret", json_response=True)
|
|
with _client(app) as client:
|
|
resp = client.post("/mcp", headers=_MCP_HEADERS, json=_INIT_BODY)
|
|
assert resp.status_code == 401
|
|
assert resp.json()["error"] == "unauthorized"
|
|
|
|
|
|
def test_api_key_wrong_is_401(tmp_path):
|
|
app = serve_mod._build_http_app(_graph_file(tmp_path), api_key="s3cret", json_response=True)
|
|
with _client(app) as client:
|
|
resp = client.post(
|
|
"/mcp",
|
|
headers={**_MCP_HEADERS, "Authorization": "Bearer nope"},
|
|
json=_INIT_BODY,
|
|
)
|
|
assert resp.status_code == 401
|
|
|
|
|
|
def test_api_key_bearer_ok(tmp_path):
|
|
app = serve_mod._build_http_app(_graph_file(tmp_path), api_key="s3cret", json_response=True)
|
|
with _client(app) as client:
|
|
resp = client.post(
|
|
"/mcp",
|
|
headers={**_MCP_HEADERS, "Authorization": "Bearer s3cret"},
|
|
json=_INIT_BODY,
|
|
)
|
|
assert resp.status_code == 200
|
|
assert resp.json()["result"]["serverInfo"]["name"] == "graphify"
|
|
|
|
|
|
def test_api_key_x_api_key_header_ok(tmp_path):
|
|
app = serve_mod._build_http_app(_graph_file(tmp_path), api_key="s3cret", json_response=True)
|
|
with _client(app) as client:
|
|
resp = client.post(
|
|
"/mcp",
|
|
headers={**_MCP_HEADERS, "X-API-Key": "s3cret"},
|
|
json=_INIT_BODY,
|
|
)
|
|
assert resp.status_code == 200
|
|
|
|
|
|
def test_blank_api_key_means_no_auth(tmp_path):
|
|
# An empty/whitespace key must normalize to "no auth", not a key of "".
|
|
app = serve_mod._build_http_app(_graph_file(tmp_path), api_key=" ", json_response=True)
|
|
with _client(app) as client:
|
|
resp = client.post("/mcp", headers=_MCP_HEADERS, json=_INIT_BODY)
|
|
assert resp.status_code == 200
|
|
|
|
|
|
def test_api_key_bearer_scheme_case_insensitive(tmp_path):
|
|
app = serve_mod._build_http_app(_graph_file(tmp_path), api_key="s3cret", json_response=True)
|
|
with _client(app) as client:
|
|
resp = client.post(
|
|
"/mcp",
|
|
headers={**_MCP_HEADERS, "Authorization": "bearer s3cret"},
|
|
json=_INIT_BODY,
|
|
)
|
|
assert resp.status_code == 200
|
|
|
|
|
|
def test_custom_mount_path(tmp_path):
|
|
app = serve_mod._build_http_app(_graph_file(tmp_path), path="/graph", json_response=True)
|
|
with _client(app) as client:
|
|
ok = client.post("/graph", headers=_MCP_HEADERS, json=_INIT_BODY)
|
|
assert ok.status_code == 200
|
|
missing = client.post("/mcp", headers=_MCP_HEADERS, json=_INIT_BODY)
|
|
assert missing.status_code == 404
|
|
|
|
|
|
def test_tools_list_over_http(tmp_path):
|
|
"""A full initialize -> tools/list round trip works over the HTTP transport."""
|
|
app = serve_mod._build_http_app(_graph_file(tmp_path), json_response=True)
|
|
with _client(app) as client:
|
|
init = client.post("/mcp", headers=_MCP_HEADERS, json=_INIT_BODY)
|
|
assert init.status_code == 200
|
|
session_id = init.headers.get("mcp-session-id")
|
|
assert session_id, "stateful transport should return a session id"
|
|
notify_headers = {**_MCP_HEADERS, "mcp-session-id": session_id}
|
|
client.post(
|
|
"/mcp",
|
|
headers=notify_headers,
|
|
json={"jsonrpc": "2.0", "method": "notifications/initialized"},
|
|
)
|
|
resp = client.post(
|
|
"/mcp",
|
|
headers=notify_headers,
|
|
json={"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {}},
|
|
)
|
|
assert resp.status_code == 200
|
|
names = {t["name"] for t in resp.json()["result"]["tools"]}
|
|
assert {"query_graph", "get_node", "graph_stats"} <= names
|
|
|
|
|
|
def _project_with_graph(tmp_path, node_count: int) -> str:
|
|
"""Create ``<proj>/graphify-out/graph.json`` and return the project dir."""
|
|
proj = tmp_path / "proj"
|
|
(proj / "graphify-out").mkdir(parents=True)
|
|
graph = {
|
|
"directed": True,
|
|
"nodes": [{"id": f"n{i}", "label": f"N{i}", "community": 0} for i in range(node_count)],
|
|
"edges": [],
|
|
}
|
|
(proj / "graphify-out" / "graph.json").write_text(json.dumps(graph), encoding="utf-8")
|
|
return str(proj)
|
|
|
|
|
|
def _init_session(client) -> dict:
|
|
init = client.post("/mcp", headers=_MCP_HEADERS, json=_INIT_BODY)
|
|
assert init.status_code == 200
|
|
headers = {**_MCP_HEADERS, "mcp-session-id": init.headers.get("mcp-session-id")}
|
|
client.post("/mcp", headers=headers, json={"jsonrpc": "2.0", "method": "notifications/initialized"})
|
|
return headers
|
|
|
|
|
|
def _call_tool(client, headers, name, arguments, rid) -> str:
|
|
resp = client.post("/mcp", headers=headers, json={
|
|
"jsonrpc": "2.0", "id": rid, "method": "tools/call",
|
|
"params": {"name": name, "arguments": arguments},
|
|
})
|
|
assert resp.status_code == 200
|
|
return resp.json()["result"]["content"][0]["text"]
|
|
|
|
|
|
def test_project_path_is_optional_on_every_tool(tmp_path):
|
|
"""Multi-project support is additive: every tool gains an optional
|
|
project_path, and none of them makes it required."""
|
|
app = serve_mod._build_http_app(_graph_file(tmp_path), json_response=True)
|
|
with _client(app) as client:
|
|
headers = _init_session(client)
|
|
resp = client.post("/mcp", headers=headers,
|
|
json={"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {}})
|
|
for tool in resp.json()["result"]["tools"]:
|
|
props = tool["inputSchema"].get("properties", {})
|
|
assert "project_path" in props, f"{tool['name']} missing project_path"
|
|
assert "project_path" not in tool["inputSchema"].get("required", [])
|
|
|
|
|
|
def test_project_path_routes_to_that_projects_graph(tmp_path):
|
|
"""One running server answers against the default graph when project_path is
|
|
omitted, and against a project's own graph when it is supplied."""
|
|
proj = _project_with_graph(tmp_path, node_count=3) # default graph has 2 nodes
|
|
app = serve_mod._build_http_app(_graph_file(tmp_path), json_response=True)
|
|
with _client(app) as client:
|
|
headers = _init_session(client)
|
|
assert "Nodes: 2" in _call_tool(client, headers, "graph_stats", {}, rid=2)
|
|
assert "Nodes: 3" in _call_tool(client, headers, "graph_stats", {"project_path": proj}, rid=3)
|
|
# Falling back to the default afterwards still works (no state leak).
|
|
assert "Nodes: 2" in _call_tool(client, headers, "graph_stats", {}, rid=4)
|
|
|
|
|
|
def test_bad_project_path_errors_without_killing_server(tmp_path):
|
|
"""A missing project graph is a tool error, not a process exit — the server
|
|
keeps serving the default graph."""
|
|
app = serve_mod._build_http_app(_graph_file(tmp_path), json_response=True)
|
|
with _client(app) as client:
|
|
headers = _init_session(client)
|
|
bad = _call_tool(client, headers, "graph_stats",
|
|
{"project_path": str(tmp_path / "does-not-exist")}, rid=2)
|
|
assert "not found" in bad.lower()
|
|
assert "Nodes: 2" in _call_tool(client, headers, "graph_stats", {}, rid=3)
|
|
|
|
|
|
def test_stateless_mode_initialize(tmp_path):
|
|
app = serve_mod._build_http_app(_graph_file(tmp_path), stateless=True, json_response=True)
|
|
with _client(app) as client:
|
|
resp = client.post("/mcp", headers=_MCP_HEADERS, json=_INIT_BODY)
|
|
assert resp.status_code == 200
|
|
|
|
|
|
def test_stateless_with_timeout_does_not_raise(tmp_path):
|
|
# session_timeout must be forced to None in stateless mode (the SDK raises
|
|
# RuntimeError otherwise). Building + a request should just work.
|
|
app = serve_mod._build_http_app(
|
|
_graph_file(tmp_path), stateless=True, session_timeout=3600, json_response=True
|
|
)
|
|
with _client(app) as client:
|
|
assert client.post("/mcp", headers=_MCP_HEADERS, json=_INIT_BODY).status_code == 200
|
|
|
|
|
|
def test_session_timeout_zero_disables(tmp_path):
|
|
# 0 / non-positive must disable reaping without tripping the SDK's validation.
|
|
app = serve_mod._build_http_app(_graph_file(tmp_path), session_timeout=0, json_response=True)
|
|
with _client(app) as client:
|
|
assert client.post("/mcp", headers=_MCP_HEADERS, json=_INIT_BODY).status_code == 200
|
|
|
|
|
|
# --- CLI argument parsing -------------------------------------------------
|
|
|
|
def test_cli_defaults_to_stdio(monkeypatch):
|
|
calls = {}
|
|
monkeypatch.setattr(serve_mod, "serve", lambda gp: calls.setdefault("stdio", gp))
|
|
monkeypatch.setattr(
|
|
serve_mod, "serve_http", lambda *a, **k: calls.setdefault("http", (a, k))
|
|
)
|
|
serve_mod._main(["graphify-out/graph.json"])
|
|
assert calls.get("stdio") == "graphify-out/graph.json"
|
|
assert "http" not in calls
|
|
|
|
|
|
def test_cli_http_passes_flags(monkeypatch):
|
|
captured = {}
|
|
monkeypatch.setattr(serve_mod, "serve", lambda gp: captured.setdefault("stdio", gp))
|
|
monkeypatch.setattr(
|
|
serve_mod, "serve_http", lambda gp, **k: captured.update(gp=gp, **k)
|
|
)
|
|
serve_mod._main([
|
|
"g.json", "--transport", "http", "--host", "0.0.0.0",
|
|
"--port", "9000", "--api-key", "k", "--stateless",
|
|
])
|
|
assert captured["gp"] == "g.json"
|
|
assert captured["host"] == "0.0.0.0"
|
|
assert captured["port"] == 9000
|
|
assert captured["api_key"] == "k"
|
|
assert captured["stateless"] is True
|
|
|
|
|
|
def test_cli_api_key_from_env(monkeypatch):
|
|
captured = {}
|
|
monkeypatch.setenv("GRAPHIFY_API_KEY", "from-env")
|
|
monkeypatch.setattr(serve_mod, "serve_http", lambda gp, **k: captured.update(**k))
|
|
serve_mod._main(["g.json", "--transport", "http"])
|
|
assert captured["api_key"] == "from-env"
|