feat(serve): add Streamable HTTP transport to MCP server (#1143)

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>
This commit is contained in:
Safi
2026-06-07 00:43:40 +01:00
parent 4b06fd7bf7
commit 2a683aac2e
4 changed files with 559 additions and 5 deletions
+25
View File
@@ -0,0 +1,25 @@
.git
.github
.venv
venv
__pycache__
*.pyc
.pytest_cache
.ruff_cache
.mypy_cache
dist
build
*.egg-info
graphify-out
graphify-benchmark
graphify_eval
graphify_test
worked
llm-stack-corpus
llm-stack-demo
product-site
ebook
tests
docs
*.md
!README.md
+25
View File
@@ -0,0 +1,25 @@
# graphify MCP server as a shared HTTP service (issue #1143).
#
# Build: docker build -t graphify .
# Run: docker run -p 8080:8080 -v "$(pwd)/graphify-out:/data" graphify \
# /data/graph.json --transport http --host 0.0.0.0 --api-key "$SECRET"
#
# Builds from source so the image includes the Streamable HTTP transport even
# before it lands on PyPI. The graph.json is mounted at runtime (-v), never
# baked into the image.
FROM python:3.12-slim
WORKDIR /app
COPY . /app
# The [mcp] extra pulls mcp + starlette + uvicorn, which the HTTP transport needs.
RUN pip install --no-cache-dir ".[mcp]"
# Run as a non-root user — the server is network-exposed.
RUN useradd --create-home --uid 10001 graphify
USER graphify
EXPOSE 8080
ENTRYPOINT ["python", "-m", "graphify.serve"]
CMD ["/data/graph.json", "--transport", "http", "--host", "0.0.0.0", "--port", "8080"]
+277 -5
View File
@@ -472,13 +472,18 @@ def _filter_blank_stdin() -> None:
sys.stdin = open(0, "r", closefd=False)
def serve(graph_path: str = "graphify-out/graph.json") -> None:
"""Start the MCP server. Requires pip install mcp."""
def _build_server(graph_path: str):
"""Build the configured low-level MCP Server (shared by every transport).
All graph query tools and resources are registered here over a single
``mcp.server.Server`` instance; the caller picks the transport (stdio or
Streamable HTTP) and runs it. Hot-reload of graph.json works the same way
regardless of transport, since reloads happen inside the tool handlers.
"""
import threading
try:
from mcp.server import Server
from mcp.server.stdio import stdio_server
from mcp import types
from mcp.types import AnyUrl
except ImportError as e:
@@ -992,8 +997,19 @@ def serve(graph_path: str = "graphify-out/graph.json") -> None:
except Exception as exc:
return [types.TextContent(type="text", text=f"Error executing {name}: {exc}")]
return server
def serve(graph_path: str = "graphify-out/graph.json") -> None:
"""Start the MCP server over stdio (the default, per-developer transport)."""
try:
from mcp.server.stdio import stdio_server
except ImportError as e:
raise ImportError('mcp not installed. Run: pip install "graphifyy[mcp]"') from e
import asyncio
server = _build_server(graph_path)
async def main() -> None:
async with stdio_server() as streams:
await server.run(streams[0], streams[1], server.create_initialization_options())
@@ -1002,6 +1018,262 @@ def serve(graph_path: str = "graphify-out/graph.json") -> None:
asyncio.run(main())
class _MCPASGIApp:
"""Raw-ASGI wrapper around the Streamable HTTP session manager.
Passed to a Starlette ``Route`` as a class instance (not a function) so
Starlette treats it as an ASGI app: it serves the exact mount path for all
methods (GET/POST/DELETE) with no request/response wrapping and no
trailing-slash redirect — mirroring how FastMCP mounts the same manager.
"""
def __init__(self, manager) -> None:
self._manager = manager
async def __call__(self, scope, receive, send) -> None:
await self._manager.handle_request(scope, receive, send)
class _ApiKeyMiddleware:
"""Pure-ASGI API-key gate for the HTTP transport.
Implemented as raw ASGI (not Starlette's BaseHTTPMiddleware) on purpose:
BaseHTTPMiddleware buffers responses and breaks the Streamable HTTP SSE
stream. This short-circuits with 401 before the request ever reaches the
session manager, leaving the streaming path untouched for authorized calls.
"""
def __init__(self, app, api_key: str) -> None:
self.app = app
self._expected = api_key.encode("utf-8")
async def __call__(self, scope, receive, send) -> None:
if scope["type"] != "http":
await self.app(scope, receive, send)
return
import hmac
headers = dict(scope.get("headers") or [])
provided = headers.get(b"x-api-key")
if provided is None:
# RFC 6750: the auth scheme token is case-insensitive.
scheme, _, token = headers.get(b"authorization", b"").partition(b" ")
if scheme.lower() == b"bearer" and token:
provided = token.strip()
# Constant-time compare; reject when no key was supplied at all.
if provided is None or not hmac.compare_digest(provided, self._expected):
body = b'{"error": "unauthorized"}'
await send({
"type": "http.response.start",
"status": 401,
"headers": [
(b"content-type", b"application/json"),
(b"content-length", str(len(body)).encode("ascii")),
],
})
await send({"type": "http.response.body", "body": body})
return
await self.app(scope, receive, send)
def _build_http_app(
graph_path: str,
*,
host: str = "127.0.0.1",
port: int = 8080,
api_key: str | None = None,
path: str = "/mcp",
json_response: bool = False,
stateless: bool = False,
session_timeout: float | None = 3600.0,
):
"""Build the Starlette ASGI app for the Streamable HTTP transport.
Split out from :func:`serve_http` (which blocks on uvicorn) so the wiring
can be exercised with an in-process ASGI test client.
``session_timeout`` reaps stateful sessions idle for that many seconds so a
long-running shared server does not leak memory when IDE clients disconnect
without sending a DELETE. ``None`` (or <= 0) disables reaping; it is forced
to ``None`` in stateless mode, which has no sessions to reap.
"""
try:
import contextlib
from starlette.applications import Starlette
from starlette.middleware import Middleware
from starlette.routing import Route
from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
from mcp.server.transport_security import TransportSecuritySettings
except ImportError as e:
raise ImportError(
'HTTP transport needs the mcp extra (mcp + starlette + uvicorn). '
'Run: pip install "graphifyy[mcp]"'
) from e
# A blank key (e.g. --api-key "" or an empty GRAPHIFY_API_KEY) must not be
# mistaken for "auth on" — normalize it to None so the gate is unambiguous.
api_key = (api_key or "").strip() or None
server = _build_server(graph_path)
# DNS-rebinding protection. When the operator binds a wildcard address they
# are intentionally exposing the server, so accept any Host header; for a
# loopback/specific bind, restrict Host to that address (with and without
# the port) plus the localhost aliases.
if host in ("0.0.0.0", "::", ""):
security = TransportSecuritySettings(enable_dns_rebinding_protection=False)
else:
allowed = {host, "localhost", "127.0.0.1"}
allowed |= {f"{h}:{port}" for h in list(allowed)}
security = TransportSecuritySettings(allowed_hosts=sorted(allowed))
# The SDK rejects a non-positive timeout and forbids one in stateless mode.
idle_timeout = None if (stateless or not session_timeout or session_timeout <= 0) else session_timeout
manager = StreamableHTTPSessionManager(
app=server,
json_response=json_response,
stateless=stateless,
security_settings=security,
session_idle_timeout=idle_timeout,
)
@contextlib.asynccontextmanager
async def lifespan(_app):
# The session manager owns an anyio task group that must wrap the whole
# server lifetime, so enter it here rather than per-request.
async with manager.run():
yield
middleware = []
if api_key:
middleware.append(Middleware(_ApiKeyMiddleware, api_key=api_key))
return Starlette(
routes=[Route(path, endpoint=_MCPASGIApp(manager))],
middleware=middleware,
lifespan=lifespan,
)
def serve_http(
graph_path: str = "graphify-out/graph.json",
*,
host: str = "127.0.0.1",
port: int = 8080,
api_key: str | None = None,
path: str = "/mcp",
json_response: bool = False,
stateless: bool = False,
session_timeout: float | None = 3600.0,
) -> None:
"""Start the MCP server over Streamable HTTP (MCP spec 2025-03-26).
Serves the same tools/resources as the stdio transport, so a single shared
process can host the graph for a whole team. Clients point their IDE MCP
config at ``http://<host>:<port><path>`` (default ``/mcp``).
``api_key`` (or the ``GRAPHIFY_API_KEY`` env var) enables a simple header
check (``Authorization: Bearer <key>`` or ``X-API-Key: <key>``). OAuth is a
deliberate follow-up. Binding ``0.0.0.0`` exposes the server beyond
localhost — set an api_key when you do.
"""
try:
import uvicorn
except ImportError as e:
raise ImportError(
'HTTP transport needs the mcp extra (mcp + starlette + uvicorn). '
'Run: pip install "graphifyy[mcp]"'
) from e
api_key = (api_key or "").strip() or None
app = _build_http_app(
graph_path,
host=host,
port=port,
api_key=api_key,
path=path,
json_response=json_response,
stateless=stateless,
session_timeout=session_timeout,
)
auth_note = "api-key required" if api_key else "no auth (set --api-key to require one)"
print(
f"graphify MCP server (streamable-http) on http://{host}:{port}{path} - {auth_note}",
file=sys.stderr,
)
if host in ("0.0.0.0", "::", "") and not api_key:
print(
f"WARNING: binding {host or '0.0.0.0'} with no api-key exposes the graph "
"unauthenticated on the network. Set --api-key (or GRAPHIFY_API_KEY).",
file=sys.stderr,
)
uvicorn.run(app, host=host, port=port)
def _main(argv: list[str] | None = None) -> None:
import argparse
import os
parser = argparse.ArgumentParser(
prog="python -m graphify.serve",
description="Serve a graphify knowledge graph over MCP (stdio or Streamable HTTP).",
)
parser.add_argument(
"graph_path",
nargs="?",
default="graphify-out/graph.json",
help="Path to graph.json (default: graphify-out/graph.json)",
)
parser.add_argument(
"--transport",
choices=["stdio", "http"],
default="stdio",
help="Transport to serve on (default: stdio)",
)
parser.add_argument("--host", default="127.0.0.1", help="HTTP bind host (default: 127.0.0.1)")
parser.add_argument("--port", type=int, default=8080, help="HTTP bind port (default: 8080)")
parser.add_argument(
"--api-key",
default=os.environ.get("GRAPHIFY_API_KEY"),
help="Require this key on the HTTP transport (env: GRAPHIFY_API_KEY)",
)
parser.add_argument("--path", default="/mcp", help="HTTP mount path (default: /mcp)")
parser.add_argument(
"--json-response",
action="store_true",
help="Return plain JSON responses instead of SSE streams",
)
parser.add_argument(
"--stateless",
action="store_true",
help="Run without per-session state (for load-balanced / CI deployments)",
)
parser.add_argument(
"--session-timeout",
type=float,
default=3600.0,
help="Reap stateful sessions idle this many seconds (default: 3600; 0 disables)",
)
args = parser.parse_args(argv)
if args.transport == "http":
serve_http(
args.graph_path,
host=args.host,
port=args.port,
api_key=args.api_key,
path=args.path,
json_response=args.json_response,
stateless=args.stateless,
session_timeout=args.session_timeout,
)
else:
serve(args.graph_path)
if __name__ == "__main__":
graph_path = sys.argv[1] if len(sys.argv) > 1 else "graphify-out/graph.json"
serve(graph_path)
_main()
+232
View File
@@ -0,0 +1,232 @@
"""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"