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

Run `business-mcp.lucos.com` on your laptop, expose it with **ngrok**, and call **MNGT** + **cake** tools from **ChatGPT Developer Mode**. **AdCenter** name-first e2e is [§7](#7-name-first-adcenter-e2e-staging) — keep it off for the first MNGT pass.

This is a **dev smoke path**. It uses `LUCOS_BROKER_MOCK_ALLOW=true` so the MCP server does **not** call `GET /api/v1/tools` on session open. A token is still required (`Authorization: Bearer lucos_mcp_live_…`, or ChatGPT `?api_key=`, or a dummy local key). Google SSO / OAuth is unused (later).

```text
ChatGPT (Developer Mode connector)
  → HTTPS (ngrok)
  → http://127.0.0.1:3333/mcp  (streamable HTTP; primary for ChatGPT)
  → MNGT / cake connectors
  → staging RO MySQL  (or MNGT_RO_FIXTURE for MNGT-only offline)

`/sse` remains for Cursor and legacy SSE clients.
```

---

## 0. Prerequisites

| Need | Notes |
| --- | --- |
| Node **24.19.0** | `nvm use` in repo root (`.nvmrc`) |
| This repo | `business-mcp.lucos.com` |
| [ngrok](https://ngrok.com/download) | Account + authtoken (`ngrok config add-authtoken …`) |
| ChatGPT **Plus / Pro / Team / Enterprise / Edu** | Free tier has no custom MCP connectors |
| Workspace permission | On Team/Enterprise, admin may need to allow custom connectors |

**Data backends (pick one):**

| Mode | When to use | Env |
| --- | --- | --- |
| **MNGT fixture only** | No DB yet; prove ChatGPT ↔ MCP wiring | `MNGT_RO_FIXTURE=true` — cake tools will **not** work (no cake fixture flag) |
| **Live staging RO** | Real MNGT + cake answers | `MNGT_RO_DSN_STAGING` + all three `CAKE_*_RO_DSN_STAGING` |

VPN / network access to staging MySQL is required for live DSNs.

---

## 1. Install and prepare `.env`

```bash
cd /path/to/business-mcp.lucos.com
nvm use
npm install
cp .env.example .env
chmod 600 .env
```

Edit `.env`. Start from this **local ChatGPT smoke** block (do **not** commit `.env`):

```bash
# Bind + DNS-rebinding allowlist (fill after ngrok starts — step 3)
MCP_HOST=127.0.0.1
MCP_PORT=3333
# MCP_ALLOWED_HOSTS=xxxx.ngrok-free.app   # set in step 3

# Skip real api.lucos.com GET /api/v1/tools on session open (Bearer header still required)
LUCOS_BROKER_MOCK_ALLOW=true
LUCOS_POLICY_ENABLED=true

# Tunnel / registry sanity
LUCOS_TOOL_ENABLE_HEALTH_STUB=true

# --- MNGT (TW-311–313) ---
LUCOS_TOOL_ENABLE_GET_ADVERTISER=true
LUCOS_TOOL_ENABLE_GET_PUBLISHER=true
LUCOS_TOOL_ENABLE_GET_CAMPAIGN_BUDGET=true
LUCOS_TOOL_ENABLE_GET_PUBLISHER_CAPS=true
LUCOS_TOOL_ENABLE_GET_PUBLISHER_AGREEMENT_STATUS=true
LUCOS_TOOL_ENABLE_GET_PUBLISHER_SPAF_STATUS=true

# Offline MNGT only (comment out when using live DSN):
MNGT_RO_FIXTURE=true
# Live MNGT (comment out MNGT_RO_FIXTURE):
# MNGT_RO_DSN_STAGING=mysql://lucos_mngt_ro:***@STAGING_HOST:3306/Admin

# --- cake (TW-316 / TW-317) — needs live DSNs; no fixture mode ---
# Leave these commented until DSNs are ready; keep tools off until then.
# LUCOS_TOOL_ENABLE_GET_ADVERTISER_REPORT=true
# LUCOS_TOOL_ENABLE_GET_AFFILIATE_REPORT=true
# LUCOS_TOOL_ENABLE_GET_POSTBACK_REPORT=true
# LUCOS_TOOL_ENABLE_GET_IVT_REPORT=true
# CAKE_ADMIN_RO_DSN_STAGING=mysql://lucos_cake_ro:***@STAGING_HOST:3306/Admin
# CAKE_WHALE_RO_DSN_STAGING=mysql://lucos_cake_ro:***@STAGING_HOST:3306/whale
# CAKE_SHORTY_RO_DSN_STAGING=mysql://lucos_cake_ro:***@STAGING_HOST:3306/Shorty

# --- Engineering code tools (TW-307 / TW-308) — separate from AdCenter/MNGT ---
# With LUCOS_BROKER_MOCK_ALLOW=true these return empty citation fixtures (no RAG).
# Live path: mock=false + LUCOS_GATEWAY_BASE_URL + Bearer MCP API key; gateway must enable
# engineering kill-switches and have RAG_QUERY_SERVICE_URL set.
# LUCOS_TOOL_ENABLE_CODE_SEARCH=true
# LUCOS_TOOL_ENABLE_GET_FILE=true
# LUCOS_TOOL_ENABLE_GET_FILE_LINES=true
# LUCOS_GATEWAY_EXECUTE_TIMEOUT_MS=65000
```

**Do not enable AdCenter flags** for the first MNGT pass (`LUCOS_TOOL_ENABLE_ADCENTER_*`). Name-first AdCenter e2e is [§7](#7-name-first-adcenter-e2e-staging).

Suggested order: wire ChatGPT with `health_stub` + MNGT first → then cake → then AdCenter name-first → optionally engineering `code_search` (live gateway + RAG; keep credentials separate from AdCenter/MNGT).

---

## 2. Start the MCP server (loopback only)

```bash
npm start
# or: npm run dev
```

Expect listen on `http://127.0.0.1:3333`.

**Liveness (local):**

```bash
curl -fsS http://127.0.0.1:3333/healthz
```

You should see `ok: true` and paths for `sse`, `messages`, `streamableHttp`. `/healthz` does not require a Bearer; `/mcp`, `/sse`, and `/messages` do.

Leave this terminal running.

---

## 3. Expose with ngrok

In a **second** terminal:

```bash
ngrok http 3333
```

Copy the HTTPS forwarding URL, e.g. `https://abc123.ngrok-free.app`.

### 3.1 Allow the ngrok Host header

The MCP SDK rejects unknown `Host` values (`Invalid Host`). Put the **hostname only** (no `https://`) in `.env`:

```bash
MCP_ALLOWED_HOSTS=abc123.ngrok-free.app
```

Restart `npm start` so the new allowlist loads.

**Optional shortcut:** rewrite Host to localhost so you can skip `MCP_ALLOWED_HOSTS` updates when the free URL changes:

```bash
ngrok http 3333 --host-header=localhost
```

### 3.2 Verify through the tunnel

```bash
curl -fsS https://abc123.ngrok-free.app/healthz
```

If free ngrok shows an HTML interstitial in a browser, prefer `curl` for this check. Persistent interstitial blocking ChatGPT usually means upgrade ngrok or use a reserved domain.

**Endpoints ChatGPT will use:**

| URL | Role |
| --- | --- |
| `https://<ngrok>/mcp` | Streamable HTTP — **primary ChatGPT connector URL** |
| `https://<ngrok>/sse` | Legacy SSE — Cursor / Phase 1 tunnel; keep for older clients |
| `https://<ngrok>/messages` | SSE companion (automatic; do not paste into ChatGPT) |

---

## 4. Connect in ChatGPT

1. Open [ChatGPT](https://chatgpt.com) → profile → **Settings**.
2. **Apps & Connectors** → **Advanced** → enable **Developer Mode**.
3. Back to **Apps & Connectors** → **Create** (or **Add custom connector**).
4. Fill in:

| Field | Value |
| --- | --- |
| Name | `Lucos Business MCP (local)` |
| Description | Local ngrok smoke — MNGT + cake |
| Connector URL | `https://<your-ngrok-host>/mcp?api_key=<lucos_mcp_live_…>` |
| Authentication | **No Auth** (ChatGPT has no Bearer field). The key is the `api_key` query. Cursor still uses `Authorization: Bearer`. |

5. Confirm you trust the connector → Create / Connect.
6. Start a **new** chat so the tool list refreshes. Enable the connector for that chat if the UI asks.

If create fails on `/mcp`, retry with `https://<your-ngrok-host>/sse?api_key=<key>` (Cursor/legacy).

Do **not** pick OAuth or Mixed, and do not paste the MCP key into Client ID / Secret fields. `?api_key=` is a temporary ChatGPT workaround (query strings leak in logs — revoke if the URL is shared).

**Do not** paste DSNs, AdCenter keys, or gateway JWTs into the ChatGPT connector form — only the MCP API key.

---

## 5. Smoke prompts (MNGT)

Ask ChatGPT explicitly to use the connector, e.g.:

> Using the Lucos Business MCP connector, call `health_stub` with ping `local-ngrok`.

> Using Lucos Business MCP, call `get_advertiser` with `adv_id` 19880 and `environment` staging.

| Tool | Sample args |
| --- | --- |
| `health_stub` | `{ "ping": "local-ngrok" }` |
| `get_advertiser` | `{ "adv_id": 19880, "environment": "staging" }` |
| `get_publisher` | `{ "publisher_id": "lucos_synth_pub", "environment": "staging" }` (fixture) or a real Affiliate name |
| `get_campaign_budget` | `{ "adv_id": 19880 }` or `{ "campaign_id": <id> }` |
| `get_publisher_caps` | `{ "publisher_id": "<Affiliate name>" }` |
| `get_publisher_agreement_status` | `{ "publisher_id": "<Affiliate name>" }` → `signed` \| `missing` only |
| `get_publisher_spaf_status` | `{ "publisher_id": "<Affiliate name>" }` → `uploaded` \| `missing` only |

**Pass checks:**

- [ ] Tools appear on the connector (or ChatGPT offers to call them)
- [ ] `health_stub` returns OK
- [ ] Fixture MNGT: advertiser `19880` / publisher `lucos_synth_pub` return `found`-style payloads
- [ ] No denied fields (`email`, agreement body, SPAF paths, etc.)
- [ ] Disabled tools (AdCenter / cake if still off) are denied or absent from successful calls. JSON `policies/tool_enablement.json` may default tools **on**; use env `LUCOS_TOOL_ENABLE_*=false` to restrict this first pass.

More detail: [`MNGT-DEPLOYMENT.md`](./MNGT-DEPLOYMENT.md) §3.

---

## 6. Enable and smoke cake

When staging RO views + grants exist (see [`CAKE-RO-VIEWS.md`](./CAKE-RO-VIEWS.md)):

1. Set the three `CAKE_*_RO_DSN_STAGING` values in `.env`.
2. Uncomment / set the four `LUCOS_TOOL_ENABLE_GET_*_REPORT=true` flags.
3. Restart `npm start` (env changes need a process restart; JSON policy alone would hot-reload, env does not).
4. In ChatGPT, reconnect or open a new chat if the tool list looks stale.

Example prompts:

> Call `get_advertiser_report` with group_by `total` for the last 31 days, environment staging. Read `summary.spend`.

> Call `get_affiliate_report` for affiliate_id `<id>` for the last 7 days.

| Tool | Notes |
| --- | --- |
| `get_advertiser_report` | Max **92**-day window (default 31). Default grain is publisher × advertiser; `group_by=total` + `summary.spend` for portfolio totals. Prefer this over `adhoc_explore`; if it errors or returns empty rows, fall back to `adhoc_explore`. |
| `get_affiliate_report` | **Requires** `affiliate_id` (unscoped = every publisher) |
| `get_postback_report` | Max **7** days; URLs redacted |
| `get_ivt_report` | Max **31** days; aggregated only — no click-level rows |

**Pass checks:**

- [ ] Date windows outside catalog limits are rejected clearly
- [ ] Postback URLs have query/token segments stripped
- [ ] Missing Shorty day-shards surface as `partial` / `shards_missing`, not silent empty success
- [ ] Postback/IVT stay fresh only if the nightly shard-view job is running on Shorty

Rules: [`CAKE-SHARD-AND-DATE-RANGE-RULES.md`](./CAKE-SHARD-AND-DATE-RANGE-RULES.md), [`CAKE-POSTBACK-URL-REDACTION-RULES.md`](./CAKE-POSTBACK-URL-REDACTION-RULES.md).

---

## 7. Name-first AdCenter e2e (staging)

Do this **after** MNGT smoke works. ChatGPT should resolve the advertiser by **name**, then list campaigns. There is **no** mint MCP tool and **no** API key in the prompt.

**Prompt:**

> Using Lucos Business MCP, list campaigns for advertiser `lucos_synth_adv` (staging). Pass `advertiser` with that name on `adcenter_list_campaigns` — the tool resolves `adv_id`.

Expected flow:

1. `adcenter_list_campaigns` with `advertiser=lucos_synth_adv` (tool resolves staging synthetic **19880**)
2. Or pass numeric `advertiser_id` if already known

`get_advertiser` with `adv_id` 19880 is fine when you already know the id.

### Enablement

JSON `policies/tool_enablement.json` may already default AdCenter + MNGT tools **on**. For a tight demo, leave JSON as-is and restrict extras with env `LUCOS_TOOL_ENABLE_*=false`. To turn AdCenter on explicitly:

```bash
LUCOS_POLICY_ENABLED=true
LUCOS_TOOL_ENABLE_SEARCH_ADVERTISERS=true
LUCOS_TOOL_ENABLE_GET_ADVERTISER=true
LUCOS_TOOL_ENABLE_ADCENTER_LIST_CAMPAIGNS=true
```

Restart `npm start` after env edits.

### Local modes

| Mode | When | What to set |
| --- | --- | --- |
| **Offline fixture** | ChatGPT Bearer dummy / no gateway | `ADCENTER_FIXTURE=true` — synthetic campaigns, no key, no mint |
| **Live mint (ChatGPT mock)** | Real AdCenter GETs without a live MCP key check | Empty MCP map `ADCENTER_LEVEL1_KEYS_STAGING={}` + `ADCENTER_ENSURE_KEY_ENABLED=true` + `ADCENTER_ADMIN_API_KEY` in MCP `.env` (gitignored). MCP mints `PUT /api/key` `{ adv_id, level: 1 }` for any advertiser_id |
| **Live gateway fallback** | Real AdCenter GETs with a forwarded Bearer | Same empty map + `LUCOS_GATEWAY_BASE_URL` + **MCP API key Bearer**. Used when local mint is off |
| **Mock RBAC + empty map** | `LUCOS_BROKER_MOCK_ALLOW=true` | Skips tool RBAC. Live AdCenter still needs local mint (`ADCENTER_ADMIN_API_KEY`) or Bearer + gateway. Without those, use `ADCENTER_FIXTURE=true` or expect `ADCENTER_KEY_NOT_FOUND` |

Do **not** mint a key offline and paste it into the MCP JSON map as the primary path. The env map is break-glass / migration only. See [`ADCENTER-LEVEL1.md`](./ADCENTER-LEVEL1.md) and [`api.lucos.com/docs/ADCENTER-LEVEL1-OPS.md`](../../api.lucos.com/docs/ADCENTER-LEVEL1-OPS.md).

**Do not** paste `ADCENTER_ADMIN_API_KEY`, level-1 keys, or gateway JWTs into the ChatGPT connector form.

### Pass / fail checklist

- [ ] `search_advertisers` for `lucos_synth_adv` returns `adv_id` **19880** (or fixture equivalent)
- [ ] `adcenter_list_campaigns` with that `advertiser_id` returns campaigns (live or fixture)
- [ ] ChatGPT output / MCP envelopes contain **no** API keys (`x_api_key`, `X-API-KEY`, admin key)
- [ ] Connector does **not** list `adcenter_ensure_level1_key` as a tool

**Negative (fail closed, no key leak):**

- [ ] Local mint off (`ADCENTER_ENSURE_KEY_ENABLED` unset) or admin key unset → `ADCENTER_ADMIN_KEY_MISSING` / `ADCENTER_ENSURE_FAILED` / `ADCENTER_KEY_NOT_FOUND`
- [ ] Error text does **not** include the admin key, minted key, or `x_api_key`

---

## 8. Troubleshooting

| Symptom | Likely fix |
| --- | --- |
| `Invalid Host` / 403 from MCP | Set `MCP_ALLOWED_HOSTS` to the ngrok hostname and restart, or use `--host-header=localhost` |
| ChatGPT “Failed to connect” | Confirm `curl https://<ngrok>/healthz`; use `/mcp?api_key=…` (streamable HTTP) as the connector URL with **No Auth**; `/sse` is Cursor/legacy; free-ngrok interstitial; key must be on the URL or Authorization header |
| ChatGPT blank reply on Q3 (scale / incremental revenue) | Connector URL should be `…/mcp` (not `/sse`). Call `recommend_actions` `{ intent: "scale" }` and **omit** `advertiser_id` — do **not** use `analyst_agent` (timeout / no reply). |
| Connector connects but no tools | Developer Mode off; or open a **new** chat after creating the connector |
| Tool denied / disabled | `LUCOS_POLICY_ENABLED=true` + matching `LUCOS_TOOL_ENABLE_*`; JSON `policies/tool_enablement.json` may default tools on — use env `LUCOS_TOOL_ENABLE_*=false` to restrict. Restart after env edits |
| Auth / gateway errors | Keep `LUCOS_BROKER_MOCK_ALLOW=true` for MNGT/cake local path; still send a token (header or `?api_key=`) |
| ChatGPT UI only offers OAuth / Mixed / No Auth | Expected. Choose **No Auth** and append `?api_key=<lucos_mcp_live_…>` to the connector URL. Do not paste the key into OAuth client fields. |
| AdCenter `ADCENTER_KEY_NOT_FOUND` with mock + empty map | Set `ADCENTER_ENSURE_KEY_ENABLED=true` + `ADCENTER_ADMIN_API_KEY` in MCP `.env`, or pass Bearer + `LUCOS_GATEWAY_BASE_URL`, or set `ADCENTER_FIXTURE=true` for offline |
| AdCenter `ADCENTER_ADMIN_KEY_MISSING` / mint fail | MCP `.env` `ADCENTER_ENSURE_KEY_ENABLED` + `ADCENTER_ADMIN_API_KEY` (never ChatGPT, never git). Fail closed — output must not contain keys |
| MNGT empty / connection error | Fixture vs DSN: don’t set both; VPN for live MySQL |
| cake tools fail immediately | All three `CAKE_*_RO_DSN_STAGING` required; cake has **no** fixture env |
| ngrok URL changed after restart | Update ChatGPT connector URL **and** `MCP_ALLOWED_HOSTS`, then restart MCP |
| Want real Google login | Unused for now. OAuth / Secure Tunnel remains a later path — current connector auth is the MCP API key. |

---

## 9. Tear down / hygiene

1. Stop ngrok (Ctrl+C) — public URL dies with it.
2. Stop `npm start`.
3. Remove or disable the ChatGPT custom connector when done.
4. Never commit `.env`. Prefer killing the tunnel when not testing — it publishes a local tool surface to the internet.

Kill switch without stopping the process: set `LUCOS_POLICY_ENABLED=false` in `.env` and restart, or flip tools off in `policies/tool_enablement.json` (JSON is re-read per call).

---

## 10. What this does *not* cover

| Topic | Where |
| --- | --- |
| AdCenter ensure-key ops (gateway admin key; mint gated by `ADCENTER_ENSURE_KEY_ENABLED`) | [`ADCENTER-LEVEL1.md`](./ADCENTER-LEVEL1.md), [`api.lucos.com/docs/ADCENTER-LEVEL1-OPS.md`](../../api.lucos.com/docs/ADCENTER-LEVEL1-OPS.md) |
| Staging PM2 / nginx | [`STAGING-DEPLOY.md`](./STAGING-DEPLOY.md) |
| ChatGPT Enterprise Secure MCP Tunnel + Google SSO | Unused / later. Current path is Bearer MCP API key on `/mcp`. |
| Prod DSNs / `*_ALLOW_PROD` | Deploy runbooks — not for laptop smoke |

---

## Quick checklist

- [ ] `.env` with mock broker + MNGT flags (+ cake when ready)
- [ ] `npm start` → local `/healthz` OK
- [ ] `ngrok http 3333` → tunnel `/healthz` OK
- [ ] `MCP_ALLOWED_HOSTS` matches ngrok host (unless host-header rewrite)
- [ ] ChatGPT Developer Mode → connector → Auth **No Auth** → `/mcp?api_key=<lucos_mcp_live_…>` (streamable HTTP; `/sse` is Cursor/legacy). Cursor still uses Bearer header.
- [ ] `health_stub` then MNGT tools
- [ ] cake DSNs + report tools (optional second pass)
- [ ] AdCenter name-first ([§7](#7-name-first-adcenter-e2e-staging)): `lucos_synth_adv` → `search_advertisers` → `adcenter_list_campaigns` (fixture **or** Bearer + gateway ensure-key; empty MCP map OK)
- [ ] Negative AdCenter: ensure disabled / mint fail → error, **no** keys in output
