# MCP API keys / ChatGPT connect

**Audience:** Platform, Infra/Security, MCP client integrators  
**Service:** `api.lucos.com`  
**Related:** [TW-291](https://admedia-jira.atlassian.net/browse/TW-291) · [TW-292](https://admedia-jira.atlassian.net/browse/TW-292) · [TW-321](https://admedia-jira.atlassian.net/browse/TW-321)–[TW-326](https://admedia-jira.atlassian.net/browse/TW-326) · [GOOGLE-IDE-OAUTH.md](./GOOGLE-IDE-OAUTH.md)

---

## Goal

When a user connects Lucos Business Metrics MCP in **ChatGPT**, they paste a **Lucos-issued MCP API key**. ChatGPT sends `Authorization: Bearer <mcp-key>` on the Secure MCP Tunnel. `requireAuthBearer` resolves the key to a named Lucos user (`req.user.authMethod = 'mcp_key'`).

**Do not** use Google SSO / OAuth for the ChatGPT connector path. Google IDE OAuth (`/google/ide-*`) is unchanged and separate.

---

## Runtime flow

```text
User generates a key in Lucos (SaaS session)
  → POST /api/v1/auth/mcp-keys  (cookie / JWT requireAuth)
  → Copy lucos_mcp_live_<secret>  (shown once)
  → Paste into ChatGPT MCP connector as Bearer
  → ChatGPT Secure MCP Tunnel → lucos-business-mcp (streamable / SSE)
  → MCP gateway client → api.lucos.com /api/v1/tools  (Bearer MCP key)
  → requireAuthBearer → resolveMcpKey → req.user (userId, email, role, authMethod=mcp_key)
  → kill switches + tool enablement still apply; Phase 1 skips per-user role/param RBAC
```

**Kill switches and tool enablement still apply.** A valid unrevoked key may call every tool that is still enabled. Per-user tool permissions are Phase 2.

---

## ChatGPT connector config

| Field | Value |
| --- | --- |
| MCP URL | `https://<mcp-host>/mcp` (streamable HTTP). `/sse` is legacy. ChatGPT: `https://<mcp-host>/mcp?api_key=<key>` with **No Auth**. |
| Auth | Cursor: Bearer = the copied MCP key. ChatGPT: `api_key` query (temporary; no Google SSO). |

Example staging:

```text
Cursor:   https://<staging-mcp-host>/mcp
          Authorization: Bearer lucos_mcp_live_<secret>

ChatGPT:  https://<staging-mcp-host>/mcp?api_key=lucos_mcp_live_<secret>
          Authentication: No Auth
```

---

## Credential design

| Piece | Detail |
| --- | --- |
| Format | `lucos_mcp_live_<32-byte base64url>` |
| Storage | SHA-256 hex `tokenHash` (unique), `userId`, `email`, `prefix` (first ~16 chars), optional `label`, `revokedAt`, `lastUsedAt`. **Never store plaintext.** |
| Lookup | Hash presented Bearer → `{ tokenHash, revokedAt: null }` |
| Limits | Max **3** active keys per user; generate is `@admedia.com` only; `MCP_KEY_ALLOWLIST_ENABLED=true` further restricts to `MCP_KEY_ALLOWED_EMAILS`; no expiry in Phase 1 |
| Audit | `req.user.authMethod = 'mcp_key'` (JWT callers get `'jwt'`) |

The prefix is support-safe (logs, list UI). The secret is random; email is **not** encoded in the token.

---

## Key management (SaaS session)

Cookie or Bearer **JWT** via `requireAuth` — not MCP keys, and not `/api/v1/tools`.

| Method | Path | Notes |
| --- | --- | --- |
| `POST` | `/api/v1/auth/mcp-keys` | Optional `{ "label": "ChatGPT" }`. Returns `{ key, meta }` **once**. |
| `GET` | `/api/v1/auth/mcp-keys` | Active keys only. Never includes the secret. |
| `DELETE` | `/api/v1/auth/mcp-keys/:id` | Sets `revokedAt`. 404 if missing or not owned. |

Non-`@admedia.com` → **403**. If `MCP_KEY_ALLOWLIST_ENABLED=true`, any `@admedia.com` user not in `MCP_KEY_ALLOWED_EMAILS` → **403**. Fourth active key → **409**.

### Create response

```json
{
  "success": true,
  "key": "lucos_mcp_live_…",
  "meta": {
    "id": "66f…",
    "prefix": "lucos_mcp_live_A",
    "label": "ChatGPT",
    "createdAt": "2026-08-24T11:00:00.000Z",
    "lastUsedAt": null,
    "revokedAt": null
  }
}
```

Copy `key` immediately. List/revoke only return `meta` fields (`id`, `prefix`, `label`, timestamps).

---

## Tool auth (`requireAuthBearer`)

On `/api/v1/tools`:

1. Missing Bearer → **401**
2. Token starts with `lucos_mcp_` → hash lookup; missing/revoked → **401**; `req.user.authMethod = 'mcp_key'`
3. Otherwise HS256 JWT as today (`JWT_SECRET`); `req.user.authMethod = 'jwt'`

Phase 1 MCP keys skip `ROLE_DENIED` and param scopes after kill-switch / tool-disabled checks. JWT callers still use `role-bindings.json`.

---

## Staging vs prod isolation

- Separate tunnel hostnames and MCP ingress  
- Separate Mongo key rows (never reuse a staging key in prod)  
- Separate kill switches / tool enablement (TW-292)  
- Key prefix `lucos_mcp_live_` is the same format in both environments; isolation is which API issued the hash  

---

## Verification

### A. Automated

```bash
cd api.lucos.com
npm test -- --testPathPattern='mcp-key|tool-auth|tool-policy|tools-auth'
```

### B. Curl smoke

```bash
# Unauthenticated → 401
curl -s -o /dev/null -w "%{http_code}\n" https://stagingapi.lucos.com/api/v1/tools

# MCP key (after POST /api/v1/auth/mcp-keys while signed in)
curl -s https://stagingapi.lucos.com/api/v1/tools \
  -H "Authorization: Bearer $MCP_KEY"
```

### C. Manual ChatGPT tunnel

1. Sign in to Lucos as an `@admedia.com` user; create an MCP key; copy it.  
2. In ChatGPT, set connector URL to `https://<mcp-host>/mcp?api_key=<key>` and choose **No Auth** (no Google login). Cursor still uses Bearer.  
3. Invoke a health/stub tool through the tunnel.  
4. Confirm `req.user.email` / `userId` and `authMethod=mcp_key` in audit.  
5. Call a tool route without Authorization → **401**.  
6. Confirm killed / disabled tools still deny.

---

## Unused / later: MCP OAuth (`/api/v1/auth/mcp/*`)

Google SSO → Lucos JWT OAuth (`GET /mcp/authorize`, `GET /mcp/callback`, `POST /mcp/token`, discovery) remains in the codebase but is **not** the ChatGPT connect path. Do not configure ChatGPT to use it. A later phase may reuse or remove that flow.

IDE Google OAuth (`/google/ide-start`, `/google/ide-callback`) is **unchanged**.

---

## Out of scope (Phase 1)

- Per-user tool permissions (Phase 2)  
- Key expiry  
- Encoding email inside the token  
- Building SSE / streamable MCP server (TW-321–TW-326)  
- Changing JWT algorithm for non-key callers (HS256 + `JWT_SECRET` remains)  

---

## Acceptance

| Criterion | Status path |
| --- | --- |
| ChatGPT connects with a pasted MCP key | This document + CRUD routes |
| Unauthenticated cannot invoke tools (401) | TW-291 + tests |
| Named user in `req.user` for audit | Key row → User; `authMethod=mcp_key` |
| Kill switches / disabled tools still deny | `evaluateToolAccess` |
| JWT tool RBAC unchanged | `authMethod=jwt` + existing tests |
| Staging/prod isolation documented | Above |
