From 4397f79aa05739d56fb59cab028d79934ddd6eb0 Mon Sep 17 00:00:00 2001 From: Simon Strandgaard Date: Thu, 26 Feb 2026 03:52:11 +0100 Subject: [PATCH 1/2] Allow these mcp tools to be invoked without api key: model_profiles, prompt_examples. --- mcp_cloud/README.md | 2 +- mcp_cloud/http_server.py | 62 +++++++++++++++++++++++++++-- mcp_cloud/tests/test_cors_config.py | 40 +++++++++++++++++++ 3 files changed, 100 insertions(+), 4 deletions(-) diff --git a/mcp_cloud/README.md b/mcp_cloud/README.md index 941a254c7..1ad70e17f 100644 --- a/mcp_cloud/README.md +++ b/mcp_cloud/README.md @@ -42,7 +42,7 @@ mcp_cloud exposes HTTP endpoints on port `8001` (or `${PLANEXE_MCP_HTTP_PORT}`). - `true`: provide a valid `X-API-Key`. Accepted keys are (1) UserApiKey from home.planexe.org (`pex_...`), or (2) `PLANEXE_MCP_API_KEY` if set (for dev or shared secret). OAuth is not supported for the MCP API. -When auth is enabled, MCP handshake/discovery calls (`initialize`, `notifications/initialized`, `tools/list`, `prompts/list`, `resources/list`, `resources/templates/list`, `ping`, `GET /mcp/tools`, and probe traffic to `/mcp` for redirect/handshake compatibility) are intentionally allowed without API key for connector health checks; tool execution remains protected. +When auth is enabled, MCP handshake/discovery calls (`initialize`, `notifications/initialized`, `tools/list`, `prompts/list`, `resources/list`, `resources/templates/list`, `ping`, `GET /mcp/tools`, and probe traffic to `/mcp` for redirect/handshake compatibility) are intentionally allowed without API key for connector health checks. In addition, `tools/call` is open without API key only for `model_profiles` and `prompt_examples`; all other tool calls remain protected. ### Connecting via HTTP/URL diff --git a/mcp_cloud/http_server.py b/mcp_cloud/http_server.py index 3aec03ba2..e5f1ce929 100644 --- a/mcp_cloud/http_server.py +++ b/mcp_cloud/http_server.py @@ -150,6 +150,10 @@ def _split_csv_env(value: Optional[str]) -> list[str]: "resources/templates/list", "ping", } +PUBLIC_TOOL_CALLS_NO_AUTH = { + "model_profiles", + "prompt_examples", +} def _allowed_cors_origin(request: Request) -> Optional[str]: @@ -222,6 +226,37 @@ async def _extract_jsonrpc_methods_from_request(request: Request) -> list[str]: return _extract_jsonrpc_methods_from_payload(payload) +def _extract_jsonrpc_tools_call_names(payload: Any) -> list[str]: + names: list[str] = [] + entries: list[Any] + if isinstance(payload, dict): + entries = [payload] + elif isinstance(payload, list): + entries = payload + else: + return names + + for entry in entries: + if not isinstance(entry, dict): + continue + if entry.get("method") != "tools/call": + continue + params = entry.get("params") + if not isinstance(params, dict): + continue + name = params.get("name") + if isinstance(name, str): + names.append(name) + return names + + +def _extract_rest_tools_call_name(payload: Any) -> Optional[str]: + if not isinstance(payload, dict): + return None + tool = payload.get("tool") + return tool if isinstance(tool, str) else None + + async def _is_public_mcp_request_without_auth(request: Request) -> bool: """Allow unauthenticated MCP handshake/discovery calls.""" path = request.url.path @@ -237,14 +272,35 @@ async def _is_public_mcp_request_without_auth(request: Request) -> bool: if path == "/mcp/tools" and method == "GET": return True - # Streamable HTTP endpoint: allow only lightweight discovery methods. + # REST MCP tools call endpoint: expose only free setup/discovery tools. + if path == "/mcp/tools/call" and method == "POST": + try: + payload = json.loads((await request.body()) or b"") + except json.JSONDecodeError: + return False + tool = _extract_rest_tools_call_name(payload) + return tool in PUBLIC_TOOL_CALLS_NO_AUTH + + # Streamable HTTP endpoint: allow lightweight discovery methods and free setup tools. if path != "/mcp/" or method != "POST": return False - methods = await _extract_jsonrpc_methods_from_request(request) + try: + payload = json.loads((await request.body()) or b"") + except json.JSONDecodeError: + return False + + methods = _extract_jsonrpc_methods_from_payload(payload) if not methods: return False - return all(item in PUBLIC_JSONRPC_METHODS_NO_AUTH for item in methods) + if all(item in PUBLIC_JSONRPC_METHODS_NO_AUTH for item in methods): + return True + + if all(item == "tools/call" for item in methods): + names = _extract_jsonrpc_tools_call_names(payload) + if names and all(name in PUBLIC_TOOL_CALLS_NO_AUTH for name in names): + return True + return False async def _log_auth_rejection(request: Request, reason: str) -> None: diff --git a/mcp_cloud/tests/test_cors_config.py b/mcp_cloud/tests/test_cors_config.py index d9de6757b..7cd7ef47a 100644 --- a/mcp_cloud/tests/test_cors_config.py +++ b/mcp_cloud/tests/test_cors_config.py @@ -160,6 +160,46 @@ def test_non_public_streamable_tools_call(self): result = asyncio.run(http_server._is_public_mcp_request_without_auth(request)) self.assertFalse(result) + def test_public_streamable_tools_call_model_profiles(self): + request = _RequestStub( + headers={}, + method="POST", + path="/mcp/", + body=b'{"jsonrpc":"2.0","id":8,"method":"tools/call","params":{"name":"model_profiles","arguments":{}}}', + ) + result = asyncio.run(http_server._is_public_mcp_request_without_auth(request)) + self.assertTrue(result) + + def test_public_streamable_tools_call_prompt_examples(self): + request = _RequestStub( + headers={}, + method="POST", + path="/mcp/", + body=b'{"jsonrpc":"2.0","id":9,"method":"tools/call","params":{"name":"prompt_examples","arguments":{}}}', + ) + result = asyncio.run(http_server._is_public_mcp_request_without_auth(request)) + self.assertTrue(result) + + def test_public_rest_tools_call_model_profiles(self): + request = _RequestStub( + headers={}, + method="POST", + path="/mcp/tools/call", + body=b'{"tool":"model_profiles","arguments":{}}', + ) + result = asyncio.run(http_server._is_public_mcp_request_without_auth(request)) + self.assertTrue(result) + + def test_non_public_rest_tools_call_task_create(self): + request = _RequestStub( + headers={}, + method="POST", + path="/mcp/tools/call", + body=b'{"tool":"task_create","arguments":{"prompt":"x"}}', + ) + result = asyncio.run(http_server._is_public_mcp_request_without_auth(request)) + self.assertFalse(result) + if __name__ == "__main__": unittest.main() From ba02c511a879da7a7707450ae9603ea460b43462 Mon Sep 17 00:00:00 2001 From: Simon Strandgaard Date: Thu, 26 Feb 2026 03:53:27 +0100 Subject: [PATCH 2/2] AGENTS.md --- mcp_cloud/AGENTS.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/mcp_cloud/AGENTS.md b/mcp_cloud/AGENTS.md index 30c503b15..3ebd52308 100644 --- a/mcp_cloud/AGENTS.md +++ b/mcp_cloud/AGENTS.md @@ -54,6 +54,22 @@ for AI agents and developer tools to interact with PlanExe. Communicates with - Canonical client header is `X-API-Key: pex_...`. - OAuth is not supported for the MCP API. Do not document, imply, or advertise OAuth support. - In docs and user-facing error/help text, instruct clients to use `X-API-Key` custom headers. +- Keep the auth split used for connector health checks: + - Unauthenticated discovery/handshake is allowed for: + - MCP methods: `initialize`, `notifications/initialized`, `tools/list`, `prompts/list`, `resources/list`, `resources/templates/list`, `ping` + - Probe compatibility: `GET/HEAD/POST /mcp`, `GET/HEAD /mcp/`, and `GET /mcp/tools` + - `tools/call` without API key is allowed **only** for free setup tools: + - `model_profiles` + - `prompt_examples` + - All other tool invocations (for example `task_create`) must remain API-key protected. +- Keep auth-denial logging explicit (`Auth rejected: ...`) with method/path/user-agent and parsed JSON-RPC methods to make Railway debugging easier. + +## HTTP Compatibility and Crawler Endpoints +- Keep `/mcp` -> `/mcp/` redirect behavior for slashless clients/probers. +- Keep CORS headers on early error responses (401/403/429/etc.) so browser inspectors do not fail with opaque CORS errors. +- Keep `PLANEXE_MCP_CORS_ORIGINS` parsing tolerant to quoted CSV and JSON-array env formats. +- Keep `GET /robots.txt` available (200) for crawler health checks and metadata discovery. +- FastMCP session lifecycle lines like `Terminating session: None` are expected informational logs; do not treat them as application failures solely based on Railway’s log-level labeling. ## Download URL environment behavior - `task_file_info.download_url` should be built from `PLANEXE_MCP_PUBLIC_BASE_URL` when set.