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
10 changes: 10 additions & 0 deletions .claude/settings.local.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"permissions": {
"allow": [
"Bash(python3:*)",
"Bash(pip show:*)",
"Bash(pip3 list:*)",
"Bash(git add:*)"
]
}
}
13 changes: 13 additions & 0 deletions docs/docker.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
16 changes: 16 additions & 0 deletions docs/mcp/mcp_troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: <key>` or `Authorization: Bearer <key>`.
- 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.
58 changes: 49 additions & 9 deletions mcp_cloud/http_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 "<scheme> <token>" – 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 <token>
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


Expand Down
91 changes: 91 additions & 0 deletions mcp_cloud/tests/test_auth_key_parsing.py
Original file line number Diff line number Diff line change
@@ -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()