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
16 changes: 16 additions & 0 deletions mcp_cloud/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion mcp_cloud/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
62 changes: 59 additions & 3 deletions mcp_cloud/http_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
Expand Down Expand Up @@ -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
Expand All @@ -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:
Expand Down
40 changes: 40 additions & 0 deletions mcp_cloud/tests/test_cors_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()