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
7 changes: 7 additions & 0 deletions mcp_cloud/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,13 @@ Use a UserApiKey from [home.planexe.org](https://home.planexe.org/), or set `PLA
- `GET /docs` - OpenAPI documentation (Swagger UI)
- `GET /robots.txt` - Crawler rules for public metadata discovery

### Discovery / `.well-known` Endpoints

The `/.well-known/` prefix is an [IETF standard (RFC 8615)](https://www.rfc-editor.org/rfc/rfc8615) for machine-readable metadata. Automated systems (registries, crawlers, AI agents) fetch these to discover what the server offers without performing a full handshake.

- **`GET /.well-known/mcp/server-card.json`** — MCP Server Card ([SEP-1649](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1649)). Lets MCP registries (Smithery, etc.) discover the server's name, description, transport type, capabilities, and auth requirements in a single JSON fetch — no MCP handshake needed.
- **`GET /.well-known/glama.json`** — Glama ownership verification. When registering at [glama.ai](https://glama.ai), their crawler fetches this to confirm the server maintainer (contains a maintainer email).

### "SSE error" or "no Server-SSE stream" from the client

Some MCP clients (e.g. OpenClaw/mcporter) connect by doing a **GET** to the server URL and expect a **Server-Sent Events (SSE)** stream (`Content-Type: text/event-stream`). That is the **Streamable HTTP** transport. This server mounts FastMCP at `/mcp`; **GET /mcp** returns a **307 redirect** to `/mcp/`, and the Streamable HTTP handshake may not match what the client expects, so the client reports "SSE error" or "could not fetch … no SSE stream".
Expand Down
59 changes: 57 additions & 2 deletions mcp_cloud/http_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,7 @@ def _append_cors_headers(request: Request, response: Response) -> Response:

headers = response.headers
headers.setdefault("Access-Control-Allow-Origin", allow_origin)
headers.setdefault("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
headers.setdefault("Access-Control-Allow-Methods", "GET, HEAD, POST, OPTIONS")

request_headers = request.headers.get("access-control-request-headers")
if request_headers:
Expand Down Expand Up @@ -778,7 +778,7 @@ async def _lifespan(app: FastAPI):
CORSMiddleware,
allow_origins=CORS_ORIGINS,
allow_credentials=False,
allow_methods=["GET", "POST", "OPTIONS"],
allow_methods=["GET", "HEAD", "POST", "OPTIONS"],
allow_headers=["*"], # Allow any header (e.g. X-API-Key) for CORS preflight
)

Expand Down Expand Up @@ -904,6 +904,20 @@ async def options_mcp() -> Response:
return Response(status_code=200)


@app.head("/mcp/")
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
405. This explicit route intercepts the request so scanners get a clean
200 instead of bouncing off the sub-app.
"""
return Response(
status_code=200,
headers={"Content-Type": "application/json"},
)


@app.api_route("/mcp", methods=["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE"])
async def redirect_mcp_no_trailing_slash() -> RedirectResponse:
"""Normalize '/mcp' to '/mcp/' so streamable HTTP requests avoid 405 mismatches."""
Expand Down Expand Up @@ -1022,6 +1036,7 @@ def root() -> dict[str, Any]:
"tools": "/mcp/tools",
"call": "/mcp/tools/call",
"health": "/healthcheck",
"mcp_server_card": "/.well-known/mcp/server-card.json",
"glama_connector": "/.well-known/glama.json",
"download": f"/download/{{plan_id}}/{REPORT_FILENAME}",
"llms_txt": "/llms.txt",
Expand All @@ -1046,6 +1061,46 @@ def _llms_txt_path() -> str:
return path


@app.get("/.well-known/mcp/server-card.json")
def mcp_server_card() -> dict[str, Any]:
"""Serve MCP Server Card for discovery (SEP-1649).

This allows registries like Smithery to discover the server's capabilities
without performing a full MCP handshake.
"""
return {
"$schema": "https://static.modelcontextprotocol.io/schemas/mcp-server-card/v1.json",
"version": "1.0",
"serverInfo": {
"name": "planexe-mcp-server",
"title": "PlanExe – AI Project Planning",
"version": "1.0.0",
},
"description": (
"MCP server that generates strategic project-plan drafts from a "
"natural-language prompt. Output is a self-contained interactive "
"HTML report with 20+ sections including executive summary, "
"interactive Gantt charts, risk analysis, SWOT, governance, "
"investor pitch, and adversarial stress-test sections."
),
"documentationUrl": "https://docs.planexe.org/",
"transport": {
"type": "streamable-http",
"endpoint": "/mcp",
},
"capabilities": {
"tools": {},
},
"authentication": {
"required": AUTH_REQUIRED,
"schemes": ["bearer"],
},
"tools": ["dynamic"],
"prompts": ["dynamic"],
"resources": ["dynamic"],
}


@app.get("/.well-known/glama.json")
def glama_connector_metadata() -> dict[str, Any]:
"""Serve Glama connector ownership metadata."""
Expand Down