diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 000000000..42bece3ba --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,10 @@ +{ + "permissions": { + "allow": [ + "Bash(python3:*)", + "Bash(pip show:*)", + "Bash(pip3 list:*)", + "Bash(git add:*)" + ] + } +} diff --git a/docs/docker.md b/docs/docker.md index 96023415e..af06d6486 100644 --- a/docs/docker.md +++ b/docs/docker.md @@ -11,6 +11,19 @@ This page covers common Docker workflows and troubleshooting. - Build fresh and start services: `docker compose build --no-cache database_postgres worker_plan frontend_single_user frontend_multi_user && docker compose up` ## While developing + +Simons typical workflow. + +While making code changes, I do this. + +```bash +docker compose down --remove-orphans +docker compose build --no-cache +docker compose up +``` + +Frequently I do `docker system prune -a` to free disk space. + - Live rebuild/restart on changes: `docker compose watch` (requires Docker Desktop 4.28+). If watch misses changes after file moves, rerun the no-cache build above. - View logs: diff --git a/docs/mcp/mcp_troubleshooting.md b/docs/mcp/mcp_troubleshooting.md index a2f90dccd..bb66a9248 100644 --- a/docs/mcp/mcp_troubleshooting.md +++ b/docs/mcp/mcp_troubleshooting.md @@ -51,3 +51,19 @@ Then: - Verify your provider has active billing. - Check usage limits or rate limits. - Try a different model or provider. + +--- + +## `Invalid API key` (403) + +If your MCP client can reach `https://mcp.planexe.org/mcp` but receives `{"detail":"Invalid API key"}`: + +- Send **only the raw key value** (e.g. `pex_…`), not `X-API-Key: pex_…`. +- The header should be `X-API-Key: ` or `Authorization: Bearer `. +- Verify with curl: + +```bash +curl -i -H "X-API-Key: pex_your_key_here" https://mcp.planexe.org/mcp/tools +``` + +- For self-hosted deployments: ensure `PLANEXE_API_KEY_SECRET` matches in both `frontend_multi_user` (key issuer) and `mcp_cloud` (key validator). A mismatch causes all `pex_…` keys to be rejected. diff --git a/mcp_cloud/http_server.py b/mcp_cloud/http_server.py index 232d1a002..a6b7dbec3 100644 --- a/mcp_cloud/http_server.py +++ b/mcp_cloud/http_server.py @@ -118,17 +118,57 @@ def _split_csv_env(value: Optional[str]) -> list[str]: ) +def _normalize_api_key_value(raw_value: Optional[str]) -> Optional[str]: + """Strip common copy-paste artefacts from an API key value. + + Handles cases where clients paste the full header line (e.g. 'X-API-Key: pex_…') + or include a Bearer/token scheme prefix (e.g. 'Bearer pex_…'), or wrap the + value in quotes. + """ + if raw_value is None: + return None + value = raw_value.strip() + if not value: + return None + + lower = value.lower() + + # Strip common header-name prefixes (copy-paste of the full header line). + for prefix in ("x-api-key:", "api-key:", "api_key:", "authorization:"): + if lower.startswith(prefix): + value = value[len(prefix):].strip() + lower = value.lower() + break + + # Strip scheme prefixes. + if lower.startswith("bearer "): + value = value[7:].strip() + elif lower.startswith("token "): + value = value[6:].strip() + elif " " in value: + # Generic " " – keep the last segment. + value = value.rsplit(" ", 1)[-1].strip() + + # Strip matching surrounding quotes. + if len(value) >= 2 and value[0] == value[-1] and value[0] in {"'", '"'}: + value = value[1:-1].strip() + + return value or None + + def _extract_api_key(request: Request) -> Optional[str]: + # Prefer Authorization: Bearer auth_header = request.headers.get("Authorization", "") - if auth_header: - parts = auth_header.split(" ", 1) - if len(parts) == 2 and parts[0].lower() == "bearer": - token = parts[1].strip() - if token: - return token - header_key = request.headers.get("X-API-Key") or request.headers.get("API_KEY") - if header_key: - return header_key + normalized_auth = _normalize_api_key_value(auth_header) + if normalized_auth: + return normalized_auth + + # Fall back to explicit API-key headers (case-insensitive via Starlette). + for header_name in ("X-API-Key", "API-Key", "API_KEY", "X_API_KEY"): + value = _normalize_api_key_value(request.headers.get(header_name)) + if value: + return value + return None diff --git a/mcp_cloud/tests/test_auth_key_parsing.py b/mcp_cloud/tests/test_auth_key_parsing.py new file mode 100644 index 000000000..e4976ddea --- /dev/null +++ b/mcp_cloud/tests/test_auth_key_parsing.py @@ -0,0 +1,91 @@ +import unittest + +import mcp_cloud.http_server as http_server + + +class _RequestStub: + def __init__(self, headers: dict[str, str]): + self.headers = headers + + +class TestNormalizeApiKeyValue(unittest.TestCase): + def test_plain_key_unchanged(self): + self.assertEqual(http_server._normalize_api_key_value("pex_abc123"), "pex_abc123") + + def test_strips_whitespace(self): + self.assertEqual(http_server._normalize_api_key_value(" pex_abc123 "), "pex_abc123") + + def test_bearer_prefix(self): + self.assertEqual(http_server._normalize_api_key_value("Bearer pex_abc123"), "pex_abc123") + + def test_bearer_prefix_case_insensitive(self): + self.assertEqual(http_server._normalize_api_key_value("BEARER pex_abc123"), "pex_abc123") + + def test_token_prefix(self): + self.assertEqual(http_server._normalize_api_key_value("token pex_abc123"), "pex_abc123") + + def test_x_api_key_header_prefix(self): + self.assertEqual(http_server._normalize_api_key_value("X-API-Key: pex_abc123"), "pex_abc123") + + def test_api_key_header_prefix(self): + self.assertEqual(http_server._normalize_api_key_value("api-key: pex_abc123"), "pex_abc123") + + def test_authorization_header_prefix(self): + self.assertEqual(http_server._normalize_api_key_value("Authorization: pex_abc123"), "pex_abc123") + + def test_double_quoted_key(self): + self.assertEqual(http_server._normalize_api_key_value('"pex_abc123"'), "pex_abc123") + + def test_single_quoted_key(self): + self.assertEqual(http_server._normalize_api_key_value("'pex_abc123'"), "pex_abc123") + + def test_none_returns_none(self): + self.assertIsNone(http_server._normalize_api_key_value(None)) + + def test_empty_string_returns_none(self): + self.assertIsNone(http_server._normalize_api_key_value("")) + + def test_whitespace_only_returns_none(self): + self.assertIsNone(http_server._normalize_api_key_value(" ")) + + +class TestExtractApiKey(unittest.TestCase): + def test_authorization_bearer(self): + request = _RequestStub(headers={"Authorization": "Bearer pex_from_auth"}) + self.assertEqual(http_server._extract_api_key(request), "pex_from_auth") + + def test_x_api_key(self): + request = _RequestStub(headers={"X-API-Key": "pex_from_x_header"}) + self.assertEqual(http_server._extract_api_key(request), "pex_from_x_header") + + def test_api_key_header(self): + request = _RequestStub(headers={"API-Key": "pex_from_api_key_header"}) + self.assertEqual(http_server._extract_api_key(request), "pex_from_api_key_header") + + def test_x_api_key_with_pasted_prefix(self): + # User accidentally pastes the full header line as the value. + request = _RequestStub(headers={"X-API-Key": "X-API-Key: pex_from_x_header"}) + self.assertEqual(http_server._extract_api_key(request), "pex_from_x_header") + + def test_authorization_with_pasted_bearer(self): + request = _RequestStub(headers={"Authorization": "Bearer pex_bearer_key"}) + self.assertEqual(http_server._extract_api_key(request), "pex_bearer_key") + + def test_no_headers_returns_none(self): + request = _RequestStub(headers={}) + self.assertIsNone(http_server._extract_api_key(request)) + + def test_empty_authorization_returns_none(self): + request = _RequestStub(headers={"Authorization": ""}) + self.assertIsNone(http_server._extract_api_key(request)) + + def test_authorization_takes_priority_over_x_api_key(self): + request = _RequestStub(headers={ + "Authorization": "Bearer pex_from_auth", + "X-API-Key": "pex_from_x_header", + }) + self.assertEqual(http_server._extract_api_key(request), "pex_from_auth") + + +if __name__ == "__main__": + unittest.main()