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.
This server cannot be deployed
Maintenance
ActivitySlowing
ResponsivenessNo issues