feat(mcp): תחבורת streamable-http לצד stdio — פותח את הדרך להזרקת השרת לסשני ACP (#231.1)
סוכני הפלטפורמה מקבלים את שרתי ה-MCP שלהם מהלקוח בפתיחת הסשן, והערוץ
הזה מקבל שרתי HTTP בלבד: כל שרת מוזרק כ-
{type:"http", name, url, headers:[Bearer]}. לשרת stdio אין מסלול לשם
בכלל — ומכאן שכל 108 הכלים נעדרו מהסוכנים מאז שמנוע-ההרצה שלהם השתנה.
שרת אחד, שתי תחבורות — לא מימוש שני (G2). מרשם-הכלים, השירותים ובריכת
ה-DB משותפים מילולית; רק פרוטוקול-החוט משתנה, כך שכלי לא יכול להתקיים
בתחבורה אחת ולא באחרת.
- MCP_TRANSPORT בוחר תחבורה, ברירת-מחדל stdio. ערך לא-חוקי נכשל ברעש
(SystemExit) ולא נופל בשקט חזרה ל-stdio — אחרת ה-listener נעדר בעוד
שהכול "נראה" תקין (כלל-הנדסה §6).
- stdio נשאר ברירת-המחדל: כל סשן אינטראקטיבי מגיע דרכו מ-~/.claude.json.
מלכודת שהתגלתה בהרצה: אי-אפשר להסתמך על FASTMCP_HOST/FASTMCP_PORT.
ל-FastMCP.__init__ יש ברירות-מחדל מפורשות (host="127.0.0.1", port=8000)
שמועברות ל-Settings(**settings), ובפידנטיק ארגומנטים מפורשים גוברים על
env — כך ש-FASTMCP_PORT מתעלמים ממנו והשרת נקשר ל-8000 בכל מקרה. על
המכונה הזו 8000 תפוס, כך שזה נכשל ברעש במקרה ולא בזכות תכנון. לכן
MCP_HTTP_HOST/MCP_HTTP_PORT נקראים אצלנו ומועברים לבנאי.
ברירת-מחדל loopback (127.0.0.1:8790): התחבורה עדיין ללא אימות, ובמרשם
יש כלים הרסניים (case_delete, precedent_library_delete). שער ה-Bearer
הוא #231.2 והוא חייב לנחות לפני חשיפה מחוץ למארח.
אומת end-to-end: claude התחבר ל-http://127.0.0.1:8791/mcp וקיבל 108
כלים בשם mcp__legal-ai__case_get — זהה בדיוק ל-stdio. כלומר 66 ההענקות,
הוראות-הסוכנים, web/paperclip_client.py ושער INV-AG3 ממשיכים לעבוד.
invariants: G2 — מקיים (שרת אחד, שתי תחבורות, מרשם משותף). G12 — מקיים;
חיווט צד-הפלטפורמה נשאר מאחורי web/agent_platform_port.py, והמודול הזה
נותר נקי מסמלי-פלטפורמה (leak_guard ירוק). INV-AG3 — לא נגוע, השער ירוק.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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__":
|
||||||
|
|||||||
Reference in New Issue
Block a user