feat(mcp): תחבורת streamable-http לצד stdio (#231.1) #425

Merged
chaim merged 1 commits from worktree-mcp-http-transport into main 2026-08-05 07:42:22 +00:00

View File

@@ -6,6 +6,7 @@ Run with: python -m legal_mcp.server
from __future__ import annotations from __future__ import annotations
import logging import logging
import os
import sys import sys
from collections.abc import AsyncIterator from collections.abc import AsyncIterator
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
@@ -41,11 +42,28 @@ async def lifespan(server: FastMCP) -> AsyncIterator[None]:
logger.info("Ezer Mishpati MCP server stopped") logger.info("Ezer Mishpati MCP server stopped")
# HTTP listener address, used only when MCP_TRANSPORT selects an HTTP transport.
#
# These MUST be passed to the constructor rather than left to FastMCP's own
# FASTMCP_HOST / FASTMCP_PORT environment settings: FastMCP.__init__ declares
# `host: str = "127.0.0.1"` and `port: int = 8000` as explicit keyword defaults
# and forwards them into Settings(**settings). In pydantic-settings, explicit
# init kwargs outrank environment variables — so FASTMCP_PORT is silently
# ignored and the server binds 8000 regardless (verified 2026-08-05; on this
# host 8000 is already taken, so it failed loudly by luck rather than design).
#
# Default to loopback: this transport is unauthenticated until #231.2 lands and
# the registry contains destructive tools.
MCP_HTTP_HOST = os.environ.get("MCP_HTTP_HOST", "127.0.0.1")
MCP_HTTP_PORT = int(os.environ.get("MCP_HTTP_PORT", "8790"))
# Create MCP server # Create MCP server
mcp = FastMCP( mcp = FastMCP(
"Ezer Mishpati - עוזר משפטי", "Ezer Mishpati - עוזר משפטי",
instructions="מערכת AI לסיוע בניסוח החלטות משפטיות בסגנון דפנה תמיר", instructions="מערכת AI לסיוע בניסוח החלטות משפטיות בסגנון דפנה תמיר",
lifespan=lifespan, lifespan=lifespan,
host=MCP_HTTP_HOST,
port=MCP_HTTP_PORT,
) )
# ── Import and register tools ─────────────────────────────────────── # ── Import and register tools ───────────────────────────────────────
@@ -1260,7 +1278,44 @@ async def corroboration_rebuild(case_law_id: str = "") -> dict:
def main(): def main():
mcp.run(transport="stdio") """Run the server on the transport named by ``MCP_TRANSPORT`` (default stdio).
ONE server, two transports — deliberately not a second implementation (G2).
The tool registry, services and DB pool above are shared verbatim; only the
wire protocol differs, so a tool can never exist on one transport and not
the other.
- ``stdio`` (default) — the historical path. Every interactive Claude Code
session reaches us this way via the ``legal-ai`` entry in ``~/.claude.json``.
Changing this default would break them all, so it stays the default.
- ``streamable-http`` — required by the agent-platform port. Agents driven
over the Agent Client Protocol receive their MCP servers from the *client*
at session start, and that channel accepts HTTP servers only: each is
injected as ``{type:"http", name, url, headers:[Bearer]}``. A stdio server
has no path into such a session at all, which is why platform-driven agents
lost all 108 tools when their execution engine changed — TaskMaster #231.
Platform-side wiring lives behind the port (``web/agent_platform_port.py``),
not here; this module only has to be reachable over HTTP.
Host/port come from FastMCP's own ``FASTMCP_HOST`` / ``FASTMCP_PORT`` settings.
Bind to loopback only: this transport carries no authentication yet, and the
registry includes destructive tools (``case_delete``, ``precedent_library_delete``).
The Bearer gate is #231.2 and MUST land before this is reachable off-host.
"""
transport = os.environ.get("MCP_TRANSPORT", "stdio").strip() or "stdio"
valid = ("stdio", "sse", "streamable-http")
if transport not in valid:
# Fail loudly — a typo must not silently fall back to stdio and leave
# the HTTP listener absent while everything "looks" fine (§6).
raise SystemExit(
f"MCP_TRANSPORT={transport!r} is not one of {valid}",
)
if transport != "stdio":
logger.info(
"Serving MCP over %s on %s:%s",
transport, mcp.settings.host, mcp.settings.port,
)
mcp.run(transport=transport)
if __name__ == "__main__": if __name__ == "__main__":