mirror of
https://github.com/headroomlabs-ai/headroom.git
synced 2026-08-10 14:27:00 -04:00
feat(mcp): add streamable HTTP MCP transport (#1773)
## Description `headroom mcp serve` only exposed stdio, which blocked MCP clients that require a Streamable HTTP endpoint. This PR adds an explicit HTTP transport mode around the existing Headroom MCP server while keeping stdio as the default and keeping tool registration single-sourced. Closes #1346. ## Type of Change - [ ] Bug fix (non-breaking change that fixes an issue) - [x] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [x] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made - Add `headroom mcp serve --transport http` with host, port, and path options. - Serve `headroom_compress`, `headroom_retrieve`, and `headroom_stats` through the same MCP server instance used by stdio. - Keep `headroom mcp serve` defaulting to stdio for current Claude Code and local MCP host configs. - Update MCP docs for stdio and HTTP setup without implying the proxy automatically owns `/mcp`. - Keep the scope clean, rebased, and covered by focused tests. ## Testing - [x] Unit tests pass (`uv run pytest tests/test_ccr_mcp_http.py tests/test_cli/test_mcp.py -q`) - [x] Linting passes (`uv run ruff check headroom/cli/mcp.py headroom/ccr/mcp_server.py headroom/ccr/mcp_http.py tests/test_ccr_mcp_http.py tests/test_cli/test_mcp.py`) - [x] Type checking passes (`uv run mypy headroom --ignore-missing-imports`) - [x] New tests added for new functionality when applicable - [x] Manual testing performed ### Test Output ```text uv run pytest tests/test_ccr_mcp_http.py tests/test_cli/test_mcp.py -q 20 passed in 0.53s uv run ruff check headroom/cli/mcp.py headroom/ccr/mcp_server.py headroom/ccr/mcp_http.py tests/test_ccr_mcp_http.py tests/test_cli/test_mcp.py All checks passed! uv run ruff format headroom/cli/mcp.py headroom/ccr/mcp_server.py headroom/ccr/mcp_http.py tests/test_ccr_mcp_http.py tests/test_cli/test_mcp.py --check 5 files already formatted uv run mypy headroom --ignore-missing-imports Success: no issues found in 407 source files ``` ## Real Behavior Proof - Environment: Local Python environment with Headroom dev dependencies and MCP extra installed. - Exact command / steps: Start `headroom mcp serve --transport http --host 127.0.0.1 --port <test-port> --path /mcp`, then perform an MCP SDK Streamable HTTP initialize/list-tools exchange. - Observed result: The HTTP transport initializes and lists the existing Headroom MCP tools; `headroom mcp serve` without `--transport` still selects stdio, and mixed-case `--transport HTTP` routes to the HTTP transport. - Not tested: live validation against external MCP hosts ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Additional Notes `CHANGELOG.md` is not edited because this repository generates changelog entries from conventional commits. Full-suite validation is left to CI.
This commit is contained in:
parent
c46cd8f950
commit
4ea96a417c
8 changed files with 262 additions and 5 deletions
|
|
@ -115,6 +115,9 @@ headroom mcp uninstall
|
|||
|
||||
# Debug mode
|
||||
headroom mcp serve --debug
|
||||
|
||||
# Streamable HTTP mode
|
||||
headroom mcp serve --transport http --host 127.0.0.1 --port 8788 --path /mcp
|
||||
```
|
||||
|
||||
## MCP host configuration
|
||||
|
|
@ -154,7 +157,9 @@ For multiple proxy instances, register one stdio MCP server per proxy URL:
|
|||
}
|
||||
```
|
||||
|
||||
Do not assume that a running proxy exposes an HTTP MCP endpoint at `/mcp`. If `http://127.0.0.1:<port>/mcp` returns `404`, use the stdio configuration above.
|
||||
If you need Streamable HTTP instead of stdio, run `headroom mcp serve --transport http` and point your MCP host at that endpoint. The default path is `/mcp`, and the default port is `8788`.
|
||||
|
||||
Do not assume that a running proxy exposes an HTTP MCP endpoint at `/mcp`. The proxy serves its own API; it does not automatically provide the MCP HTTP transport.
|
||||
|
||||
### `command: "headroom"` fails to start
|
||||
|
||||
|
|
|
|||
78
headroom/ccr/mcp_http.py
Normal file
78
headroom/ccr/mcp_http.py
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
"""Streamable HTTP transport helpers for the Headroom MCP server."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
|
||||
from starlette.applications import Starlette
|
||||
from starlette.routing import Route
|
||||
from starlette.types import Receive, Scope, Send
|
||||
|
||||
|
||||
def _normalize_path(path: str) -> str:
|
||||
normalized = path.strip()
|
||||
if not normalized.startswith("/"):
|
||||
normalized = f"/{normalized}"
|
||||
if normalized != "/":
|
||||
normalized = normalized.rstrip("/")
|
||||
return normalized
|
||||
|
||||
|
||||
class StreamableHTTPASGIApp:
|
||||
"""Delegate ASGI requests to the SDK session manager."""
|
||||
|
||||
def __init__(self, session_manager: StreamableHTTPSessionManager):
|
||||
self.session_manager = session_manager
|
||||
|
||||
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
||||
await self.session_manager.handle_request(scope, receive, send)
|
||||
|
||||
|
||||
def create_streamable_http_session_manager(server: Any) -> StreamableHTTPSessionManager:
|
||||
"""Build the SDK session manager for an existing MCP server."""
|
||||
return StreamableHTTPSessionManager(app=server.server)
|
||||
|
||||
|
||||
def create_streamable_http_app(
|
||||
session_manager: StreamableHTTPSessionManager,
|
||||
*,
|
||||
path: str,
|
||||
debug: bool = False,
|
||||
) -> Starlette:
|
||||
"""Build the Starlette app that exposes the MCP server over HTTP."""
|
||||
streamable_http_app = StreamableHTTPASGIApp(session_manager)
|
||||
return Starlette(
|
||||
debug=debug,
|
||||
routes=[
|
||||
Route(
|
||||
_normalize_path(path),
|
||||
endpoint=streamable_http_app,
|
||||
methods=["GET", "POST", "DELETE", "OPTIONS"],
|
||||
)
|
||||
],
|
||||
lifespan=lambda app: session_manager.run(),
|
||||
)
|
||||
|
||||
|
||||
async def serve_streamable_http(
|
||||
server: Any,
|
||||
*,
|
||||
host: str,
|
||||
port: int,
|
||||
path: str,
|
||||
debug: bool = False,
|
||||
) -> None:
|
||||
"""Serve the MCP server over Streamable HTTP with uvicorn."""
|
||||
import uvicorn
|
||||
|
||||
session_manager = create_streamable_http_session_manager(server)
|
||||
starlette_app = create_streamable_http_app(session_manager, path=path, debug=debug)
|
||||
config = uvicorn.Config(
|
||||
starlette_app,
|
||||
host=host,
|
||||
port=port,
|
||||
log_level="debug" if debug else "warning",
|
||||
)
|
||||
uvicorn_server = uvicorn.Server(config)
|
||||
await uvicorn_server.serve()
|
||||
|
|
@ -1046,6 +1046,24 @@ class HeadroomMCPServer:
|
|||
self.server.create_initialization_options(),
|
||||
)
|
||||
|
||||
async def run_streamable_http(
|
||||
self,
|
||||
host: str,
|
||||
port: int,
|
||||
path: str,
|
||||
debug: bool = False,
|
||||
) -> None:
|
||||
"""Run the server with Streamable HTTP transport."""
|
||||
from .mcp_http import serve_streamable_http
|
||||
|
||||
await serve_streamable_http(
|
||||
self,
|
||||
host=host,
|
||||
port=port,
|
||||
path=path,
|
||||
debug=debug,
|
||||
)
|
||||
|
||||
async def cleanup(self) -> None:
|
||||
"""Clean up resources."""
|
||||
if self._http_client:
|
||||
|
|
|
|||
|
|
@ -17,6 +17,9 @@ from .main import main
|
|||
CLAUDE_CONFIG_DIR = Path.home() / ".claude"
|
||||
MCP_CONFIG_PATH = CLAUDE_CONFIG_DIR / "mcp.json"
|
||||
DEFAULT_PROXY_URL = "http://127.0.0.1:8787"
|
||||
DEFAULT_HTTP_HOST = "127.0.0.1"
|
||||
DEFAULT_HTTP_PORT = 8788
|
||||
DEFAULT_HTTP_PATH = "/mcp"
|
||||
|
||||
|
||||
def get_headroom_command() -> list[str]:
|
||||
|
|
@ -257,6 +260,32 @@ def mcp_status() -> None:
|
|||
envvar="HEADROOM_PROXY_URL",
|
||||
help=f"Headroom proxy URL (default: {DEFAULT_PROXY_URL})",
|
||||
)
|
||||
@click.option(
|
||||
"--transport",
|
||||
type=click.Choice(["stdio", "http"], case_sensitive=False),
|
||||
default="stdio",
|
||||
show_default=True,
|
||||
help="Transport to use for headroom mcp serve",
|
||||
)
|
||||
@click.option(
|
||||
"--host",
|
||||
default=DEFAULT_HTTP_HOST,
|
||||
show_default=True,
|
||||
help="HTTP bind host",
|
||||
)
|
||||
@click.option(
|
||||
"--port",
|
||||
default=DEFAULT_HTTP_PORT,
|
||||
show_default=True,
|
||||
type=int,
|
||||
help="HTTP bind port",
|
||||
)
|
||||
@click.option(
|
||||
"--path",
|
||||
default=DEFAULT_HTTP_PATH,
|
||||
show_default=True,
|
||||
help="HTTP endpoint path",
|
||||
)
|
||||
@click.option(
|
||||
"--direct",
|
||||
is_flag=True,
|
||||
|
|
@ -267,16 +296,26 @@ def mcp_status() -> None:
|
|||
is_flag=True,
|
||||
help="Enable debug logging",
|
||||
)
|
||||
def mcp_serve(proxy_url: str | None, direct: bool, debug: bool) -> None:
|
||||
def mcp_serve(
|
||||
proxy_url: str | None,
|
||||
transport: str,
|
||||
host: str,
|
||||
port: int,
|
||||
path: str,
|
||||
direct: bool,
|
||||
debug: bool,
|
||||
) -> None:
|
||||
"""Start the MCP server (called by Claude Code).
|
||||
|
||||
\b
|
||||
This command is typically invoked by Claude Code via the MCP config,
|
||||
not run directly. It starts the MCP server with stdio transport.
|
||||
not run directly. It starts the MCP server with stdio by default or
|
||||
Streamable HTTP when requested.
|
||||
|
||||
\b
|
||||
For manual testing:
|
||||
headroom mcp serve --debug
|
||||
headroom mcp serve --transport http --host 127.0.0.1 --port 8788 --path /mcp
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
|
|
@ -301,6 +340,8 @@ def mcp_serve(proxy_url: str | None, direct: bool, debug: bool) -> None:
|
|||
format="%(levelname)s: %(message)s",
|
||||
)
|
||||
|
||||
transport = transport.lower()
|
||||
|
||||
# Use default if not specified
|
||||
effective_proxy_url = proxy_url or DEFAULT_PROXY_URL
|
||||
|
||||
|
|
@ -314,7 +355,10 @@ def mcp_serve(proxy_url: str | None, direct: bool, debug: bool) -> None:
|
|||
|
||||
async def run() -> None:
|
||||
try:
|
||||
await server.run_stdio()
|
||||
if transport == "http":
|
||||
await server.run_streamable_http(host=host, port=port, path=path, debug=debug)
|
||||
else:
|
||||
await server.run_stdio()
|
||||
finally:
|
||||
await server.cleanup()
|
||||
|
||||
|
|
|
|||
|
|
@ -212,6 +212,8 @@ strands = [
|
|||
mcp = [
|
||||
"mcp>=1.0.0",
|
||||
"httpx>=0.24.0",
|
||||
"starlette>=0.27.0",
|
||||
"uvicorn>=0.23.0,<1.0",
|
||||
]
|
||||
# Voice filler detection
|
||||
voice = [
|
||||
|
|
|
|||
51
tests/test_ccr_mcp_http.py
Normal file
51
tests/test_ccr_mcp_http.py
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
"""Contract tests for the Headroom Streamable HTTP MCP transport."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
pytest.importorskip("mcp")
|
||||
|
||||
from mcp.client.session import ClientSession
|
||||
from mcp.client.streamable_http import streamable_http_client
|
||||
|
||||
from headroom.ccr.mcp_http import (
|
||||
create_streamable_http_app,
|
||||
create_streamable_http_session_manager,
|
||||
)
|
||||
from headroom.ccr.mcp_server import create_ccr_mcp_server
|
||||
|
||||
pytestmark = pytest.mark.anyio
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def anyio_backend() -> str:
|
||||
return "asyncio"
|
||||
|
||||
|
||||
async def test_streamable_http_initialize_and_list_tools() -> None:
|
||||
server = create_ccr_mcp_server()
|
||||
session_manager = create_streamable_http_session_manager(server)
|
||||
app = create_streamable_http_app(session_manager, path="/mcp")
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
|
||||
async with session_manager.run():
|
||||
async with httpx.AsyncClient(
|
||||
transport=transport, base_url="http://testserver"
|
||||
) as http_client:
|
||||
async with streamable_http_client(
|
||||
"http://testserver/mcp",
|
||||
http_client=http_client,
|
||||
terminate_on_close=False,
|
||||
) as (read_stream, write_stream, get_session_id):
|
||||
async with ClientSession(read_stream, write_stream) as session:
|
||||
initialize_result = await session.initialize()
|
||||
list_tools_result = await session.list_tools()
|
||||
|
||||
tool_names = [tool.name for tool in list_tools_result.tools]
|
||||
assert initialize_result.protocolVersion
|
||||
assert get_session_id() is not None
|
||||
assert "headroom_compress" in tool_names
|
||||
assert "headroom_retrieve" in tool_names
|
||||
assert "headroom_stats" in tool_names
|
||||
|
|
@ -8,7 +8,7 @@ These are real tests that:
|
|||
|
||||
import json
|
||||
import sys
|
||||
from unittest.mock import MagicMock, patch
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from click.testing import CliRunner
|
||||
|
|
@ -240,9 +240,64 @@ class TestMCPServeCommand:
|
|||
result = runner.invoke(main, ["mcp", "serve", "--help"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "transport" in result.output
|
||||
assert "host" in result.output
|
||||
assert "port" in result.output
|
||||
assert "path" in result.output
|
||||
assert "proxy-url" in result.output
|
||||
assert "debug" in result.output
|
||||
|
||||
def test_serve_defaults_to_stdio(self):
|
||||
"""Serve command defaults to stdio transport."""
|
||||
fake_server = MagicMock()
|
||||
fake_server.run_stdio = AsyncMock()
|
||||
fake_server.run_streamable_http = AsyncMock()
|
||||
fake_server.cleanup = AsyncMock()
|
||||
|
||||
runner = CliRunner()
|
||||
with patch("headroom.ccr.mcp_server.create_ccr_mcp_server", return_value=fake_server):
|
||||
result = runner.invoke(main, ["mcp", "serve"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
fake_server.run_stdio.assert_awaited_once()
|
||||
fake_server.run_streamable_http.assert_not_awaited()
|
||||
fake_server.cleanup.assert_awaited_once()
|
||||
|
||||
def test_serve_http_transport_uses_http_settings(self):
|
||||
"""Serve command uses HTTP transport when requested with mixed-case input."""
|
||||
fake_server = MagicMock()
|
||||
fake_server.run_stdio = AsyncMock()
|
||||
fake_server.run_streamable_http = AsyncMock()
|
||||
fake_server.cleanup = AsyncMock()
|
||||
|
||||
runner = CliRunner()
|
||||
with patch("headroom.ccr.mcp_server.create_ccr_mcp_server", return_value=fake_server):
|
||||
result = runner.invoke(
|
||||
main,
|
||||
[
|
||||
"mcp",
|
||||
"serve",
|
||||
"--transport",
|
||||
"HTTP",
|
||||
"--host",
|
||||
"0.0.0.0",
|
||||
"--port",
|
||||
"9191",
|
||||
"--path",
|
||||
"/mcp",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0
|
||||
fake_server.run_stdio.assert_not_awaited()
|
||||
fake_server.run_streamable_http.assert_awaited_once_with(
|
||||
host="0.0.0.0",
|
||||
port=9191,
|
||||
path="/mcp",
|
||||
debug=False,
|
||||
)
|
||||
fake_server.cleanup.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.skipif(not MCP_AVAILABLE, reason="MCP SDK not installed")
|
||||
class TestMCPServerInitialization:
|
||||
|
|
|
|||
4
uv.lock
generated
4
uv.lock
generated
|
|
@ -1647,6 +1647,8 @@ langchain = [
|
|||
mcp = [
|
||||
{ name = "httpx" },
|
||||
{ name = "mcp" },
|
||||
{ name = "starlette" },
|
||||
{ name = "uvicorn" },
|
||||
]
|
||||
memory = [
|
||||
{ name = "sentence-transformers", marker = "platform_machine != 'x86_64' or sys_platform != 'darwin'" },
|
||||
|
|
@ -1844,6 +1846,7 @@ requires-dist = [
|
|||
{ name = "sqlite-vec", marker = "extra == 'dev'", specifier = ">=0.1.6" },
|
||||
{ name = "sqlite-vec", marker = "extra == 'memory'", specifier = ">=0.1.6" },
|
||||
{ name = "sqlite-vec", marker = "extra == 'proxy'", specifier = ">=0.1.6" },
|
||||
{ name = "starlette", marker = "extra == 'mcp'", specifier = ">=0.27.0" },
|
||||
{ name = "strands-agents", marker = "extra == 'strands'", specifier = ">=0.1.0" },
|
||||
{ name = "tiktoken", specifier = ">=0.5.0" },
|
||||
{ name = "tomli", marker = "python_full_version < '3.11'", specifier = ">=2.0.0" },
|
||||
|
|
@ -1857,6 +1860,7 @@ requires-dist = [
|
|||
{ name = "tree-sitter", marker = "extra == 'code'", specifier = ">=0.25.2,<0.27" },
|
||||
{ name = "tree-sitter-language-pack", marker = "extra == 'code'", specifier = ">=0.10.0,<1.0" },
|
||||
{ name = "uvicorn", marker = "extra == 'dev'", specifier = ">=0.23.0,<1.0" },
|
||||
{ name = "uvicorn", marker = "extra == 'mcp'", specifier = ">=0.23.0,<1.0" },
|
||||
{ name = "uvicorn", marker = "extra == 'proxy'", specifier = ">=0.23.0,<1.0" },
|
||||
{ name = "watchdog", marker = "extra == 'proxy'", specifier = ">=4.0.0" },
|
||||
{ name = "websockets", marker = "extra == 'dev'", specifier = ">=13.0" },
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue