Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 12 additions & 10 deletions mcp_cloud/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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())
3 changes: 0 additions & 3 deletions mcp_cloud/db_setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand Down
5 changes: 1 addition & 4 deletions mcp_cloud/handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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 [
Expand All @@ -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()
Expand All @@ -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)
Expand Down
6 changes: 3 additions & 3 deletions mcp_cloud/http_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -30,7 +30,7 @@
_split_csv_env,
_startup_log,
app,
fastmcp_server,
mcp_server,
)

# --- middleware exports (auth, CORS, rate limiting) -----------------------
Expand Down
4 changes: 2 additions & 2 deletions mcp_cloud/middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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)
44 changes: 22 additions & 22 deletions mcp_cloud/route_registration.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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 (
Expand Down Expand Up @@ -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,
Expand All @@ -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).
Expand All @@ -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."""
Expand Down Expand Up @@ -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)

Expand All @@ -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.
"""
Expand All @@ -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,
Expand All @@ -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.
Expand All @@ -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:
Expand Down
54 changes: 32 additions & 22 deletions mcp_cloud/server_boot.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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")

Expand Down Expand Up @@ -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


# ---------------------------------------------------------------------------
Expand All @@ -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()
Expand All @@ -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
Expand Down
Loading