Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,11 @@ Lint from repo root: `bunx biome ci .` (matches CI in `.github/workflows/ci.yml`
- `bun` is installed via npm into `~/.local/bin` (on `PATH` via `~/.bashrc`); the standalone bun.sh installer is blocked by the network egress policy.
- The repo's dated migrations assume a pre-existing hosted base schema. `supabase/migrations/00000000000000_local_base_schema.sql` reconstructs it (tables, signup trigger, slug triggers, `pgmq_public` wrappers, storage policies) so a fresh `supabase start` / `supabase db reset` works locally. It is guarded to be a no-op where objects already exist.
- App env lives in `apps/cursor/.env` (gitignored). For local dev it uses the Supabase CLI's well-known local default keys (`supabase status` prints them) with `NEXT_PUBLIC_SUPABASE_URL=http://127.0.0.1:54321`. `CRON_SECRET` guards `/api/queue/plugin-scans/drain` and `/api/cron/*`.
- The login page only offers GitHub/Google OAuth, which are not configured locally. Create a confirmed user via the GoTrue admin API (`POST {SUPABASE_URL}/auth/v1/admin/users` with the secret key, `"email_confirm": true`); a DB trigger provisions the `public.users` profile row. To get a browser session, add a temporary route that calls `supabase.auth.signInWithPassword(...)` with the server client (do not commit it). Put the user's id in `ADMIN_USER_IDS`/`NEXT_PUBLIC_ADMIN_USER_IDS` to use `/admin/plugins`.
- The login page only offers GitHub/Google OAuth, which are not configured locally. Create a confirmed user via the GoTrue admin API (`POST {SUPABASE_URL}/auth/v1/admin/users` with the secret key, `"email_confirm": true`); a DB trigger provisions the `public.users` profile row. To get a browser session, add a temporary route that calls `supabase.auth.signInWithPassword(...)` with the server client (do not commit it). To use `/admin/plugins` and the MCP server, add the user to the `public.admins` table: `insert into public.admins (user_id) values ('<id>')`. Email rows only match Google/GitHub identities, so local password users need a `user_id` row.
- Cache Components (`cacheComponents: true`) makes pages with `generateStaticParams` throw 500 on an empty database — seed at least one active plugin. Use `bun run seed:extract` / `seed:insert`, but note: the insert script must be run as `bun run --conditions=react-server --env-file=apps/cursor/.env apps/cursor/src/scripts/insert-from-jsonl.ts` or the `server-only` import throws under plain `bun run`. GitHub Code Search (used by `seed:extract` discovery) is heavily rate-limited; hand-writing `apps/cursor/.seed/candidates.json` with `{"candidates":[{"owner":...,"repo":...,"source":"seed:topic","matchedQuery":"manual"}]}` skips discovery.
- Seeded plugin logos hosted on `raw.githubusercontent.com` 500 plugin pages because that host is not in `next.config.mjs` `images.remotePatterns`; null them: `update plugins set logo = null where logo like 'https://raw.githubusercontent.com%'`.
- After changing `.env` or database content backing cached pages, restart `bun dev` (and `rm -rf apps/cursor/.next` if stale renders persist) — hot reload does not invalidate Cache Components output.
- Without `CURSOR_API_KEY`, plugin submission works but the security scan errors (plugin lands in the admin "Scan issues" queue, where an admin can publish it manually). This is expected locally.
- Supabase `edge_runtime` is disabled in `supabase/config.toml` (no edge functions in this app; its boot probe needs deno.land which is blocked).
- Community Plugins MCP server (`/api/mcp`): the local Supabase OAuth 2.1 server is enabled in `config.toml`. Register the client with `bun run mcp:create-oauth-client` from `apps/cursor` (add `--redirect-uri http://localhost:<port>/callback` for a local test client), put the printed ID in `MCP_OAUTH_CLIENT_IDS` in `.env`, and restart `bun dev`. Locally the authorization server metadata is served at `http://127.0.0.1:54321/auth/v1/.well-known/oauth-authorization-server` (the RFC 8414 path-inserted URL 404s), which MCP SDK clients find via their OIDC fallback. Drive the flow with the official MCP client SDK (`@modelcontextprotocol/sdk`): a 401 from `/api/mcp` starts discovery, the browser step lands on `/oauth/consent`. GoTrue auto-approves clients a user already consented to on the first authorization-details fetch (the code is issued immediately and the authorization is consumed); `delete from auth.oauth_consents` resets that locally.
- If `supabase start` fails with Realtime "connection not available" while initialising the schema, containers cannot reach each other because a legacy iptables `FORWARD DROP` policy is present: `sudo iptables-legacy -P FORWARD ACCEPT`, then retry. Without systemd, start Docker with `sudo dockerd` in a tmux session.
114 changes: 114 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,3 +132,117 @@ The scan is asynchronous and runs out of the request lifecycle:
The drain route uses `maxDuration = 800` (Vercel Pro+ Fluid Compute ceiling) and
relies on `CURSOR_API_KEY` + `CRON_SECRET` from the env file.


## Managing plugins via MCP (admins)

Admins can moderate Community Plugins from Cursor through the MCP server at
`/api/mcp`. It exposes the same operations as `/admin/plugins`:

| Tool | Effect |
|------|--------|
| `list_moderation_queue` | Flagged, scan issues, verification requests, or hidden plugins |
| `search_plugins`, `get_plugin` | Find a plugin and see its status, scan verdict, owner, and components |
| `list_moderation_events` | Moderation audit log (web and MCP) |
| `approve_plugin`, `approve_flagged_plugin` | Publish a hidden plugin; flagged/blocked plugins need the explicit `approve_flagged_plugin` |
| `unpublish_plugin`, `block_plugin` | Hide a plugin, or hide and permanently block it (with a reason) |
| `rescan_plugin` | Queue a new security scan (refused while one is in progress) |
| `set_plugin_verified`, `dismiss_verification_request` | Manage the verified badge |

Deleting plugins stays in the web admin. Write tools are rate-limited per admin
(`mcp-moderation` Vercel Firewall rule), and every change from the web admin or
MCP is recorded in the append-only `plugin_moderation_events` table.

### Who is an admin

Admins are rows in the `public.admins` table, which also controls
`/admin/plugins`. Add or remove people in the Supabase dashboard (Table
Editor → `admins`) or with SQL. Changes apply on the next request; nothing
needs a redeploy. Fill in one of:

- `user_id`: an existing account (`auth.users.id`).
- `email`: someone who hasn't signed in yet, or any of their accounts. It only
matches a Google or GitHub sign-in whose provider verified that address; an
email typed into an email/password signup never counts.

```sql
-- By email, works before they sign in with Google or GitHub
insert into public.admins (email, note) values ('jane@cursor.com', 'plugin moderation');

-- By account (check it's the right one first)
insert into public.admins (user_id, note)
select id, 'plugin moderation' from auth.users where email = 'jane@cursor.com';

-- Remove
delete from public.admins where email = 'jane@cursor.com';
```

### How access works

- Supabase Auth is the OAuth 2.1 authorization server. Cursor signs in through
a pre-registered public OAuth client (PKCE, no secret); dynamic client
registration stays off.
- The consent page (`/oauth/consent`) only lets admins approve, and only for
the allowlisted OAuth client. The production client's ID is built into
`src/lib/mcp/auth.ts`; a comma-separated `MCP_OAUTH_CLIENT_IDS` replaces it,
for example for a local Supabase stack.
- `/api/mcp` verifies every bearer token (signature and expiry via
`getClaims`, plus a session check with the Auth server), requires the
allowlisted `client_id`, and requires the user to be an admin. Deleting someone's `admins` row or signing them out cuts off MCP
access immediately. Website session tokens are not accepted by the MCP
server, and MCP tokens are not accepted as website sessions.

### Setup

1. In the Supabase dashboard, enable **Authentication → OAuth Server**, set the
authorization path to `/oauth/consent`, and leave dynamic client
registration off. The Site URL must be the app's origin. Use asymmetric JWT
signing keys if clients request the `openid` scope. Locally,
`supabase/config.toml` already enables this.
2. Apply the migrations (adds `plugin_moderation_events` and `admins`) and add
the admins. When moving off the old `ADMIN_USER_IDS` env var, copy its IDs
in before deploying, then delete `ADMIN_USER_IDS` and
`NEXT_PUBLIC_ADMIN_USER_IDS`:

```sql
insert into public.admins (user_id, note)
select btrim(id)::uuid, 'migrated from ADMIN_USER_IDS'
from unnest(string_to_array('<ADMIN_USER_IDS value>', ',')) as id;
```

3. Production's OAuth client is already registered and built in. For another
Supabase project, register one and set `MCP_OAUTH_CLIENT_IDS` to the
printed client ID:

```bash
cd apps/cursor && bun run mcp:create-oauth-client
```

It registers Cursor's fixed callbacks
(`https://www.cursor.com/agents/mcp/oauth/callback` and
`http://localhost:8787/callback`); pass `--redirect-uri <url>` for others.
4. Add the `mcp-moderation` rate limit rule (60 requests / 1h, keyed by user)
in Vercel Firewall. Until it exists, write tools aren't rate limited and
the server logs an error.
5. Admins add the server to their Cursor `mcp.json`:

```json
{
"mcpServers": {
"cursor-directory": {
"url": "https://cursor.directory/api/mcp",
"auth": {
"CLIENT_ID": "12bb4179-e29a-4f99-a718-335ef4d0edb2",
"scopes": ["email"]
}
}
}
}
```

Cursor opens the consent page on first use. Keep the write tools out of
Cursor's auto-run allowlist so each change is approved by a person.

Prefer this per-admin `mcp.json` entry over a Cursor Team MCP server: team
servers are also available to Cloud Agents, which run tools without asking
for approval, and Cursor's docs don't say whether team servers accept a
pre-registered OAuth client ID.
15 changes: 8 additions & 7 deletions apps/cursor/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,14 @@ NEXT_PUBLIC_SUPABASE_URL=<your_supabase_project_url>
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=<your_supabase_publishable_key>
SUPABASE_SECRET_KEY=<your_supabase_secret_key>

# Admin allow-list (comma-separated Supabase user IDs).
# NEXT_PUBLIC_ADMIN_USER_IDS mirrors ADMIN_USER_IDS to the browser so admin-only
# UI (e.g. the verify controls on a plugin page) can render conditionally.
# Server-side enforcement still uses ADMIN_USER_IDS — the public copy is purely
# for UI gating.
ADMIN_USER_IDS=
NEXT_PUBLIC_ADMIN_USER_IDS=
# Admins are rows in the public.admins table (see README), not env vars.

# Vercel Firewall — backs the rate limiters in src/lib/rate-limit.ts.
# Configure these rules in Vercel dashboard → Firewall (rate limit IDs):
# install-per-plugin 3 / 24h
# install-global 20 / 1h
# plugin-scan 5 / 1h
# mcp-moderation 60 / 1h
# In production, no env vars are required (the SDK uses VERCEL_* automatically).
# Set this to a deployed host (e.g. preview URL) to exercise rate limits in
# local dev; otherwise checkRateLimit is a no-op outside production.
Expand All @@ -36,3 +31,9 @@ GITHUB_TOKEN=

# Shared secret guarding /api/cron/* routes against unauthenticated callers.
CRON_SECRET=

# Community Plugins MCP server (/api/mcp). Comma-separated Supabase OAuth
# client IDs the MCP server accepts. Unset or empty, it accepts the production
# client built into src/lib/mcp/auth.ts. For a local Supabase stack, set it to
# the ID `bun run mcp:create-oauth-client` prints (see README).
MCP_OAUTH_CLIENT_IDS=
13 changes: 13 additions & 0 deletions apps/cursor/next.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,19 @@ const nextConfig = {
root: resolve(__dirname, "../.."),
},
serverExternalPackages: ["@cursor/sdk"],
headers: async () => {
return [
{
// The OAuth consent screen must never render inside another site's
// frame (clickjacking an admin into approving).
source: "/oauth/:path*",
headers: [
{ key: "X-Frame-Options", value: "DENY" },
{ key: "Content-Security-Policy", value: "frame-ancestors 'none'" },
],
},
];
},
redirects: async () => {
return [
{
Expand Down
5 changes: 4 additions & 1 deletion apps/cursor/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,13 @@
"lint": "next lint",
"typecheck": "tsc --noEmit",
"seed:extract": "bun run --env-file=.env src/scripts/extract-from-github.ts",
"seed:insert": "bun run --env-file=.env src/scripts/insert-from-jsonl.ts"
"seed:insert": "bun run --env-file=.env src/scripts/insert-from-jsonl.ts",
"mcp:create-oauth-client": "bun run --env-file=.env src/scripts/create-mcp-oauth-client.ts"
},
"dependencies": {
"@cursor/sdk": "^1.0.12",
"@hookform/resolvers": "^5.2.2",
"@modelcontextprotocol/server": "^2.3.1",
"@radix-ui/react-alert-dialog": "1.1.15",
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-dialog": "^1.1.15",
Expand All @@ -41,6 +43,7 @@
"framer-motion": "^12.38.0",
"fuse.js": "^7.3.0",
"lucide-react": "^1.14.0",
"mcp-handler": "^2.2.0",
"nanoid": "^5.1.11",
"next": "16.2.6",
"next-safe-action": "^8.5.2",
Expand Down
16 changes: 16 additions & 0 deletions apps/cursor/src/actions/admin-status.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
"use server";

import { isAdmin } from "@/utils/admin";
import { getSession } from "@/utils/supabase/auth";
import { actionClient } from "./safe-action";

/**
* Tells client components whether to render admin-only UI. Only a UI hint:
* every admin action is checked again on the server.
*/
export const getAdminStatusAction = actionClient
.metadata({ actionName: "get-admin-status" })
.action(async () => {
const session = await getSession();
return { isAdmin: session ? await isAdmin(session.user.id) : false };
});
72 changes: 72 additions & 0 deletions apps/cursor/src/actions/oauth-consent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
"use server";

import { z } from "zod";
import { getMcpAuthorizationDetails, isMcpClient } from "@/lib/mcp/auth";
import { createClient } from "@/utils/supabase/server";
import {
ActionError,
adminActionClient,
authActionClient,
} from "./safe-action";

const authorizationSchema = z.object({
authorizationId: z.string().trim().min(1).max(200),
});

/**
* Approves an OAuth authorization request from the consent page. Re-checks
* what the page showed instead of trusting it: the signed-in user is an admin
* (adminActionClient) and the requesting client is a registered MCP client.
*/
export const approveOAuthAuthorizationAction = adminActionClient
.metadata({ actionName: "approve-oauth-authorization" })
.schema(authorizationSchema)
.action(async ({ parsedInput: { authorizationId } }) => {
const supabase = await createClient();
const { data: details, error } = await getMcpAuthorizationDetails(
supabase,
authorizationId,
);

if (error || !details) {
throw new ActionError(
"This authorization request has expired. Start again from Cursor.",
);
}

// Already approved earlier (only possible for allowlisted clients): the
// Auth server has issued the redirect.
if (!("authorization_id" in details)) {
return { redirectUrl: details.redirect_url };
}

if (!isMcpClient(details.client.id)) {
throw new ActionError("This application is not allowed to connect.");
}

const { data, error: approveError } =
await supabase.auth.oauth.approveAuthorization(authorizationId);

if (approveError || !data) {
throw new ActionError(
"Could not approve the request. Start again from Cursor.",
);
}

return { redirectUrl: data.redirect_url };
});

export const denyOAuthAuthorizationAction = authActionClient
.metadata({ actionName: "deny-oauth-authorization" })
.schema(authorizationSchema)
.action(async ({ parsedInput: { authorizationId } }) => {
const supabase = await createClient();
const { data, error } =
await supabase.auth.oauth.denyAuthorization(authorizationId);

if (error || !data) {
throw new ActionError("This authorization request has expired.");
}

return { redirectUrl: data.redirect_url };
});
Loading
Loading