mirror of
https://github.com/safishamsi/graphify.git
synced 2026-07-13 19:07:10 +00:00
2a683aac2e
python -m graphify.serve graph.json --transport http --port 8080 serves the same MCP tools over the Streamable HTTP transport (spec 2025-03-26) so a single shared process can serve the graph for a whole team. - _build_server() refactors server registration into a shared factory (stdio behavior is byte-for-byte unchanged — all 52 existing tests pass) - _ApiKeyMiddleware: raw ASGI (not BaseHTTPMiddleware) preserves SSE streaming; constant-time compare; RFC-6750 case-insensitive Bearer; blank-key normalized to no-auth - DNS-rebinding protection via TransportSecuritySettings; wildcard binds disable it and print an exposure warning when no api-key is set - session_idle_timeout reaps idle stateful sessions (default 3600s) so a long-running shared server does not leak memory on client disconnect - Dockerfile + .dockerignore for containerized team deployment - 16 new tests via in-process ASGI test client (importorskip-guarded) - stdio remains the default; no change for existing setups Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
233 lines
8.3 KiB
Python
233 lines
8.3 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 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"
|