# business-mcp.lucos.com

> Jira / Phase 1 product name: **lucos-business-mcp** (same service).

ChatGPT- and Cursor-facing **Business Metrics MCP** for AdMedia. Lucos retrieves and controls; ChatGPT reasons.

## Role vs `api.lucos.com`

| Layer | Responsibility (Phase 1 first cut) |
| --- | --- |
| **This repo** | MCP SSE (+ Streamable HTTP) transport, tool registry, connectors, schema harvest |
| **`api.lucos.com`** | JWT auth, tool RBAC, audit inserts, kill-switch policy enforcement |

```
ChatGPT / Cursor
  → SSE / Streamable HTTP
  → business-mcp.lucos.com
  → api.lucos.com (authz + audit)   # TW-325 gateway client (fail-closed)
  → AdCenter HTTP / RO SQL / catalog search
```

This is **not** a second permissions broker.

## Stack

| Item | Choice |
| --- | --- |
| Language | TypeScript (ESM) |
| Runtime | Node.js **24.19.0** LTS (see `.nvmrc`) |
| Package manager | **npm** |
| MCP SDK | `@modelcontextprotocol/sdk` |
| Transport | Streamable HTTP (`/mcp`) **primary for ChatGPT**; SSE (`/sse` + `/messages`) for Cursor / legacy |
| Tests | Vitest |
| Lint/format | ESLint + Prettier |

## Run the MCP server

```bash
nvm use
npm install
npm start
# or: npm run dev  (watch mode)
```

Default listen: `http://127.0.0.1:3333`

| Endpoint | Purpose |
| --- | --- |
| `GET /healthz` | Liveness |
| `GET /sse` | Legacy MCP SSE stream (Cursor / Phase 1) |
| `POST /messages?sessionId=…` | SSE client JSON-RPC messages |
| `ALL /mcp` | Streamable HTTP (**primary ChatGPT connector URL**) |

### Local ChatGPT via ngrok (MNGT + cake)

Step-by-step: expose local MCP with ngrok and test MNGT / cake tools in ChatGPT Developer Mode (Bearer MCP key; mock skips gateway validation): [`docs/LOCAL-CHATGPT-NGROK.md`](docs/LOCAL-CHATGPT-NGROK.md).

### Tunnel E2E checklist (TW-326)

Tools are **disabled by default**. Local loopback verification (Cursor SSE + `health_stub`):

1. **Env trio + start** (shell or temporary `.env` — do **not** commit policy enable):

```bash
export LUCOS_POLICY_ENABLED=true
export LUCOS_TOOL_ENABLE_HEALTH_STUB=true
export LUCOS_BROKER_MOCK_ALLOW=true
npm start
```

   Or enable/disable tools in `policies/tool_enablement.json` (re-read on each call — no redeploy for file/env changes). Staging defaults are **enabled**; use `LUCOS_POLICY_ENABLED=false` as a kill switch.

2. **Liveness:** `curl http://127.0.0.1:3333/healthz` (`/healthz` is public; `/mcp`, `/sse`, `/messages` require a Bearer header or `?api_key=`).

3. **Cursor SSE:** point the client at `http://127.0.0.1:3333/sse` with a Bearer header — see [Cursor (SSE)](#cursor-sse--matches-ticket) below.

4. **Call `health_stub`** from the MCP client (optional `ping` argument). Expect a structured OK payload through the mock broker.

5. **ChatGPT connector:** paste the MCP API key (`lucos_mcp_live_…`) as Bearer — see [ChatGPT (MCP API key)](#chatgpt-mcp-api-key). Google SSO / OAuth is unused (later).

### Checks

```bash
npm run typecheck
npm test
npm run lint
```

## Staging deploy (PM2)

Full staging runbook — **schema harvest → sensitivity → Qdrant index → `catalog_search` → MNGT → PM2**: [`docs/STAGING-DEPLOY.md`](docs/STAGING-DEPLOY.md).

```bash
# On staging VM — after npm ci
cp deploy/business-mcp.staging.env.example .env   # edit secrets; never commit
chmod 600 .env

# Catalog pipeline (first time / refresh) — reads .env automatically
npm run harvest:schemas -- --env staging --target admin
npm run sensitivity:apply -- --catalog catalogs/schemas/staging/admin.json \
  --overrides catalogs/sensitivity/staging/admin.overrides.json
npm run sensitivity:gate -- --catalog catalogs/schemas/staging/admin.json
npm run catalog:index -- --catalog catalogs/schemas/staging/admin.json

# MCP server
npm run start:pm2-staging
pm2 save && pm2 startup
```

Templates: [`.env.example`](.env.example), [`deploy/business-mcp.staging.env.example`](deploy/business-mcp.staging.env.example), [`ecosystem.config.cjs`](ecosystem.config.cjs), [`deploy/nginx-staging.conf.example`](deploy/nginx-staging.conf.example).

## MCP client config

### Cursor (SSE — matches ticket)

`.cursor/mcp.json` (or Cursor Settings → MCP):

```json
{
  "mcpServers": {
    "lucos-business": {
      "url": "http://127.0.0.1:3333/sse",
      "headers": {
        "Authorization": "Bearer lucos_mcp_live_…"
      }
    }
  }
}
```

If your Cursor build expects Streamable HTTP instead of legacy SSE, use:

```json
{
  "mcpServers": {
    "lucos-business": {
      "url": "http://127.0.0.1:3333/mcp",
      "headers": {
        "Authorization": "Bearer lucos_mcp_live_…"
      }
    }
  }
}
```

**DEV (`user-lucos-business-dev`):** hosted URL is `https://dev-mcp.lucos.com/sse` (legacy SSE) or `https://dev-mcp.lucos.com/mcp` (Streamable HTTP — preferred if Cursor supports it). After token or server changes, reload that MCP server in Cursor so `tools/list` succeeds before Slack questions. If live discovery errors or the connection times out, toggle the server off/on or switch to `/mcp`.

Field names can vary by Cursor version (`url` vs `serverUrl`). Prefer local loopback while developing. Local mock (`LUCOS_BROKER_MOCK_ALLOW=true`) still requires a Bearer header; it skips gateway validation.

### ChatGPT (MCP API key)

- **Connector URL:** `https://<mcp-host>/mcp` (streamable HTTP, **primary**). `/sse` is Cursor / legacy SSE (`/messages`).
- **Cursor / header clients:** `Authorization: Bearer lucos_mcp_live_…`. **Not** Google SSO.
- **ChatGPT Developer Mode workaround:** the UI only offers OAuth / Mixed / No Auth. Choose **No Auth** and put the key on the URL: `https://<mcp-host>/mcp?api_key=lucos_mcp_live_…` (`access_token` is an alias). The server copies that into the session Bearer and still validates it against the gateway when mock is off. **Temporary** — query strings leak in access logs; revoke the key if the URL is shared.
- The server requires a token on `/mcp`, `/sse`, and `/messages` (header or `api_key` query). `/healthz` stays public.
- ChatGPT often sends the token only on session open (initialize / SSE GET). The server remembers it (`server/session-bearer.ts`) and forwards it on later tools/call.
- Hosted (`LUCOS_BROKER_MOCK_ALLOW=false`): session open also `GET {LUCOS_GATEWAY_BASE_URL}/api/v1/tools` with that Bearer; non-200 → 401, session is not opened.
- Local ngrok mock: still send a token (dummy key is fine); gateway validation is skipped.
- OAuth / Google SSO is unused (later).
- Q3 (campaigns to scale / incremental revenue): call `recommend_actions` `{ intent: "scale" }` and **omit** `advertiser_id`. A blank ChatGPT reply usually means the connector is on `/sse` or the model used `analyst_agent` (timeout) instead of `recommend_actions`.
- Do not put AdCenter keys or DB credentials in ChatGPT or Cursor config — only the MCP API key.

## AdCenter level-1 + HTTP connector (TW-301 / TW-302)

Ops minting and smoke scripts live in **`api.lucos.com`**. This repo owns the consumer contract + HTTP client:

- Synthetic staging `advertiser_id`: **19880**
- Env map: `ADCENTER_LEVEL1_KEYS_STAGING={"19880":"<key>"}` (vault/host only). Live mint: `ADCENTER_ENSURE_KEY_ENABLED` + `ADCENTER_ADMIN_API_KEY` for staging and prod (`ADCENTER_ENSURE_KEY_PROD` is ignored).
- Credentials: `connectors/adcenter/credentials.ts` (reject Basic / `X-API-ADVID`)
- HTTP client (TW-302): `connectors/adcenter/` — `getCampaign`, `getCreative`, `getReportSummary`, `getCampaignReport`
- Notes: [`docs/ADCENTER-LEVEL1.md`](docs/ADCENTER-LEVEL1.md)
- Fixture IDs: [`docs/fixtures/adcenter-staging.json`](docs/fixtures/adcenter-staging.json)
- Tests: `tests/adcenter-credentials.test.ts`, `tests/adcenter-client.test.ts`

AdCenter display GET tools (campaigns/creatives/reports/lists/pixels/channels) are wired and disabled by default — see `docs/ADCENTER-LEVEL1.md`. Local smoke without a minted key: `ADCENTER_FIXTURE=true`. Live calls use `https://apiad.admedia.com/v1/` (not the UI host `stagingadcenter.admedia.com`). SM/AdWords GETs not wired yet.

## Schema harvest (TW-297)

- Job: [`jobs/schema_harvest/`](jobs/schema_harvest/) (JSON catalogs under `catalogs/schemas/`)
- Targets: `admin`, `shorty`, `adcenter`, `whale`, `keywords` (real MySQL schema names)
- Fixture (no DB):

```bash
npm run harvest:schemas -- --env staging --target admin \
  --fixture tests/fixtures/schema-harvest-admin.json
```

- Live staging Admin: `SCHEMA_HARVEST_ADMIN_DSN_STAGING`, then `--env staging --target admin`
- Live prod Admin: `SCHEMA_HARVEST_ALLOW_PROD=true` + `SCHEMA_HARVEST_ADMIN_DSN_PROD`, then `--env prod --target admin`
- Docs: [`jobs/schema_harvest/README.md`](jobs/schema_harvest/README.md)

## Sensitivity review (TW-298)

- Job: [`jobs/sensitivity_review/`](jobs/sensitivity_review/)
- Apply + overrides, then gate before Qdrant (TW-299):

```bash
npm run sensitivity:apply -- \
  --catalog catalogs/schemas/staging/admin.json \
  --overrides catalogs/sensitivity/staging/admin.overrides.json
npm run sensitivity:gate -- --catalog catalogs/schemas/staging/admin.json
```

- Docs: [`jobs/sensitivity_review/README.md`](jobs/sensitivity_review/README.md)

## Catalog index (TW-299)

- Job: [`jobs/catalog_index/`](jobs/catalog_index/) → Qdrant **`lucos_business_catalog`**
- Never uses code collection `adpilot_embeddings`

```bash
# Defaults (QDRANT_URL etc. have code defaults; OPENAI_API_KEY required for live)
export QDRANT_URL=http://127.0.0.1:6333
export QDRANT_BUSINESS_COLLECTION=lucos_business_catalog
export EMBEDDING_MODEL=text-embedding-3-small
export EMBEDDING_DIMENSION=1536
export OPENAI_API_KEY=sk-...

npm run catalog:index -- --catalog catalogs/schemas/staging/admin.json --dry-run
npm run catalog:index -- --catalog catalogs/schemas/staging/admin.json --fixture
npm run catalog:index -- --catalog catalogs/schemas/staging/admin.json
```

- Docs: [`jobs/catalog_index/README.md`](jobs/catalog_index/README.md)

## catalog_search (TW-300)

- Tool searches `lucos_business_catalog` (disabled by default)
- Enable locally:

```bash
export LUCOS_POLICY_ENABLED=true
export LUCOS_TOOL_ENABLE_CATALOG_SEARCH=true
export LUCOS_BROKER_MOCK_ALLOW=true
export OPENAI_API_KEY=sk-...
npm start
```

- Offline fixture mode: `CATALOG_SEARCH_FIXTURE=true` (no OpenAI/Qdrant; empty hits unless points injected)
- Handler: [`connectors/catalog/search.ts`](connectors/catalog/search.ts)

## Engineering code tools (TW-307 / TW-308)

- MCP tools: `code_search`, `get_file`, `get_file_lines` (disabled by default)
- **Execute-on-gateway** — full-body POST to `api.lucos.com/api/v1/tools/{tool}` (RAG + secret filter + audit live on the gateway). Not authorize-then-local like MNGT/AdCenter.
- Local mock (empty citations): `LUCOS_BROKER_MOCK_ALLOW=true` + `LUCOS_TOOL_ENABLE_CODE_SEARCH=true` (etc.)
- Live: mock off, `LUCOS_GATEWAY_BASE_URL`, Bearer MCP API key; gateway engineering kill-switches + `RAG_QUERY_SERVICE_URL`
- Docs: [`api.lucos.com/docs/ENGINEERING-CODE-TOOLS.md`](../api.lucos.com/docs/ENGINEERING-CODE-TOOLS.md), `AGENTS.md`

## Hard rules

- Tools **enabled by default** in `policies/tool_enablement.json` (kill with `LUCOS_POLICY_ENABLED=false`)
- No row ingest; schema harvest = `INFORMATION_SCHEMA` only
- No `execute_sql`
- AdCenter: level-1 keys only (never Basic + `X-API-ADVID`)
- No secrets in git

See `AGENTS.md` and `.cursor/rules/business-mcp.mdc`.

## Ticket map

| Work | Ticket |
| --- | --- |
| MCP SSE runtime | TW-323 |
| Schema harvest | TW-297 |
| Sensitivity review | TW-298 |
| Catalog Qdrant index | TW-299 |
| catalog_search tool | TW-300 |
| code_search (MCP → RAG) | TW-307 |
| get_file / get_file_lines (MCP → RAG chunks) | TW-308 |
| AdCenter keys (consumer) | TW-301 |
| Tool registry flags | TW-324 |
| Gateway client | TW-325 |
| Health stub polish | TW-326 |
| AdCenter keys + fixtures | TW-301 (`api.lucos.com/docs/ADCENTER-LEVEL1-OPS.md`) |
| MNGT RO views | TW-309 |
| MNGT RO SQL connector | TW-310 |
| MNGT get_advertiser + get_publisher | TW-311 |
| MNGT get_campaign_budget + get_publisher_caps | TW-312 |
| MNGT agreement + SPAF status tools | TW-313 |
| MNGT Phase-2 count/search/list tools | TW-328 |
| MNGT staging/prod deploy runbook | [`docs/MNGT-DEPLOYMENT.md`](docs/MNGT-DEPLOYMENT.md) |
| Staging PM2 + nginx | [`docs/STAGING-DEPLOY.md`](docs/STAGING-DEPLOY.md) |
| Entity / status dictionary | TW-327 |

Epic: [TW-283](https://admedia-jira.atlassian.net/browse/TW-283) · Parent: [TW-321](https://admedia-jira.atlassian.net/browse/TW-321)
