Skip to main content
Glama
geryslov

LinkedIn Ads MCP

by geryslov
README.md
# LinkedIn Ads for Claude — product site

Thin front door for the standalone LinkedIn Ads MCP: LinkedIn SSO, one setup
screen, and an admin panel. No ads dashboard — that's the separate
`linkedin-ads-buddy` app.

```bash
npm install
cp .env.example .env    # fill in the anon key + your product MCP URL
npm run dev             # http://localhost:8080
```

## How it fits together

Shares the Supabase project (`bxoxefmenvlxiubynuay`) with `linkedin-ads-buddy`,
but **not** its tables. Two independent systems:

| | Legacy dashboard | This product |
|---|---|---|
| Table | `mcp_api_keys` | `mcp_keys` |
| MCP entrypoint | `mcp-server/src/server.ts` | `mcp-server/src/server-product.ts` |
| Railway service | `linkedin-ads-buddy-production` | separate service |
| Key → token | anon PostgREST select | `resolve_mcp_key()` RPC |

Nothing here touches the legacy path. That's deliberate — the old dashboard is a
working daily driver and must keep working unchanged.

## Routes

| Route | Auth | Purpose |
|---|---|---|
| `/` | — | Landing + Sign in with LinkedIn |
| `/auth/callback` | — | Supabase OIDC return; captures `provider_token`, calls `link_mcp_key` |
| `/linkedin/callback` | — | Server-side connect return; calls `connect_linkedin` |
| `/setup` | session | Connection status, MCP key, Claude setup instructions |
| `/admin` | admin | Suspend/activate users, revoke keys, toggle read-only |
| `/privacy`, `/terms` | — | Placeholders — **replace before launch** |

## Setup checklist

Do these in order. Steps 1–2 are the go/no-go.

**1. LinkedIn developer app** (reuse the existing one — a new app needs fresh
Advertising API approval, which takes weeks and can be refused)

- Products: **"Sign In with LinkedIn using OpenID Connect"** *and* **"Advertising
  API"**. If OIDC is missing, `openid` is ungrantable and the whole combined-scope
  request fails. This is the single go/no-go.
- Auth → Authorized redirect URLs, **add** (keep the existing ones):
  - `https://bxoxefmenvlxiubynuay.supabase.co/auth/v1/callback` — Supabase SSO
  - `https://<this-site>/linkedin/callback` — server-side connect flow
  - `http://localhost:8080/linkedin/callback` — local dev

**2. Check for refresh tokens.** Run one live connect and look at what LinkedIn
returns. `connect_linkedin` reports `hasRefreshToken`. Access tokens last ~60
days; refresh tokens are partner-gated. This single fact decides whether
reconnects can ever be made silent, so find out early.

**3. Supabase Auth provider — NOT needed.**

Sign-in deliberately does *not* use `signInWithOAuth({provider: "linkedin_oidc"})`.
That would require enabling the LinkedIn provider in the Supabase dashboard, using
credentials this project already holds as edge secrets. Instead the flow is:

```
/  → get_auth_url (scopeSet: "oidc")   → LinkedIn consent
   → /auth/callback?code=…
   → linkedin_signin  (edge, service role)
        exchange code → /v2/userinfo → find-or-create user
        → store ads token in mcp_keys
        → generateLink → one-time hashed_token
   → supabase.auth.verifyOtp({token_hash}) → session
```

The LinkedIn access token never reaches the browser, and `linkedin_oidc` can stay
disabled. If you ever *do* enable it, nothing here needs to change.

**4. Migrations** — run manually in the SQL Editor, in order:

1. `20260805120000_mcp_product_keys.sql`
2. `20260805120100_resolve_mcp_key_rpc.sql`

**5. Mint the resolver JWT.** Sign `{"role":"mcp_server"}` with the project JWT
secret. Set it as `SUPABASE_MCP_JWT` on the product Railway service.

**6. Deploy the edge function** (CI for this is broken — do it by hand):

```bash
SUPABASE_ACCESS_TOKEN=<token> npx supabase functions deploy linkedin-api --project-ref bxoxefmenvlxiubynuay
```

**7. New Railway service** from the same `mcp-server/` folder:
`startCommand = npm run start:product`, plus `SUPABASE_MCP_JWT`, `SETUP_URL`,
`OAUTH_CLIENT_ID`, `SUPABASE_ANON_KEY`.

**8. Grant yourself admin.** ⚠️ Your existing admin row belongs to the
email/password user. Signing in with LinkedIn creates a **different**
`auth.users` row, and the signup trigger only ever grants `'user'` — so you will
not have `/admin` until you run this:

```sql
insert into user_roles (user_id, role)
select id, 'admin' from auth.users where email = 'you@example.com'
on conflict do nothing;
```

## Known gaps

- Legal pages are placeholders.
- No email nudge before a token expires — the `/setup` banner is the only warning.
- Access is self-serve (`access_status` defaults to `'active'`). To require
  approval, change the column default to `'pending'`; the "waiting for approval"
  screen already exists.
- No Stripe. `profiles.plan` / `stripe_customer_id` are there for it, unused.