diff --git a/mcp_cloud/app.py b/mcp_cloud/app.py index dda4648d..709de885 100644 --- a/mcp_cloud/app.py +++ b/mcp_cloud/app.py @@ -7,16 +7,13 @@ """ import asyncio -from mcp.server.stdio import stdio_server - -# -- db_setup: Flask app, DB, constants, request classes, MCP Server ---------- +# -- db_setup: Flask app, DB, constants, request classes --------------------- from mcp_cloud.db_setup import ( # noqa: F401 app, db, build_postgres_uri_from_env, ensure_planitem_stop_columns, PLANEXE_SERVER_INSTRUCTIONS, - mcp_cloud_server as mcp_cloud, WORKER_PLAN_URL, REPORT_FILENAME, REPORT_CONTENT_TYPE, @@ -178,12 +175,17 @@ async def main(): db.create_all() logger.info("Database initialized") - async with stdio_server() as streams: - await mcp_cloud.run( - streams[0], - streams[1], - mcp_cloud.create_initialization_options() - ) + # Imported here rather than at module scope: route_registration reaches + # back into this module, so a top-level import would be circular. + from mcp.server.mcpserver import MCPServer + from mcp_cloud.route_registration import register_tools_and_prompts + + server = MCPServer( + name="planexe-mcp-server", + instructions=PLANEXE_SERVER_INSTRUCTIONS, + ) + register_tools_and_prompts(server) + await server.run_stdio_async() if __name__ == "__main__": asyncio.run(main()) diff --git a/mcp_cloud/db_setup.py b/mcp_cloud/db_setup.py index 692aa133..b0121f32 100644 --- a/mcp_cloud/db_setup.py +++ b/mcp_cloud/db_setup.py @@ -16,7 +16,6 @@ def _startup_log(msg: str) -> None: _startup_log("db_setup.py: begin imports") from flask import Flask -from mcp.server import Server from pydantic import BaseModel from sqlalchemy import inspect, text from worker_plan_api.model_profile import ModelProfileEnum @@ -282,8 +281,6 @@ def ensure_last_progress_at_column() -> None: "New users: create an account and obtain an API key at https://home.planexe.org/ ." ) -mcp_cloud_server = Server("planexe-mcp-cloud", instructions=PLANEXE_SERVER_INSTRUCTIONS) - WORKER_PLAN_URL = os.environ.get("PLANEXE_WORKER_PLAN_URL", "http://worker_plan:8000") REPORT_FILENAME = "report.html" diff --git a/mcp_cloud/handlers.py b/mcp_cloud/handlers.py index 0c76218d..10ce45bc 100644 --- a/mcp_cloud/handlers.py +++ b/mcp_cloud/handlers.py @@ -26,7 +26,6 @@ PlanListRequest, SendFeedbackRequest, ModelProfilesRequest, - mcp_cloud_server, ) from mcp_cloud.auth import _resolve_user_from_api_key from mcp_cloud.db_queries import ( @@ -60,7 +59,6 @@ logger = logging.getLogger(__name__) -@mcp_cloud_server.list_tools() async def handle_list_tools() -> list[Tool]: """List all available MCP tools.""" return [ @@ -74,7 +72,6 @@ async def handle_list_tools() -> list[Tool]: for definition in TOOL_DEFINITIONS ] -@mcp_cloud_server.call_tool() async def handle_call_tool(name: str, arguments: dict[str, Any]) -> CallToolResult: """Dispatch MCP tool calls and return structured JSON errors for unknown tools.""" start = time.monotonic() @@ -90,7 +87,7 @@ async def handle_call_tool(name: str, arguments: dict[str, Any]) -> CallToolResu ) result = await handler(arguments) elapsed_ms = (time.monotonic() - start) * 1000 - if result.isError: + if result.is_error: logger.info("tool_call tool=%s result=error duration_ms=%.0f", name, elapsed_ms) else: logger.info("tool_call tool=%s result=ok duration_ms=%.0f", name, elapsed_ms) diff --git a/mcp_cloud/http_server.py b/mcp_cloud/http_server.py index b0255b5e..87e78a5f 100644 --- a/mcp_cloud/http_server.py +++ b/mcp_cloud/http_server.py @@ -2,10 +2,10 @@ PlanExe MCP Cloud — HTTP server (backward-compatibility re-export shim) All implementation has moved to focused modules: -- server_boot.py — config, FastMCP/FastAPI creation, lifespan, entry point +- server_boot.py — config, MCP/FastAPI creation, lifespan, entry point - middleware.py — CORS, auth, rate limiting, body size, enforce_api_key - tool_http_bridge.py — request/response models, result normalization, tool wrappers -- route_registration.py — FastMCP tool registration, MCP prompts, route handlers +- route_registration.py — MCP tool registration, MCP prompts, route handlers This module re-exports public symbols so that existing tests and the Dockerfile entry point (``python -m mcp_cloud.http_server``) continue @@ -30,7 +30,7 @@ _split_csv_env, _startup_log, app, - fastmcp_server, + mcp_server, ) # --- middleware exports (auth, CORS, rate limiting) ----------------------- diff --git a/mcp_cloud/middleware.py b/mcp_cloud/middleware.py index ada39208..5864cd5a 100644 --- a/mcp_cloud/middleware.py +++ b/mcp_cloud/middleware.py @@ -546,7 +546,7 @@ class _NormalizeMcpPath: Smithery (and possibly other registries) POST to ``/mcp`` but refuse to follow 307 redirects. By rewriting the path *before* routing, the mounted - FastMCP sub-app receives the request directly — no HTTP redirect needed. + MCP sub-app receives the request directly — no HTTP redirect needed. """ def __init__(self, app: Any) -> None: @@ -656,6 +656,6 @@ def apply_middleware(app: Any) -> None: """Register middleware on the FastAPI app. Called by server_boot after routes are mounted.""" app.middleware("http")(enforce_api_key) # Rewrite /mcp -> /mcp/ at the ASGI level so clients that refuse to follow - # 307 redirects (e.g. Smithery) still reach the mounted FastMCP app. + # 307 redirects (e.g. Smithery) still reach the mounted MCP app. # Added last so it becomes the outermost middleware (runs first). app.add_middleware(_NormalizeMcpPath) diff --git a/mcp_cloud/route_registration.py b/mcp_cloud/route_registration.py index f0ce457f..231f3400 100644 --- a/mcp_cloud/route_registration.py +++ b/mcp_cloud/route_registration.py @@ -1,7 +1,7 @@ """ PlanExe MCP Cloud — route registration -FastMCP tool registration, MCP prompts, and all FastAPI route handlers. +MCP tool registration, MCP prompts, and all FastAPI route handlers. Called by ``server_boot.py`` during application assembly. """ import asyncio @@ -11,7 +11,7 @@ from fastapi import Depends, FastAPI, HTTPException, Request, Response from fastapi.responses import FileResponse, JSONResponse, RedirectResponse, StreamingResponse -from mcp.server.fastmcp import FastMCP +from mcp.server.mcpserver import MCPServer from mcp.types import ToolAnnotations from mcp_cloud.app import ( @@ -49,9 +49,9 @@ # --------------------------------------------------------------------------- -# FastMCP tool registration +# MCP tool registration # --------------------------------------------------------------------------- -def _register_tools(server: FastMCP) -> None: +def _register_tools(server: MCPServer) -> None: handler_map = { "example_plans": example_plans, "example_prompts": example_prompts, @@ -77,7 +77,7 @@ def _register_tools(server: FastMCP) -> None: )(handler) # Inject the canonical outputSchema from TOOL_DEFINITIONS into each - # FastMCP tool so that list_tools advertises the schema we control. + # registered tool so that list_tools advertises the schema we control. # # We set the schema as an instance attribute on the Tool, which shadows # the cached_property (Tool.output_schema reads fn_metadata.output_schema). @@ -95,16 +95,16 @@ def _register_tools(server: FastMCP) -> None: continue if "oneOf" in schema: continue - fastmcp_tool = server._tool_manager.get_tool(tool_def.name) - if fastmcp_tool is None: + registered_tool = server._tool_manager.get_tool(tool_def.name) + if registered_tool is None: continue - fastmcp_tool.__dict__["output_schema"] = schema + registered_tool.__dict__["output_schema"] = schema # --------------------------------------------------------------------------- # MCP Prompts # --------------------------------------------------------------------------- -def _register_prompts(server: FastMCP) -> None: +def _register_prompts(server: MCPServer) -> None: @server.prompt() def getting_started() -> str: """Quick-start guide for using PlanExe to create a project plan.""" @@ -148,8 +148,8 @@ def plan_a_project(topic: str, location: str = "") -> str: ) -def register_tools_and_prompts(server: FastMCP) -> None: - """Register all MCP tools and prompts on the FastMCP server.""" +def register_tools_and_prompts(server: MCPServer) -> None: + """Register all MCP tools and prompts on the MCP server.""" _register_tools(server) _register_prompts(server) @@ -169,7 +169,7 @@ async def options_mcp() -> Response: async def head_mcp_trailing_slash() -> Response: """Handle HEAD /mcp/ for health-check probes (e.g. Smithery scanner). - The mounted FastMCP Streamable HTTP app does not support HEAD and returns + The mounted Streamable HTTP app does not support HEAD and returns 405. This explicit route intercepts the request so scanners get a clean 200 instead of bouncing off the sub-app. """ @@ -184,14 +184,14 @@ async def head_mcp_trailing_slash() -> Response: # --------------------------------------------------------------------------- def register_routes( app: FastAPI, - fastmcp_server: FastMCP, - get_fastmcp: Callable[..., FastMCP], + mcp_server: MCPServer, + get_mcp_server: Callable[..., MCPServer], ) -> None: """Register all HTTP route handlers on the FastAPI app. Must be called before ``app.mount("/mcp", ...)`` so that explicit routes like ``/mcp/tools`` and ``/mcp/tools/call`` take priority over the mounted - FastMCP sub-app. + MCP sub-app. """ from mcp_cloud.server_boot import ( AUTH_REQUIRED, @@ -211,7 +211,7 @@ def register_routes( @app.post("/mcp/tools/call", response_model=MCPToolCallResponse) async def call_tool( payload: MCPToolCallRequest, - fastmcp_server: FastMCP = Depends(get_fastmcp), + mcp_server: MCPServer = Depends(get_mcp_server), ) -> MCPToolCallResponse: """ Call an MCP tool by name with arguments. @@ -232,25 +232,25 @@ async def call_tool( content, error = _normalize_tool_result(result) return MCPToolCallResponse(content=content, error=error) - return await call_tool_via_registry(fastmcp_server, payload.tool, arguments) + return await call_tool_via_registry(mcp_server, payload.tool, arguments) # -- Tools list endpoint ----------------------------------------------- @app.get("/mcp/tools") - async def list_tools(fastmcp_server: FastMCP = Depends(get_fastmcp)) -> dict[str, Any]: + async def list_tools(mcp_server: MCPServer = Depends(get_mcp_server)) -> dict[str, Any]: """List all available MCP tools.""" - tools = await fastmcp_server.list_tools() + tools = await mcp_server.list_tools() sanitized = [] for tool in tools: tool_entry = { "name": tool.name, "description": tool.description, - "inputSchema": tool.inputSchema, + "inputSchema": tool.input_schema, } if tool.title: tool_entry["title"] = tool.title - if tool.outputSchema: - tool_entry["outputSchema"] = tool.outputSchema + if tool.output_schema: + tool_entry["outputSchema"] = tool.output_schema if tool.annotations: tool_entry["annotations"] = tool.annotations if tool.icons: diff --git a/mcp_cloud/server_boot.py b/mcp_cloud/server_boot.py index a76216f4..a998ae1b 100644 --- a/mcp_cloud/server_boot.py +++ b/mcp_cloud/server_boot.py @@ -1,7 +1,7 @@ """ PlanExe MCP Cloud — server bootstrap -Configuration constants, FastMCP / FastAPI creation, and application lifespan. +Configuration constants, MCP / FastAPI creation, and application lifespan. This module is the canonical source for all env-var-derived settings. """ import asyncio @@ -27,7 +27,8 @@ def _startup_log(msg: str) -> None: from fastapi import FastAPI, HTTPException, Request from fastapi.middleware.cors import CORSMiddleware -from mcp.server.fastmcp import FastMCP +from mcp.server.mcpserver import MCPServer +from mcp.server.transport_security import TransportSecuritySettings _startup_log("server_boot.py: 3rd-party imports done") @@ -146,35 +147,44 @@ def _split_csv_env(value: Optional[str]) -> list[str]: # --------------------------------------------------------------------------- -# FastMCP server creation +# MCP server creation # --------------------------------------------------------------------------- -_startup_log("server_boot.py: creating FastMCP server") +_startup_log("server_boot.py: creating MCP server") -fastmcp_server = FastMCP( +mcp_server = MCPServer( name="planexe-mcp-server", instructions=PLANEXE_SERVER_INSTRUCTIONS, - host=HTTP_HOST, - port=HTTP_PORT, - streamable_http_path="/", - json_response=True, - stateless_http=True, + version=SERVER_VERSION, ) # Tool registration and MCP prompts are applied by route_registration. from mcp_cloud.route_registration import register_routes, register_tools_and_prompts -register_tools_and_prompts(fastmcp_server) - -fastmcp_http_app = fastmcp_server.streamable_http_app() +register_tools_and_prompts(mcp_server) + +# Transport settings live on streamable_http_app() rather than the server. +# +# transport_security is set explicitly because the SDK turns on DNS rebinding +# protection by itself when host is a loopback address, restricting Host and +# Origin to localhost. This server is reached over the LAN in development +# (see mcp_cloud/README.md) and through the Railway proxy in production, so the +# implicit localhost-only allowlist would reject legitimate clients. Access is +# controlled by the API-key middleware and the CORS origins configured above. +mcp_http_app = mcp_server.streamable_http_app( + streamable_http_path="/", + json_response=True, + stateless_http=True, + transport_security=TransportSecuritySettings(enable_dns_rebinding_protection=False), +) # --------------------------------------------------------------------------- # FastAPI dependency # --------------------------------------------------------------------------- -def _get_fastmcp(request: Request) -> FastMCP: - fastmcp_server = getattr(request.app.state, "fastmcp_server", None) - if fastmcp_server is None: +def _get_mcp_server(request: Request) -> MCPServer: + mcp_server = getattr(request.app.state, "mcp_server", None) + if mcp_server is None: raise HTTPException(status_code=503, detail="mcp_cloud not initialized") - return fastmcp_server + return mcp_server # --------------------------------------------------------------------------- @@ -183,11 +193,11 @@ def _get_fastmcp(request: Request) -> FastMCP: @asynccontextmanager async def _lifespan(app: FastAPI): from mcp_cloud.middleware import _sweep_rate_buckets - app.state.fastmcp_server = fastmcp_server + app.state.mcp_server = mcp_server stop_event = asyncio.Event() sweeper_task = asyncio.create_task(_sweep_rate_buckets(stop_event)) try: - async with fastmcp_server.session_manager.run(): + async with mcp_server.session_manager.run(): yield finally: stop_event.set() @@ -214,14 +224,14 @@ async def _lifespan(app: FastAPI): allow_headers=["*"], # Allow any header (e.g. X-API-Key) for CORS preflight ) -# Register all routes (must happen before mounting FastMCP sub-app). -register_routes(app, fastmcp_server, _get_fastmcp) +# Register all routes (must happen before mounting the MCP sub-app). +register_routes(app, mcp_server, _get_mcp_server) # Mount the Streamable HTTP MCP endpoint AFTER the explicit /mcp/tools and # /mcp/tools/call routes so that those routes take priority. Starlette checks # routes in registration order; if the mount were first it would shadow the # REST endpoints with a 404 from the sub-app. -app.mount("/mcp", fastmcp_http_app) +app.mount("/mcp", mcp_http_app) # Apply middleware from middleware.py. from mcp_cloud.middleware import apply_middleware diff --git a/mcp_cloud/tests/test_model_profiles_tool.py b/mcp_cloud/tests/test_model_profiles_tool.py index 4838c7b0..6e71ba7c 100644 --- a/mcp_cloud/tests/test_model_profiles_tool.py +++ b/mcp_cloud/tests/test_model_profiles_tool.py @@ -36,10 +36,10 @@ def test_model_profiles_returns_structured_content(self): with patch("mcp_cloud.handlers._get_model_profiles_sync", return_value=payload): result = asyncio.run(handle_model_profiles({})) - self.assertFalse(result.isError) - self.assertEqual(result.structuredContent["default_profile"], "baseline") - self.assertEqual(result.structuredContent["profiles"][0]["profile"], "baseline") - self.assertNotIn("available", result.structuredContent["profiles"][0]) + self.assertFalse(result.is_error) + self.assertEqual(result.structured_content["default_profile"], "baseline") + self.assertEqual(result.structured_content["profiles"][0]["profile"], "baseline") + self.assertNotIn("available", result.structured_content["profiles"][0]) def test_model_profiles_returns_error_when_none_available(self): payload = { @@ -51,8 +51,8 @@ def test_model_profiles_returns_error_when_none_available(self): with patch("mcp_cloud.handlers._get_model_profiles_sync", return_value=payload): result = asyncio.run(handle_model_profiles({})) - self.assertTrue(result.isError) - self.assertEqual(result.structuredContent["error"]["code"], "MODEL_PROFILES_UNAVAILABLE") + self.assertTrue(result.is_error) + self.assertEqual(result.structured_content["error"]["code"], "MODEL_PROFILES_UNAVAILABLE") if __name__ == "__main__": diff --git a/mcp_cloud/tests/test_plan_create_tool.py b/mcp_cloud/tests/test_plan_create_tool.py index aa73bcd0..3f5bd6f1 100644 --- a/mcp_cloud/tests/test_plan_create_tool.py +++ b/mcp_cloud/tests/test_plan_create_tool.py @@ -24,7 +24,7 @@ class TestPlanCreateTool(unittest.TestCase): def test_plan_create_visible_schema_exposes_prompt_and_model_profile(self): tools = asyncio.run(handle_list_tools()) plan_create_tool = next(tool for tool in tools if tool.name == "plan_create") - properties = plan_create_tool.inputSchema.get("properties", {}) + properties = plan_create_tool.input_schema.get("properties", {}) self.assertIn("prompt", properties) self.assertIn("model_profile", properties) @@ -42,12 +42,12 @@ def test_plan_create_returns_structured_content(self): result = asyncio.run(handle_plan_create(arguments)) self.assertIsInstance(result, CallToolResult) - self.assertIsInstance(result.structuredContent, dict) - self.assertIn("plan_id", result.structuredContent) - self.assertIn("created_at", result.structuredContent) - self.assertIsInstance(uuid.UUID(result.structuredContent["plan_id"]), uuid.UUID) + self.assertIsInstance(result.structured_content, dict) + self.assertIn("plan_id", result.structured_content) + self.assertIn("created_at", result.structured_content) + self.assertIsInstance(uuid.UUID(result.structured_content["plan_id"]), uuid.UUID) # New plan should not have deduplicated key - self.assertNotIn("deduplicated", result.structuredContent) + self.assertNotIn("deduplicated", result.structured_content) def test_plan_create_dedup_returns_existing_plan(self): """When _find_recent_duplicate_plan returns a plan, plan_create returns it with deduplicated=True.""" @@ -70,9 +70,9 @@ def test_plan_create_dedup_returns_existing_plan(self): result = asyncio.run(handle_plan_create(arguments)) self.assertIsInstance(result, CallToolResult) - self.assertIsInstance(result.structuredContent, dict) - self.assertEqual(result.structuredContent["plan_id"], str(existing_id)) - self.assertTrue(result.structuredContent["deduplicated"]) + self.assertIsInstance(result.structured_content, dict) + self.assertEqual(result.structured_content["plan_id"], str(existing_id)) + self.assertTrue(result.structured_content["deduplicated"]) def test_find_recent_duplicate_plan_returns_none_when_window_zero(self): """Opt-out: window_seconds=0 always returns None.""" diff --git a/mcp_cloud/tests/test_plan_feedback_tool.py b/mcp_cloud/tests/test_plan_feedback_tool.py index 77efd771..b5701dd7 100644 --- a/mcp_cloud/tests/test_plan_feedback_tool.py +++ b/mcp_cloud/tests/test_plan_feedback_tool.py @@ -21,10 +21,10 @@ def test_feedback_success_minimal(self): })) self.assertIsInstance(result, CallToolResult) - self.assertFalse(result.isError) - self.assertIn("feedback_id", result.structuredContent) - self.assertIn("received_at", result.structuredContent) - self.assertEqual(result.structuredContent["message"], "Feedback received. Thank you.") + self.assertFalse(result.is_error) + self.assertIn("feedback_id", result.structured_content) + self.assertIn("received_at", result.structured_content) + self.assertEqual(result.structured_content["message"], "Feedback received. Thank you.") def test_feedback_success_all_fields(self): """Feedback with all optional fields succeeds.""" @@ -43,8 +43,8 @@ def test_feedback_success_all_fields(self): "rating": 3, })) - self.assertFalse(result.isError) - self.assertIn("feedback_id", result.structuredContent) + self.assertFalse(result.is_error) + self.assertIn("feedback_id", result.structured_content) def test_feedback_invalid_category(self): """Invalid category returns INVALID_FEEDBACK error.""" @@ -53,8 +53,8 @@ def test_feedback_invalid_category(self): "message": "test", })) - self.assertTrue(result.isError) - self.assertEqual(result.structuredContent["error"]["code"], "INVALID_FEEDBACK") + self.assertTrue(result.is_error) + self.assertEqual(result.structured_content["error"]["code"], "INVALID_FEEDBACK") def test_feedback_missing_message(self): """Missing required message field returns INVALID_FEEDBACK error.""" @@ -62,8 +62,8 @@ def test_feedback_missing_message(self): "category": "mcp", })) - self.assertTrue(result.isError) - self.assertEqual(result.structuredContent["error"]["code"], "INVALID_FEEDBACK") + self.assertTrue(result.is_error) + self.assertEqual(result.structured_content["error"]["code"], "INVALID_FEEDBACK") def test_feedback_plan_not_found(self): """plan_id that doesn't exist returns PLAN_NOT_FOUND error.""" @@ -74,8 +74,8 @@ def test_feedback_plan_not_found(self): "plan_id": "nonexistent-uuid", })) - self.assertTrue(result.isError) - self.assertEqual(result.structuredContent["error"]["code"], "PLAN_NOT_FOUND") + self.assertTrue(result.is_error) + self.assertEqual(result.structured_content["error"]["code"], "PLAN_NOT_FOUND") def test_feedback_rating_out_of_range(self): """Rating outside 1-5 returns INVALID_FEEDBACK error.""" @@ -85,8 +85,8 @@ def test_feedback_rating_out_of_range(self): "rating": 10, })) - self.assertTrue(result.isError) - self.assertEqual(result.structuredContent["error"]["code"], "INVALID_FEEDBACK") + self.assertTrue(result.is_error) + self.assertEqual(result.structured_content["error"]["code"], "INVALID_FEEDBACK") def test_feedback_db_failure_returns_success(self): """DB write failure is logged but success is returned (fire-and-forget).""" @@ -96,9 +96,9 @@ def test_feedback_db_failure_returns_success(self): "message": "test feedback", })) - self.assertFalse(result.isError) - self.assertIn("feedback_id", result.structuredContent) - self.assertEqual(result.structuredContent["message"], "Feedback received. Thank you.") + self.assertFalse(result.is_error) + self.assertIn("feedback_id", result.structured_content) + self.assertEqual(result.structured_content["message"], "Feedback received. Thank you.") def test_feedback_all_categories_accepted(self): """All 4 defined categories are accepted.""" @@ -109,7 +109,7 @@ def test_feedback_all_categories_accepted(self): "category": category, "message": f"Test {category}", })) - self.assertFalse(result.isError, f"Category {category} should be accepted") + self.assertFalse(result.is_error, f"Category {category} should be accepted") def test_feedback_invalid_user_api_key(self): """Invalid user_api_key returns INVALID_USER_API_KEY error.""" @@ -120,8 +120,8 @@ def test_feedback_invalid_user_api_key(self): "user_api_key": "pex_bad_key", })) - self.assertTrue(result.isError) - self.assertEqual(result.structuredContent["error"]["code"], "INVALID_USER_API_KEY") + self.assertTrue(result.is_error) + self.assertEqual(result.structured_content["error"]["code"], "INVALID_USER_API_KEY") def test_feedback_requires_key_when_env_set(self): """When PLANEXE_MCP_REQUIRE_USER_KEY is true, missing key returns error.""" @@ -131,8 +131,8 @@ def test_feedback_requires_key_when_env_set(self): "message": "test", })) - self.assertTrue(result.isError) - self.assertEqual(result.structuredContent["error"]["code"], "USER_API_KEY_REQUIRED") + self.assertTrue(result.is_error) + self.assertEqual(result.structured_content["error"]["code"], "USER_API_KEY_REQUIRED") def test_feedback_no_key_when_not_required(self): """When key is not required and not provided, feedback succeeds.""" @@ -143,7 +143,7 @@ def test_feedback_no_key_when_not_required(self): "message": "test", })) - self.assertFalse(result.isError) + self.assertFalse(result.is_error) def test_feedback_passes_user_id_from_api_key(self): """Valid user_api_key resolves user_id and passes it to _create_feedback_sync.""" diff --git a/mcp_cloud/tests/test_plan_file_info_tool.py b/mcp_cloud/tests/test_plan_file_info_tool.py index 11cfead3..bfb32ada 100644 --- a/mcp_cloud/tests/test_plan_file_info_tool.py +++ b/mcp_cloud/tests/test_plan_file_info_tool.py @@ -53,7 +53,7 @@ def test_report_read_defaults_to_metadata(self): ): result = asyncio.run(handle_plan_file_info({"plan_id": plan_id})) - payload = result.structuredContent + payload = result.structured_content self.assertEqual(payload["download_size"], len(content_bytes)) self.assertEqual(payload["content_type"], "text/html; charset=utf-8") self.assertNotIn("download_path", payload) @@ -75,7 +75,7 @@ def test_report_read_zip(self): ): result = asyncio.run(handle_plan_file_info({"plan_id": plan_id, "artifact": "zip"})) - payload = result.structuredContent + payload = result.structured_content self.assertEqual(payload["download_size"], len(content_bytes)) self.assertEqual(payload["content_type"], ZIP_CONTENT_TYPE) @@ -94,7 +94,7 @@ def test_report_read_zip_for_failed_task(self): ): result = asyncio.run(handle_plan_file_info({"plan_id": plan_id, "artifact": "zip"})) - payload = result.structuredContent + payload = result.structured_content self.assertEqual(payload["download_size"], len(content_bytes)) self.assertEqual(payload["content_type"], ZIP_CONTENT_TYPE) @@ -108,8 +108,8 @@ def test_plan_file_info_returns_empty_object_when_pending(self): with patch("mcp_cloud.handlers._get_plan_for_report_sync", return_value=plan_snapshot): result = asyncio.run(handle_plan_file_info({"plan_id": plan_id})) - self.assertFalse(result.isError) - self.assertEqual(result.structuredContent, {"ready": False, "reason": "processing"}) + self.assertFalse(result.is_error) + self.assertEqual(result.structured_content, {"ready": False, "reason": "processing"}) def test_plan_file_info_not_ready_preserves_structured_content(self): """Not-ready responses must carry structuredContent so MCP clients see the payload.""" @@ -122,8 +122,8 @@ def test_plan_file_info_not_ready_preserves_structured_content(self): with patch("mcp_cloud.handlers._get_plan_for_report_sync", return_value=plan_snapshot): result = asyncio.run(handle_plan_file_info({"plan_id": plan_id, "artifact": "report"})) - self.assertFalse(result.isError) - sc = result.structuredContent + self.assertFalse(result.is_error) + sc = result.structured_content self.assertIsNotNone(sc, "structuredContent must be present for not-ready responses") self.assertFalse(sc["ready"]) self.assertEqual(sc["reason"], "processing") @@ -143,8 +143,8 @@ def test_plan_file_info_pending_zip_preserves_structured_content(self): ): result = asyncio.run(handle_plan_file_info({"plan_id": plan_id, "artifact": "zip"})) - self.assertFalse(result.isError) - sc = result.structuredContent + self.assertFalse(result.is_error) + sc = result.structured_content self.assertIsNotNone(sc) self.assertFalse(sc["ready"]) self.assertEqual(sc["reason"], "processing") @@ -155,8 +155,8 @@ def test_plan_file_info_not_found_has_structured_content(self): with patch("mcp_cloud.handlers._get_plan_for_report_sync", return_value=None): result = asyncio.run(handle_plan_file_info({"plan_id": plan_id})) - self.assertTrue(result.isError) - sc = result.structuredContent + self.assertTrue(result.is_error) + sc = result.structured_content self.assertIsNotNone(sc, "Error responses must include structuredContent") self.assertEqual(sc["error"]["code"], "PLAN_NOT_FOUND") @@ -172,7 +172,7 @@ def test_plan_file_info_content_mirrors_structured_content(self): with patch("mcp_cloud.handlers._get_plan_for_report_sync", return_value=plan_snapshot): result = asyncio.run(handle_plan_file_info({"plan_id": plan_id})) - sc = result.structuredContent + sc = result.structured_content content_text = result.content[0].text self.assertEqual(json.loads(content_text), sc) @@ -192,8 +192,8 @@ def test_plan_file_info_ready_report_has_structured_content(self): ): result = asyncio.run(handle_plan_file_info({"plan_id": plan_id, "artifact": "report"})) - self.assertFalse(result.isError) - sc = result.structuredContent + self.assertFalse(result.is_error) + sc = result.structured_content self.assertIsNotNone(sc) self.assertIn("content_type", sc) self.assertIn("sha256", sc) @@ -211,8 +211,8 @@ def test_plan_file_info_generation_failed_has_structured_content_with_error(self with patch("mcp_cloud.handlers._get_plan_for_report_sync", return_value=plan_snapshot): result = asyncio.run(handle_plan_file_info({"plan_id": plan_id, "artifact": "report"})) - self.assertFalse(result.isError) - sc = result.structuredContent + self.assertFalse(result.is_error) + sc = result.structured_content self.assertIsNotNone(sc) self.assertEqual(sc["error"]["code"], "generation_failed") self.assertIn("Out of memory", sc["error"]["message"]) @@ -228,8 +228,8 @@ def test_plan_file_info_returns_generation_failed_payload(self): with patch("mcp_cloud.handlers._get_plan_for_report_sync", return_value=plan_snapshot): result = asyncio.run(handle_plan_file_info({"plan_id": plan_id, "artifact": "report"})) - self.assertFalse(result.isError) - self.assertEqual(result.structuredContent["error"]["code"], "generation_failed") + self.assertFalse(result.is_error) + self.assertEqual(result.structured_content["error"]["code"], "generation_failed") def test_report_expires_at_present_when_download_url_set(self): """expires_at must be an ISO 8601 UTC timestamp when download_url is present.""" @@ -251,7 +251,7 @@ def test_report_expires_at_present_when_download_url_set(self): with patch.object(_dt_mod, "_get_download_base_url", return_value="https://example.com"): result = asyncio.run(handle_plan_file_info({"plan_id": plan_id, "artifact": "report"})) - sc = result.structuredContent + sc = result.structured_content self.assertIn("download_url", sc) self.assertIn("expires_at", sc) expires = datetime.fromisoformat(sc["expires_at"]) @@ -277,7 +277,7 @@ def test_zip_expires_at_present_when_download_url_set(self): with patch.object(_dt_mod, "_get_download_base_url", return_value="https://example.com"): result = asyncio.run(handle_plan_file_info({"plan_id": plan_id, "artifact": "zip"})) - sc = result.structuredContent + sc = result.structured_content self.assertIn("download_url", sc) self.assertIn("expires_at", sc) expires = datetime.fromisoformat(sc["expires_at"]) @@ -299,7 +299,7 @@ def test_expires_at_absent_when_no_download_url(self): ): result = asyncio.run(handle_plan_file_info({"plan_id": plan_id})) - sc = result.structuredContent + sc = result.structured_content self.assertNotIn("download_url", sc) self.assertNotIn("expires_at", sc) diff --git a/mcp_cloud/tests/test_plan_list_tool.py b/mcp_cloud/tests/test_plan_list_tool.py index 4d7b46b1..9988c750 100644 --- a/mcp_cloud/tests/test_plan_list_tool.py +++ b/mcp_cloud/tests/test_plan_list_tool.py @@ -35,9 +35,9 @@ def test_plan_list_returns_plans(self): result = asyncio.run(handle_plan_list({"user_api_key": "pex_test", "limit": 10})) self.assertIsInstance(result, CallToolResult) - self.assertFalse(result.isError) - self.assertEqual(len(result.structuredContent["plans"]), 2) - self.assertIn("Returned 2 plan(s)", result.structuredContent["message"]) + self.assertFalse(result.is_error) + self.assertEqual(len(result.structured_content["plans"]), 2) + self.assertIn("Returned 2 plan(s)", result.structured_content["message"]) def test_plan_list_empty_result(self): user_context = {"user_id": "user-1", "credits_balance": 10.0} @@ -45,9 +45,9 @@ def test_plan_list_empty_result(self): patch("mcp_cloud.handlers._list_plans_sync", return_value=[]): result = asyncio.run(handle_plan_list({"user_api_key": "pex_test"})) - self.assertFalse(result.isError) - self.assertEqual(result.structuredContent["plans"], []) - self.assertIn("Returned 0 plan(s)", result.structuredContent["message"]) + self.assertFalse(result.is_error) + self.assertEqual(result.structured_content["plans"], []) + self.assertIn("Returned 0 plan(s)", result.structured_content["message"]) def test_plan_list_clamps_limit(self): """Limit is clamped to [1, 50].""" @@ -66,15 +66,15 @@ def test_plan_list_invalid_user_api_key(self): with patch("mcp_cloud.handlers._resolve_user_from_api_key", return_value=None): result = asyncio.run(handle_plan_list({"user_api_key": "pex_bad"})) - self.assertTrue(result.isError) - self.assertEqual(result.structuredContent["error"]["code"], "INVALID_USER_API_KEY") + self.assertTrue(result.is_error) + self.assertEqual(result.structured_content["error"]["code"], "INVALID_USER_API_KEY") def test_plan_list_requires_key_when_env_set(self): with patch.dict("os.environ", {"PLANEXE_MCP_REQUIRE_USER_KEY": "true"}): result = asyncio.run(handle_plan_list({"limit": 5})) - self.assertTrue(result.isError) - self.assertEqual(result.structuredContent["error"]["code"], "USER_API_KEY_REQUIRED") + self.assertTrue(result.is_error) + self.assertEqual(result.structured_content["error"]["code"], "USER_API_KEY_REQUIRED") def test_plan_list_no_key_when_not_required(self): """When key is not required and not provided, returns all tasks (user_id=None).""" @@ -82,7 +82,7 @@ def test_plan_list_no_key_when_not_required(self): patch("mcp_cloud.handlers._list_plans_sync", return_value=[]) as mock_list: result = asyncio.run(handle_plan_list({"limit": 5})) - self.assertFalse(result.isError) + self.assertFalse(result.is_error) # user_id should be None self.assertIsNone(mock_list.call_args[0][0]) diff --git a/mcp_cloud/tests/test_plan_resume_tool.py b/mcp_cloud/tests/test_plan_resume_tool.py index 87efb83a..0aabb6fa 100644 --- a/mcp_cloud/tests/test_plan_resume_tool.py +++ b/mcp_cloud/tests/test_plan_resume_tool.py @@ -26,11 +26,11 @@ def test_plan_resume_returns_structured_content(self): result = asyncio.run(handle_plan_resume({"plan_id": plan_id})) self.assertIsInstance(result, CallToolResult) - self.assertFalse(result.isError) - self.assertEqual(result.structuredContent["plan_id"], plan_id) - self.assertEqual(result.structuredContent["state"], "pending") - self.assertEqual(result.structuredContent["model_profile"], "baseline") - self.assertEqual(result.structuredContent["resume_count"], 1) + self.assertFalse(result.is_error) + self.assertEqual(result.structured_content["plan_id"], plan_id) + self.assertEqual(result.structured_content["state"], "pending") + self.assertEqual(result.structured_content["model_profile"], "baseline") + self.assertEqual(result.structured_content["resume_count"], 1) def test_plan_resume_includes_sse_url(self): plan_id = str(uuid.uuid4()) @@ -48,16 +48,16 @@ def test_plan_resume_includes_sse_url(self): ): result = asyncio.run(handle_plan_resume({"plan_id": plan_id})) - self.assertFalse(result.isError) - self.assertEqual(result.structuredContent["sse_url"], f"{base_url}/sse/plan/{plan_id}") + self.assertFalse(result.is_error) + self.assertEqual(result.structured_content["sse_url"], f"{base_url}/sse/plan/{plan_id}") def test_plan_resume_returns_plan_not_found(self): plan_id = str(uuid.uuid4()) with patch("mcp_cloud.handlers._resume_plan_sync", return_value=None): result = asyncio.run(handle_plan_resume({"plan_id": plan_id})) - self.assertTrue(result.isError) - self.assertEqual(result.structuredContent["error"]["code"], "PLAN_NOT_FOUND") + self.assertTrue(result.is_error) + self.assertEqual(result.structured_content["error"]["code"], "PLAN_NOT_FOUND") def test_plan_resume_returns_plan_not_resumable(self): plan_id = str(uuid.uuid4()) @@ -65,8 +65,8 @@ def test_plan_resume_returns_plan_not_resumable(self): with patch("mcp_cloud.handlers._resume_plan_sync", return_value=payload): result = asyncio.run(handle_plan_resume({"plan_id": plan_id})) - self.assertTrue(result.isError) - self.assertEqual(result.structuredContent["error"]["code"], "PLAN_NOT_RESUMABLE") + self.assertTrue(result.is_error) + self.assertEqual(result.structured_content["error"]["code"], "PLAN_NOT_RESUMABLE") def test_plan_resume_returns_pipeline_version_mismatch(self): plan_id = str(uuid.uuid4()) @@ -74,8 +74,8 @@ def test_plan_resume_returns_pipeline_version_mismatch(self): with patch("mcp_cloud.handlers._resume_plan_sync", return_value=payload): result = asyncio.run(handle_plan_resume({"plan_id": plan_id})) - self.assertTrue(result.isError) - self.assertEqual(result.structuredContent["error"]["code"], "PIPELINE_VERSION_MISMATCH") + self.assertTrue(result.is_error) + self.assertEqual(result.structured_content["error"]["code"], "PIPELINE_VERSION_MISMATCH") def test_plan_resume_default_model_profile(self): """plan_resume should default model_profile to baseline.""" diff --git a/mcp_cloud/tests/test_plan_retry_tool.py b/mcp_cloud/tests/test_plan_retry_tool.py index e24cd41d..1f841462 100644 --- a/mcp_cloud/tests/test_plan_retry_tool.py +++ b/mcp_cloud/tests/test_plan_retry_tool.py @@ -25,10 +25,10 @@ def test_plan_retry_returns_structured_content(self): result = asyncio.run(handle_plan_retry({"plan_id": plan_id})) self.assertIsInstance(result, CallToolResult) - self.assertFalse(result.isError) - self.assertEqual(result.structuredContent["plan_id"], plan_id) - self.assertEqual(result.structuredContent["state"], "pending") - self.assertEqual(result.structuredContent["model_profile"], "baseline") + self.assertFalse(result.is_error) + self.assertEqual(result.structured_content["plan_id"], plan_id) + self.assertEqual(result.structured_content["state"], "pending") + self.assertEqual(result.structured_content["model_profile"], "baseline") def test_plan_retry_includes_sse_url(self): plan_id = str(uuid.uuid4()) @@ -45,16 +45,16 @@ def test_plan_retry_includes_sse_url(self): ): result = asyncio.run(handle_plan_retry({"plan_id": plan_id})) - self.assertFalse(result.isError) - self.assertEqual(result.structuredContent["sse_url"], f"{base_url}/sse/plan/{plan_id}") + self.assertFalse(result.is_error) + self.assertEqual(result.structured_content["sse_url"], f"{base_url}/sse/plan/{plan_id}") def test_plan_retry_returns_plan_not_found(self): plan_id = str(uuid.uuid4()) with patch("mcp_cloud.handlers._retry_failed_plan_sync", return_value=None): result = asyncio.run(handle_plan_retry({"plan_id": plan_id})) - self.assertTrue(result.isError) - self.assertEqual(result.structuredContent["error"]["code"], "PLAN_NOT_FOUND") + self.assertTrue(result.is_error) + self.assertEqual(result.structured_content["error"]["code"], "PLAN_NOT_FOUND") def test_plan_retry_returns_plan_not_failed(self): plan_id = str(uuid.uuid4()) @@ -62,8 +62,8 @@ def test_plan_retry_returns_plan_not_failed(self): with patch("mcp_cloud.handlers._retry_failed_plan_sync", return_value=payload): result = asyncio.run(handle_plan_retry({"plan_id": plan_id})) - self.assertTrue(result.isError) - self.assertEqual(result.structuredContent["error"]["code"], "PLAN_NOT_FAILED") + self.assertTrue(result.is_error) + self.assertEqual(result.structured_content["error"]["code"], "PLAN_NOT_FAILED") if __name__ == "__main__": diff --git a/mcp_cloud/tests/test_plan_status_tool.py b/mcp_cloud/tests/test_plan_status_tool.py index 112e9c4c..2823ae44 100644 --- a/mcp_cloud/tests/test_plan_status_tool.py +++ b/mcp_cloud/tests/test_plan_status_tool.py @@ -28,12 +28,12 @@ def test_plan_status_returns_structured_content(self): result = asyncio.run(handle_plan_status({"plan_id": plan_id})) self.assertIsInstance(result, CallToolResult) - self.assertIsInstance(result.structuredContent, dict) - self.assertEqual(result.structuredContent["plan_id"], plan_id) - self.assertIn("state", result.structuredContent) - self.assertIn("progress_percentage", result.structuredContent) - self.assertIsInstance(result.structuredContent["progress_percentage"], float) - self.assertEqual(result.structuredContent["progress_percentage"], 100.0) + self.assertIsInstance(result.structured_content, dict) + self.assertEqual(result.structured_content["plan_id"], plan_id) + self.assertIn("state", result.structured_content) + self.assertIn("progress_percentage", result.structured_content) + self.assertIsInstance(result.structured_content["progress_percentage"], float) + self.assertEqual(result.structured_content["progress_percentage"], 100.0) def test_plan_status_falls_back_to_zip_snapshot_files_when_primary_source_empty(self): plan_id = str(uuid.uuid4()) @@ -56,7 +56,7 @@ def test_plan_status_falls_back_to_zip_snapshot_files_when_primary_source_empty( ): result = asyncio.run(handle_plan_status({"plan_id": plan_id})) - files = result.structuredContent["files"] + files = result.structured_content["files"] self.assertEqual(len(files), 1) self.assertEqual(files[0]["path"], "plan.txt") self.assertEqual(files[0]["updated_at"], "2026-03-08T23:49:53Z") @@ -79,15 +79,15 @@ def test_plan_status_uses_processing_state_name(self): ): result = asyncio.run(handle_plan_status({"plan_id": plan_id})) - self.assertEqual(result.structuredContent["state"], "processing") + self.assertEqual(result.structured_content["state"], "processing") def test_plan_status_returns_plan_not_found_error(self): plan_id = str(uuid.uuid4()) with patch("mcp_cloud.handlers._get_plan_status_snapshot_sync", return_value=None): result = asyncio.run(handle_plan_status({"plan_id": plan_id})) - self.assertTrue(result.isError) - self.assertEqual(result.structuredContent["error"]["code"], "PLAN_NOT_FOUND") + self.assertTrue(result.is_error) + self.assertEqual(result.structured_content["error"]["code"], "PLAN_NOT_FOUND") def test_plan_status_completed_normalizes_steps(self): @@ -110,7 +110,7 @@ def test_plan_status_completed_normalizes_steps(self): ): result = asyncio.run(handle_plan_status({"plan_id": plan_id})) - sc = result.structuredContent + sc = result.structured_content self.assertEqual(sc["progress_percentage"], 100.0) self.assertEqual(sc["steps_completed"], 30) self.assertEqual(sc["steps_total"], 30) @@ -136,7 +136,7 @@ def test_plan_status_includes_file_counts_from_db(self): ): result = asyncio.run(handle_plan_status({"plan_id": plan_id})) - sc = result.structuredContent + sc = result.structured_content self.assertEqual(sc["steps_completed"], 23) self.assertEqual(sc["steps_total"], 30) @@ -161,7 +161,7 @@ def test_plan_status_file_counts_with_extra_files(self): ): result = asyncio.run(handle_plan_status({"plan_id": plan_id})) - sc = result.structuredContent + sc = result.structured_content self.assertEqual(sc["steps_completed"], 15) self.assertEqual(sc["steps_total"], 30) @@ -185,7 +185,7 @@ def test_plan_status_file_counts_null_when_pending(self): ): result = asyncio.run(handle_plan_status({"plan_id": plan_id})) - sc = result.structuredContent + sc = result.structured_content self.assertIsNone(sc["steps_completed"]) self.assertIsNone(sc["steps_total"]) self.assertIsNone(sc["current_step"]) @@ -212,7 +212,7 @@ def test_plan_status_includes_current_step(self): ): result = asyncio.run(handle_plan_status({"plan_id": plan_id})) - self.assertEqual(result.structuredContent["current_step"], "SWOT Analysis") + self.assertEqual(result.structured_content["current_step"], "SWOT Analysis") def test_plan_status_stopped_returns_stopped_state(self): """User-stopped plan has state='stopped' and no stop_reason field.""" @@ -232,8 +232,8 @@ def test_plan_status_stopped_returns_stopped_state(self): ): result = asyncio.run(handle_plan_status({"plan_id": plan_id})) - self.assertEqual(result.structuredContent["state"], "stopped") - self.assertNotIn("stop_reason", result.structuredContent) + self.assertEqual(result.structured_content["state"], "stopped") + self.assertNotIn("stop_reason", result.structured_content) def test_plan_status_actual_failure_has_no_stop_reason(self): """Failed plan response does not contain stop_reason field.""" @@ -253,9 +253,9 @@ def test_plan_status_actual_failure_has_no_stop_reason(self): ): result = asyncio.run(handle_plan_status({"plan_id": plan_id})) - self.assertEqual(result.structuredContent["state"], "failed") - self.assertNotIn("stop_reason", result.structuredContent) - self.assertIn("error", result.structuredContent) + self.assertEqual(result.structured_content["state"], "failed") + self.assertNotIn("stop_reason", result.structured_content) + self.assertIn("error", result.structured_content) def test_plan_status_failed_includes_failure_diagnostics(self): """Failed plan with all four diagnostic fields populated surfaces them in response.""" @@ -283,7 +283,7 @@ def test_plan_status_failed_includes_failure_diagnostics(self): ): result = asyncio.run(handle_plan_status({"plan_id": plan_id})) - sc = result.structuredContent + sc = result.structured_content self.assertEqual(sc["state"], "failed") self.assertIn("error", sc) err = sc["error"] @@ -315,7 +315,7 @@ def test_plan_status_failed_diagnostics_null_when_not_set(self): ): result = asyncio.run(handle_plan_status({"plan_id": plan_id})) - sc = result.structuredContent + sc = result.structured_content self.assertEqual(sc["state"], "failed") self.assertIn("error", sc) err = sc["error"] @@ -352,7 +352,7 @@ def test_plan_status_includes_last_progress_at(self): ): result = asyncio.run(handle_plan_status({"plan_id": plan_id})) - timing = result.structuredContent["timing"] + timing = result.structured_content["timing"] self.assertIn("last_progress_at", timing) self.assertIsInstance(timing["last_progress_at"], str) self.assertIn("2026-03-12", timing["last_progress_at"]) @@ -383,7 +383,7 @@ def test_plan_status_last_progress_at_null_when_pending(self): ): result = asyncio.run(handle_plan_status({"plan_id": plan_id})) - timing = result.structuredContent["timing"] + timing = result.structured_content["timing"] self.assertIn("last_progress_at", timing) self.assertIsNone(timing["last_progress_at"]) @@ -412,7 +412,7 @@ def test_plan_status_non_failed_omits_diagnostics(self): ): result = asyncio.run(handle_plan_status({"plan_id": plan_id})) - sc = result.structuredContent + sc = result.structured_content self.assertEqual(sc["state"], "processing") self.assertNotIn("error", sc) diff --git a/mcp_cloud/tests/test_tool_surface_consistency.py b/mcp_cloud/tests/test_tool_surface_consistency.py index 6e9012cc..8ca73a41 100644 --- a/mcp_cloud/tests/test_tool_surface_consistency.py +++ b/mcp_cloud/tests/test_tool_surface_consistency.py @@ -182,12 +182,12 @@ def test_cloud_prompt_schema_includes_prompt_shape_guidance(self): self.assertIn("objective, scope, constraints, timeline, stakeholders, budget/resources, and success criteria", prompt_schema) -class TestFastMCPCanonicalOutputSchema(unittest.TestCase): - """FastMCP tools must advertise the canonical outputSchema from TOOL_DEFINITIONS.""" +class TestMCPServerCanonicalOutputSchema(unittest.TestCase): + """Registered tools must advertise the canonical outputSchema from TOOL_DEFINITIONS.""" - def test_fastmcp_flat_tools_use_canonical_output_schema(self): + def test_flat_tools_use_canonical_output_schema(self): """Flat-schema tools must have their canonical outputSchema injected.""" - from mcp_cloud.http_server import fastmcp_server + from mcp_cloud.http_server import mcp_server for tool_def in cloud_app.TOOL_DEFINITIONS: if tool_def.output_schema is None: @@ -195,20 +195,20 @@ def test_fastmcp_flat_tools_use_canonical_output_schema(self): if "oneOf" in tool_def.output_schema: continue # oneOf schemas are tested separately with self.subTest(tool=tool_def.name): - fastmcp_tool = fastmcp_server._tool_manager.get_tool(tool_def.name) + registered_tool = mcp_server._tool_manager.get_tool(tool_def.name) self.assertIsNotNone( - fastmcp_tool, - f"FastMCP tool {tool_def.name!r} not registered", + registered_tool, + f"MCP tool {tool_def.name!r} not registered", ) self.assertEqual( - fastmcp_tool.output_schema, + registered_tool.output_schema, tool_def.output_schema, - f"FastMCP tool {tool_def.name!r} outputSchema does not match TOOL_DEFINITIONS", + f"MCP tool {tool_def.name!r} outputSchema does not match TOOL_DEFINITIONS", ) - def test_fastmcp_oneof_tools_have_no_output_schema(self): + def test_oneof_tools_have_no_output_schema(self): """oneOf schemas must NOT be advertised — MCP clients reject them.""" - from mcp_cloud.http_server import fastmcp_server + from mcp_cloud.http_server import mcp_server oneof_tools = [ td.name for td in cloud_app.TOOL_DEFINITIONS @@ -217,22 +217,22 @@ def test_fastmcp_oneof_tools_have_no_output_schema(self): self.assertTrue(len(oneof_tools) > 0, "Expected at least one oneOf tool") for name in oneof_tools: with self.subTest(tool=name): - fastmcp_tool = fastmcp_server._tool_manager.get_tool(name) + registered_tool = mcp_server._tool_manager.get_tool(name) self.assertIsNone( - fastmcp_tool.output_schema, - f"FastMCP tool {name!r} must not advertise oneOf outputSchema", + registered_tool.output_schema, + f"MCP tool {name!r} must not advertise oneOf outputSchema", ) - def test_all_tool_definitions_registered_in_fastmcp(self): - """Every tool in TOOL_DEFINITIONS must be registered in the FastMCP server.""" - from mcp_cloud.http_server import fastmcp_server + def test_all_tool_definitions_registered_in_mcp_server(self): + """Every tool in TOOL_DEFINITIONS must be registered in the MCP server.""" + from mcp_cloud.http_server import mcp_server for tool_def in cloud_app.TOOL_DEFINITIONS: with self.subTest(tool=tool_def.name): - fastmcp_tool = fastmcp_server._tool_manager.get_tool(tool_def.name) + registered_tool = mcp_server._tool_manager.get_tool(tool_def.name) self.assertIsNotNone( - fastmcp_tool, - f"TOOL_DEFINITIONS has {tool_def.name!r} but FastMCP does not", + registered_tool, + f"TOOL_DEFINITIONS has {tool_def.name!r} but the MCP server does not", ) def test_plan_file_info_canonical_schema_has_three_oneof_variants(self): @@ -296,17 +296,17 @@ def test_tool_functions_return_plain_call_tool_result(self): f"not Annotated[CallToolResult, ...]", ) - def test_fastmcp_plan_file_info_not_derived_from_pydantic(self): + def test_plan_file_info_not_derived_from_pydantic(self): """plan_file_info must not have a schema derived from PlanFileInfoOutput.""" - from mcp_cloud.http_server import fastmcp_server + from mcp_cloud.http_server import mcp_server from mcp_cloud.tool_models import PlanFileInfoOutput - fastmcp_tool = fastmcp_server._tool_manager.get_tool("plan_file_info") + registered_tool = mcp_server._tool_manager.get_tool("plan_file_info") pydantic_schema = PlanFileInfoOutput.model_json_schema() # oneOf schemas are not advertised, so output_schema should be None. # Either way, it must NOT equal the flat Pydantic derivation. self.assertNotEqual( - fastmcp_tool.output_schema, + registered_tool.output_schema, pydantic_schema, "plan_file_info outputSchema looks like it was derived from " "PlanFileInfoOutput instead of using the canonical schema", diff --git a/mcp_cloud/tool_http_bridge.py b/mcp_cloud/tool_http_bridge.py index 763c56e7..ea456a30 100644 --- a/mcp_cloud/tool_http_bridge.py +++ b/mcp_cloud/tool_http_bridge.py @@ -2,7 +2,7 @@ PlanExe MCP Cloud — tool HTTP bridge Pydantic request/response models, MCP result normalization, and thin -async wrapper functions that connect FastMCP tool registrations to the +async wrapper functions that connect MCP tool registrations to the handler implementations in ``mcp_cloud.app``. """ import json @@ -10,7 +10,7 @@ from typing import Annotated, Any, Literal, Optional, Sequence from pydantic import BaseModel, Field -from mcp.server.fastmcp import FastMCP +from mcp.server.mcpserver import MCPServer from mcp.types import CallToolResult, ContentBlock, TextContent from mcp_cloud.app import ( @@ -126,7 +126,7 @@ def _normalize_tool_result(result: Any) -> tuple[list[dict[str, Any]], Optional[ # --------------------------------------------------------------------------- -# FastMCP tool wrapper functions +# MCP tool wrapper functions # --------------------------------------------------------------------------- async def plan_create( prompt: str, @@ -255,11 +255,11 @@ async def send_feedback( # Registry-based tool call (used by REST endpoint) # --------------------------------------------------------------------------- async def call_tool_via_registry( - server: FastMCP, + server: MCPServer, tool_name: str, arguments: dict[str, Any], ) -> MCPToolCallResponse: - """Call tools via the FastMCP registry.""" + """Call tools via the MCP tool registry.""" try: result = await server.call_tool(tool_name, arguments) except Exception as e: