From 81ea43a70a7c1b7fb5dce0e34f1cd7f2522cde01 Mon Sep 17 00:00:00 2001 From: Chaim Date: Wed, 5 Aug 2026 07:40:05 +0000 Subject: [PATCH] =?UTF-8?q?feat(mcp):=20=D7=AA=D7=97=D7=91=D7=95=D7=A8?= =?UTF-8?q?=D7=AA=20streamable-http=20=D7=9C=D7=A6=D7=93=20stdio=20?= =?UTF-8?q?=E2=80=94=20=D7=A4=D7=95=D7=AA=D7=97=20=D7=90=D7=AA=20=D7=94?= =?UTF-8?q?=D7=93=D7=A8=D7=9A=20=D7=9C=D7=94=D7=96=D7=A8=D7=A7=D7=AA=20?= =?UTF-8?q?=D7=94=D7=A9=D7=A8=D7=AA=20=D7=9C=D7=A1=D7=A9=D7=A0=D7=99=20ACP?= =?UTF-8?q?=20(#231.1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit סוכני הפלטפורמה מקבלים את שרתי ה-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 --- mcp-server/src/legal_mcp/server.py | 57 +++++++++++++++++++++++++++++- 1 file changed, 56 insertions(+), 1 deletion(-) diff --git a/mcp-server/src/legal_mcp/server.py b/mcp-server/src/legal_mcp/server.py index 2966297..e2c482a 100644 --- a/mcp-server/src/legal_mcp/server.py +++ b/mcp-server/src/legal_mcp/server.py @@ -6,6 +6,7 @@ Run with: python -m legal_mcp.server from __future__ import annotations import logging +import os import sys from collections.abc import AsyncIterator from contextlib import asynccontextmanager @@ -41,11 +42,28 @@ async def lifespan(server: FastMCP) -> AsyncIterator[None]: 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 mcp = FastMCP( "Ezer Mishpati - עוזר משפטי", instructions="מערכת AI לסיוע בניסוח החלטות משפטיות בסגנון דפנה תמיר", lifespan=lifespan, + host=MCP_HTTP_HOST, + port=MCP_HTTP_PORT, ) # ── Import and register tools ─────────────────────────────────────── @@ -1260,7 +1278,44 @@ async def corroboration_rebuild(case_law_id: str = "") -> dict: 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__":