+ Signed in as{" "}
+
+ {session.user.email ?? session.user.id}
+
+ .
+
+ );
+
+ // The user approved this client before, so the Auth server already issued
+ // the authorization code when we fetched the details. Still require a click
+ // instead of redirecting, so a crafted link can't silently complete
+ // someone else's sign-in.
+ if (!("authorization_id" in details)) {
+ const continueUrl = parseHttpUrl(details.redirect_url);
+ if (!admin || !continueUrl) {
+ return (
+
+ {signedInAs}
+
+ The Community Plugins MCP server is only available to Cursor
+ Directory admins.
+
+
+
+ );
+ }
+ return (
+
+ {signedInAs}
+
+ You approved this application before. Continue to finish signing in at{" "}
+ {continueUrl.host}.
+
+ );
+}
diff --git a/apps/cursor/src/components/github-signin.tsx b/apps/cursor/src/components/github-signin.tsx
index e65a8178..96efa835 100644
--- a/apps/cursor/src/components/github-signin.tsx
+++ b/apps/cursor/src/components/github-signin.tsx
@@ -18,7 +18,7 @@ export function GithubSignin({ redirectTo }: { redirectTo?: string }) {
supabase.auth.signInWithOAuth({
provider: "github",
options: {
- redirectTo: `${window.location.origin}/auth/callback?next=${next}`,
+ redirectTo: `${window.location.origin}/auth/callback?next=${encodeURIComponent(next)}`,
},
});
}}
diff --git a/apps/cursor/src/components/google-signin.tsx b/apps/cursor/src/components/google-signin.tsx
index f7a0b63a..221f52ee 100644
--- a/apps/cursor/src/components/google-signin.tsx
+++ b/apps/cursor/src/components/google-signin.tsx
@@ -18,7 +18,7 @@ export function GoogleSignin({ redirectTo }: { redirectTo?: string }) {
supabase.auth.signInWithOAuth({
provider: "google",
options: {
- redirectTo: `${window.location.origin}/auth/callback?next=${next}`,
+ redirectTo: `${window.location.origin}/auth/callback?next=${encodeURIComponent(next)}`,
},
});
}}
diff --git a/apps/cursor/src/utils/supabase/auth.ts b/apps/cursor/src/utils/supabase/auth.ts
index b5fe27c0..f817f81a 100644
--- a/apps/cursor/src/utils/supabase/auth.ts
+++ b/apps/cursor/src/utils/supabase/auth.ts
@@ -16,12 +16,16 @@ export type VerifiedSession = {
* authorization decision on the server. `getClaims()` verifies the JWT
* (against the project's JWKS, or via the Auth server for symmetric keys)
* before we trust `sub`.
+ *
+ * Tokens that carry a `client_id` were issued to an OAuth client (the MCP
+ * server) and only grant what that client allows, so they are not accepted
+ * as website sessions.
*/
export async function getSession(): Promise {
const supabase = await createClient();
const { data, error } = await supabase.auth.getClaims();
- if (error || !data?.claims.sub) {
+ if (error || !data?.claims.sub || data.claims.client_id) {
return null;
}
diff --git a/supabase/config.toml b/supabase/config.toml
index ffa7fc71..4b7827b4 100644
--- a/supabase/config.toml
+++ b/supabase/config.toml
@@ -363,13 +363,16 @@ enabled = false
# Obtain from https://clerk.com/setup/supabase
# domain = "example.clerk.accounts.dev"
-# OAuth server configuration
+# OAuth server configuration. Supabase Auth issues the access tokens for the
+# Community Plugins MCP server (/api/mcp); the consent UI lives in the app at
+# `authorization_url_path` (apps/cursor/src/app/oauth/consent).
[auth.oauth_server]
# Enable OAuth server functionality
-enabled = false
+enabled = true
# Path for OAuth consent flow UI
authorization_url_path = "/oauth/consent"
-# Allow dynamic client registration
+# Keep off: MCP clients use a pre-registered OAuth client instead of
+# registering themselves.
allow_dynamic_registration = false
[edge_runtime]
From 762213207eb4cbc69dbe3f0cb9f1f0535821c8ec Mon Sep 17 00:00:00 2001
From: Cursor Agent
Date: Wed, 14 Oct 2026 17:33:08 +0000
Subject: [PATCH 04/13] Document the Community Plugins MCP server
Co-authored-by: Eric Zakariasson
---
AGENTS.md | 2 ++
README.md | 68 +++++++++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 70 insertions(+)
diff --git a/AGENTS.md b/AGENTS.md
index b2d1a7b2..8939591d 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -22,3 +22,5 @@ Lint from repo root: `bunx biome ci .` (matches CI in `.github/workflows/ci.yml`
- 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:/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.
diff --git a/README.md b/README.md
index 25bd9c7f..814c56c2 100644
--- a/README.md
+++ b/README.md
@@ -132,3 +132,71 @@ 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.
+
+### 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 (`ADMIN_USER_IDS`)
+ approve, and only for client IDs listed in `MCP_OAUTH_CLIENT_IDS`.
+- `/api/mcp` verifies every bearer token (signature and expiry via
+ `getClaims`, plus a session check with the Auth server), requires a
+ `client_id` from `MCP_OAUTH_CLIENT_IDS`, and requires the user to be an
+ admin. Removing someone from `ADMIN_USER_IDS` 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`).
+3. Register the OAuth client and note 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 ` for others.
+4. Set `MCP_OAUTH_CLIENT_IDS` to that client ID and add the `mcp-moderation`
+ rate limit rule (60 requests / 1h, keyed by user) in Vercel Firewall.
+5. Admins add the server to their Cursor `mcp.json`:
+
+ ```json
+ {
+ "mcpServers": {
+ "cursor-directory": {
+ "url": "https://cursor.directory/api/mcp",
+ "auth": { "CLIENT_ID": "", "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.
From 25b646ce7509e08359b08f70db12f6ce40f7913d Mon Sep 17 00:00:00 2001
From: Cursor Agent
Date: Wed, 14 Oct 2026 17:50:13 +0000
Subject: [PATCH 05/13] Look up moderation history of deleted plugins
list_moderation_events resolved its plugin filter through the plugins
table, so a deleted plugin's audit history could not be queried. Match on
the event's plugin_id or plugin_slug instead.
Co-authored-by: Eric Zakariasson
---
apps/cursor/src/lib/mcp/tools.ts | 8 ++++++--
1 file changed, 6 insertions(+), 2 deletions(-)
diff --git a/apps/cursor/src/lib/mcp/tools.ts b/apps/cursor/src/lib/mcp/tools.ts
index cbd71b6e..86432cf9 100644
--- a/apps/cursor/src/lib/mcp/tools.ts
+++ b/apps/cursor/src/lib/mcp/tools.ts
@@ -388,8 +388,12 @@ export function registerModerationTools(server: McpServer) {
)
.order("created_at", { ascending: false })
.limit(limit);
- if (plugin)
- request = request.eq("plugin_id", await resolvePluginId(plugin));
+ // Match on the event itself so deleted plugins keep their history.
+ if (plugin) {
+ request = UUID_RE.test(plugin)
+ ? request.eq("plugin_id", plugin)
+ : request.eq("plugin_slug", plugin);
+ }
const { data, error } = await request;
if (error) {
From e85b5431f12f35fe0e057c7b0d05521ced79aaa7 Mon Sep 17 00:00:00 2001
From: Cursor Agent
Date: Wed, 14 Oct 2026 17:57:00 +0000
Subject: [PATCH 06/13] Only auto-approve allowlisted clients on the consent
page
For a client the user approved before, the Auth server approves on the
authorization-details fetch and returns only the redirect URL, so the
consent page could not check the client against MCP_OAUTH_CLIENT_IDS. A
client removed from the allowlist could still get a code (and a user
token) through the Continue screen.
Revoke the user's grants to clients outside the allowlist before fetching
the details, so any other client comes back as a pending request that the
consent page and approve action refuse.
Co-authored-by: Eric Zakariasson
---
apps/cursor/src/actions/oauth-consent.ts | 11 +++++---
apps/cursor/src/app/oauth/consent/page.tsx | 16 ++++++-----
apps/cursor/src/lib/mcp/auth.ts | 32 +++++++++++++++++++++-
3 files changed, 47 insertions(+), 12 deletions(-)
diff --git a/apps/cursor/src/actions/oauth-consent.ts b/apps/cursor/src/actions/oauth-consent.ts
index 4f89a21b..83095da0 100644
--- a/apps/cursor/src/actions/oauth-consent.ts
+++ b/apps/cursor/src/actions/oauth-consent.ts
@@ -1,7 +1,7 @@
"use server";
import { z } from "zod";
-import { isMcpClient } from "@/lib/mcp/auth";
+import { getMcpAuthorizationDetails, isMcpClient } from "@/lib/mcp/auth";
import { createClient } from "@/utils/supabase/server";
import {
ActionError,
@@ -23,8 +23,10 @@ export const approveOAuthAuthorizationAction = adminActionClient
.schema(authorizationSchema)
.action(async ({ parsedInput: { authorizationId } }) => {
const supabase = await createClient();
- const { data: details, error } =
- await supabase.auth.oauth.getAuthorizationDetails(authorizationId);
+ const { data: details, error } = await getMcpAuthorizationDetails(
+ supabase,
+ authorizationId,
+ );
if (error || !details) {
throw new ActionError(
@@ -32,7 +34,8 @@ export const approveOAuthAuthorizationAction = adminActionClient
);
}
- // Already approved earlier: the Auth server has issued the redirect.
+ // 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 };
}
diff --git a/apps/cursor/src/app/oauth/consent/page.tsx b/apps/cursor/src/app/oauth/consent/page.tsx
index ca812d3d..20c8618d 100644
--- a/apps/cursor/src/app/oauth/consent/page.tsx
+++ b/apps/cursor/src/app/oauth/consent/page.tsx
@@ -3,7 +3,7 @@ import Link from "next/link";
import { redirect } from "next/navigation";
import { type ReactNode, Suspense } from "react";
import { buttonVariants } from "@/components/ui/button";
-import { isMcpClient } from "@/lib/mcp/auth";
+import { getMcpAuthorizationDetails, isMcpClient } from "@/lib/mcp/auth";
import { isAdmin } from "@/utils/admin";
import { getSession } from "@/utils/supabase/auth";
import { createClient } from "@/utils/supabase/server";
@@ -87,8 +87,10 @@ async function ConsentContent({
}
const supabase = await createClient();
- const { data: details, error } =
- await supabase.auth.oauth.getAuthorizationDetails(authorizationId);
+ const { data: details, error } = await getMcpAuthorizationDetails(
+ supabase,
+ authorizationId,
+ );
if (error || !details) {
return (
@@ -110,10 +112,10 @@ async function ConsentContent({
);
- // The user approved this client before, so the Auth server already issued
- // the authorization code when we fetched the details. Still require a click
- // instead of redirecting, so a crafted link can't silently complete
- // someone else's sign-in.
+ // The user approved this (allowlisted) client before, so the Auth server
+ // already issued the authorization code when we fetched the details. Still
+ // require a click instead of redirecting, so a crafted link can't silently
+ // complete someone else's sign-in.
if (!("authorization_id" in details)) {
const continueUrl = parseHttpUrl(details.redirect_url);
if (!admin || !continueUrl) {
diff --git a/apps/cursor/src/lib/mcp/auth.ts b/apps/cursor/src/lib/mcp/auth.ts
index af4523b0..53e601ac 100644
--- a/apps/cursor/src/lib/mcp/auth.ts
+++ b/apps/cursor/src/lib/mcp/auth.ts
@@ -1,6 +1,10 @@
import "server-only";
import type { AuthInfo } from "@modelcontextprotocol/server";
-import { createClient, type SupabaseClient } from "@supabase/supabase-js";
+import {
+ type AuthOAuthAuthorizationDetailsResponse,
+ createClient,
+ type SupabaseClient,
+} from "@supabase/supabase-js";
import { isAdmin } from "@/utils/admin";
export const MCP_RESOURCE_PATH = "/api/mcp";
@@ -28,6 +32,32 @@ export function isMcpClient(clientId: string | null | undefined): boolean {
return !!clientId && getMcpClientIds().includes(clientId);
}
+/**
+ * `getAuthorizationDetails` for the consent flow. For a client the user
+ * approved before, the Auth server approves on this call and returns only the
+ * redirect URL, without the client. Revoking the user's grants to clients
+ * outside MCP_OAUTH_CLIENT_IDS first means only allowlisted clients are ever
+ * approved that way; any other client comes back as a pending request, which
+ * the consent page refuses.
+ */
+export async function getMcpAuthorizationDetails(
+ supabase: SupabaseClient,
+ authorizationId: string,
+): Promise {
+ const { data: grants, error } = await supabase.auth.oauth.listGrants();
+ if (error) return { data: null, error };
+
+ for (const { client } of grants) {
+ if (isMcpClient(client.id)) continue;
+ const { error: revokeError } = await supabase.auth.oauth.revokeGrant({
+ clientId: client.id,
+ });
+ if (revokeError) return { data: null, error: revokeError };
+ }
+
+ return supabase.auth.oauth.getAuthorizationDetails(authorizationId);
+}
+
export type McpAuthExtra = {
userId: string;
email: string | null;
From e46709749e80f0bb48c64ee0bee659e99572b11f Mon Sep 17 00:00:00 2001
From: Cursor Agent
Date: Wed, 14 Oct 2026 17:57:00 +0000
Subject: [PATCH 07/13] Pass the block reason to blockPlugin as a named option
Two adjacent string parameters (pluginId, reason) were easy to swap.
Co-authored-by: Eric Zakariasson
---
apps/cursor/src/lib/mcp/tools.ts | 2 +-
apps/cursor/src/lib/plugins/moderation.ts | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/apps/cursor/src/lib/mcp/tools.ts b/apps/cursor/src/lib/mcp/tools.ts
index 86432cf9..ae2c0046 100644
--- a/apps/cursor/src/lib/mcp/tools.ts
+++ b/apps/cursor/src/lib/mcp/tools.ts
@@ -519,7 +519,7 @@ export function registerModerationTools(server: McpServer) {
}
return changed(
"Blocked",
- await blockPlugin(await beginWrite(admin), state.id, reason),
+ await blockPlugin(await beginWrite(admin), state.id, { reason }),
);
}),
);
diff --git a/apps/cursor/src/lib/plugins/moderation.ts b/apps/cursor/src/lib/plugins/moderation.ts
index 6c8dea53..920ac324 100644
--- a/apps/cursor/src/lib/plugins/moderation.ts
+++ b/apps/cursor/src/lib/plugins/moderation.ts
@@ -192,7 +192,7 @@ export function approveFlaggedPlugin(ctx: ModerationContext, pluginId: string) {
export function blockPlugin(
ctx: ModerationContext,
pluginId: string,
- reason?: string,
+ { reason }: { reason?: string } = {},
) {
return updatePlugin(
ctx,
From 2da90db25eab9c82a766aedb8a84901f8ca9616e Mon Sep 17 00:00:00 2001
From: Cursor Agent
Date: Wed, 14 Oct 2026 18:29:50 +0000
Subject: [PATCH 08/13] Manage admins in a Supabase table instead of
ADMIN_USER_IDS
Admins are now rows in public.admins, checked through the is_admin SQL
function by the web admin, the OAuth consent page, and the MCP server.
Rows take effect on the next request, so granting or revoking access no
longer needs a redeploy.
A row names either a user_id or an email. Email rows only match a Google
or GitHub identity whose provider verified that address, so an address
typed into an email/password signup can't claim them. The table has RLS
on with no policies, and only the service role can execute is_admin.
Admin-only UI on plugin pages now asks the server through a server
action instead of reading NEXT_PUBLIC_ADMIN_USER_IDS, so admin user IDs
no longer ship to the browser.
Co-authored-by: Eric Zakariasson
---
apps/cursor/.env.example | 8 +--
apps/cursor/src/actions/admin-status.ts | 16 ++++++
apps/cursor/src/actions/safe-action.ts | 2 +-
apps/cursor/src/app/admin/plugins/page.tsx | 2 +-
apps/cursor/src/app/oauth/consent/page.tsx | 2 +-
.../components/plugins/plugin-admin-panel.tsx | 19 ++-----
.../components/plugins/verify-controls.tsx | 14 +----
apps/cursor/src/hooks/use-is-admin.ts | 43 +++++++++++++++
apps/cursor/src/lib/mcp/auth.ts | 2 +-
apps/cursor/src/utils/admin.ts | 39 ++++++-------
supabase/migrations/20261014180820_admins.sql | 55 +++++++++++++++++++
11 files changed, 144 insertions(+), 58 deletions(-)
create mode 100644 apps/cursor/src/actions/admin-status.ts
create mode 100644 apps/cursor/src/hooks/use-is-admin.ts
create mode 100644 supabase/migrations/20261014180820_admins.sql
diff --git a/apps/cursor/.env.example b/apps/cursor/.env.example
index fd75f843..82169cda 100644
--- a/apps/cursor/.env.example
+++ b/apps/cursor/.env.example
@@ -3,13 +3,7 @@ NEXT_PUBLIC_SUPABASE_URL=
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=
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):
diff --git a/apps/cursor/src/actions/admin-status.ts b/apps/cursor/src/actions/admin-status.ts
new file mode 100644
index 00000000..4ada9d71
--- /dev/null
+++ b/apps/cursor/src/actions/admin-status.ts
@@ -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 };
+ });
diff --git a/apps/cursor/src/actions/safe-action.ts b/apps/cursor/src/actions/safe-action.ts
index ff2b7bfd..fbec5848 100644
--- a/apps/cursor/src/actions/safe-action.ts
+++ b/apps/cursor/src/actions/safe-action.ts
@@ -58,7 +58,7 @@ export const authActionClient = actionClient.use(async ({ next }) => {
});
export const adminActionClient = authActionClient.use(async ({ next, ctx }) => {
- if (!isAdmin(ctx.userId)) {
+ if (!(await isAdmin(ctx.userId))) {
throw new ActionError("Unauthorized: admin access required");
}
diff --git a/apps/cursor/src/app/admin/plugins/page.tsx b/apps/cursor/src/app/admin/plugins/page.tsx
index 93863159..2e4de644 100644
--- a/apps/cursor/src/app/admin/plugins/page.tsx
+++ b/apps/cursor/src/app/admin/plugins/page.tsx
@@ -22,7 +22,7 @@ export const metadata: Metadata = {
async function AdminPluginsContent() {
const session = await getSession();
- if (!session || !isAdmin(session.user.id)) {
+ if (!session || !(await isAdmin(session.user.id))) {
redirect("/");
}
diff --git a/apps/cursor/src/app/oauth/consent/page.tsx b/apps/cursor/src/app/oauth/consent/page.tsx
index 20c8618d..a7024a17 100644
--- a/apps/cursor/src/app/oauth/consent/page.tsx
+++ b/apps/cursor/src/app/oauth/consent/page.tsx
@@ -101,7 +101,7 @@ async function ConsentContent({
);
}
- const admin = isAdmin(session.user.id);
+ const admin = await isAdmin(session.user.id);
const signedInAs = (
Signed in as{" "}
diff --git a/apps/cursor/src/components/plugins/plugin-admin-panel.tsx b/apps/cursor/src/components/plugins/plugin-admin-panel.tsx
index c30b93b4..ca8ea8f1 100644
--- a/apps/cursor/src/components/plugins/plugin-admin-panel.tsx
+++ b/apps/cursor/src/components/plugins/plugin-admin-panel.tsx
@@ -12,7 +12,7 @@ import {
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useAction } from "next-safe-action/hooks";
-import { type ReactNode, useEffect, useState, useTransition } from "react";
+import { type ReactNode, useState, useTransition } from "react";
import { toast } from "sonner";
import {
approveFlaggedPluginAction,
@@ -35,10 +35,9 @@ import {
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Button, buttonVariants } from "@/components/ui/button";
+import { useIsAdmin } from "@/hooks/use-is-admin";
import type { FlagSeverity, PluginRow, ScanStatus } from "@/lib/plugins/types";
import { cn } from "@/lib/utils";
-import { isAdminClient } from "@/utils/admin";
-import { createClient } from "@/utils/supabase/client";
const severityClass: Record = {
high: "bg-red-500/15 text-red-500 border-red-500/30",
@@ -81,23 +80,15 @@ type Props = {
/**
* Moderation toolbar shown only to admins on the public plugin page, so the
* queue actions (approve, re-scan, confirm flag, unpublish, delete) don't
- * require a round trip to /admin/plugins. Rendering is gated client-side via
- * NEXT_PUBLIC_ADMIN_USER_IDS; every action is enforced server-side by
- * `adminActionClient`.
+ * require a round trip to /admin/plugins. Rendering is gated client-side by
+ * `useIsAdmin`; every action is enforced server-side by `adminActionClient`.
*/
export function PluginAdminPanel({ plugin }: Props) {
const router = useRouter();
- const [isAdmin, setIsAdmin] = useState(false);
+ const isAdmin = useIsAdmin();
const [confirmDelete, setConfirmDelete] = useState(false);
const [isRefreshing, startRefresh] = useTransition();
- useEffect(() => {
- const supabase = createClient();
- supabase.auth.getSession().then(({ data: { session } }) => {
- setIsAdmin(isAdminClient(session?.user.id ?? null));
- });
- }, []);
-
// The actions invalidate the detail cache tag; refresh so the panel and the
// rest of the page re-render with the new row. The transition keeps the
// buttons disabled until the fresh data has actually arrived.
diff --git a/apps/cursor/src/components/plugins/verify-controls.tsx b/apps/cursor/src/components/plugins/verify-controls.tsx
index 78306b49..7871cd77 100644
--- a/apps/cursor/src/components/plugins/verify-controls.tsx
+++ b/apps/cursor/src/components/plugins/verify-controls.tsx
@@ -2,31 +2,21 @@
import { BadgeCheck, BadgeMinus, Check, Loader2, X } from "lucide-react";
import { useAction } from "next-safe-action/hooks";
-import { useEffect, useState } from "react";
import { toast } from "sonner";
import {
dismissVerificationRequestAction,
setPluginVerifiedAction,
} from "@/actions/verify-plugin";
import { Button } from "@/components/ui/button";
+import { useIsAdmin } from "@/hooks/use-is-admin";
import type { PluginRow } from "@/lib/plugins/types";
-import { isAdminClient } from "@/utils/admin";
-import { createClient } from "@/utils/supabase/client";
type Props = {
plugin: PluginRow;
};
export function VerifyControls({ plugin }: Props) {
- const [isAdmin, setIsAdmin] = useState(false);
-
- useEffect(() => {
- const supabase = createClient();
- supabase.auth.getSession().then(({ data: { session } }) => {
- const userId = session?.user.id ?? null;
- setIsAdmin(isAdminClient(userId));
- });
- }, []);
+ const isAdmin = useIsAdmin();
const requestPending = !!plugin.verification_requested_at;
diff --git a/apps/cursor/src/hooks/use-is-admin.ts b/apps/cursor/src/hooks/use-is-admin.ts
new file mode 100644
index 00000000..af035021
--- /dev/null
+++ b/apps/cursor/src/hooks/use-is-admin.ts
@@ -0,0 +1,43 @@
+import { useEffect, useState } from "react";
+import { getAdminStatusAction } from "@/actions/admin-status";
+import { createClient } from "@/utils/supabase/client";
+
+// Shared by every admin-only component on a page so they make one lookup;
+// fetched again when a different user is signed in.
+let cached: { userId: string; isAdmin: Promise } | null = null;
+
+async function fetchIsAdmin(): Promise {
+ const {
+ data: { session },
+ } = await createClient().auth.getSession();
+ const userId = session?.user.id;
+ if (!userId) return false;
+
+ if (cached?.userId !== userId) {
+ cached = {
+ userId,
+ isAdmin: getAdminStatusAction().then(
+ (result) => result?.data?.isAdmin === true,
+ () => false,
+ ),
+ };
+ }
+ return cached.isAdmin;
+}
+
+/** Whether to show admin-only UI. The server enforces every admin action. */
+export function useIsAdmin(): boolean {
+ const [isAdmin, setIsAdmin] = useState(false);
+
+ useEffect(() => {
+ let active = true;
+ fetchIsAdmin().then((value) => {
+ if (active) setIsAdmin(value);
+ });
+ return () => {
+ active = false;
+ };
+ }, []);
+
+ return isAdmin;
+}
diff --git a/apps/cursor/src/lib/mcp/auth.ts b/apps/cursor/src/lib/mcp/auth.ts
index 53e601ac..aed46b38 100644
--- a/apps/cursor/src/lib/mcp/auth.ts
+++ b/apps/cursor/src/lib/mcp/auth.ts
@@ -118,7 +118,7 @@ export async function verifyMcpAccessToken(
const extra: McpAuthExtra = {
userId: claims.sub,
email: claims.email ?? null,
- isAdmin: isAdmin(claims.sub),
+ isAdmin: await isAdmin(claims.sub),
};
return {
diff --git a/apps/cursor/src/utils/admin.ts b/apps/cursor/src/utils/admin.ts
index 139444ed..843637ef 100644
--- a/apps/cursor/src/utils/admin.ts
+++ b/apps/cursor/src/utils/admin.ts
@@ -1,25 +1,22 @@
-export function getAdminIds(): string[] {
- return (process.env.ADMIN_USER_IDS ?? "")
- .split(",")
- .map((id) => id.trim())
- .filter(Boolean);
-}
+import "server-only";
+import { createClient } from "@/utils/supabase/admin-client";
-export function isAdmin(userId: string): boolean {
- return getAdminIds().includes(userId);
-}
+/**
+ * Whether the user is an admin (web admin and the Community Plugins MCP
+ * server). Admins are rows in `public.admins`, matched by user ID or by an
+ * email that Google or GitHub verified for one of the user's identities; see
+ * the `is_admin` SQL function. Any lookup failure counts as "not an admin".
+ */
+export async function isAdmin(userId: string): Promise {
+ const supabase = await createClient();
+ const { data, error } = await supabase.rpc("is_admin", {
+ p_user_id: userId,
+ });
-// Browser-safe admin check. Reads NEXT_PUBLIC_ADMIN_USER_IDS, which is a
-// non-sensitive mirror used purely for conditionally rendering admin UI;
-// server-side enforcement still goes through `isAdmin` above.
-export function getPublicAdminIds(): string[] {
- return (process.env.NEXT_PUBLIC_ADMIN_USER_IDS ?? "")
- .split(",")
- .map((id) => id.trim())
- .filter(Boolean);
-}
+ if (error) {
+ console.error("[admin] is_admin lookup failed:", error.message);
+ return false;
+ }
-export function isAdminClient(userId: string | null | undefined): boolean {
- if (!userId) return false;
- return getPublicAdminIds().includes(userId);
+ return data === true;
}
diff --git a/supabase/migrations/20261014180820_admins.sql b/supabase/migrations/20261014180820_admins.sql
new file mode 100644
index 00000000..dd3f1774
--- /dev/null
+++ b/supabase/migrations/20261014180820_admins.sql
@@ -0,0 +1,55 @@
+-- Admins of the web admin (/admin/plugins) and the Community Plugins MCP
+-- server, managed from the Supabase dashboard instead of an env var.
+--
+-- Fill in either column:
+-- user_id an existing account (auth.users.id)
+-- email someone who hasn't signed in yet, or any account of theirs. It
+-- only matches a Google or GitHub identity whose provider verified
+-- that email (see is_admin), never an email the user typed in.
+create table if not exists public.admins (
+ id bigint generated always as identity primary key,
+ user_id uuid unique references auth.users (id) on delete cascade,
+ email text check (email like '%@%'),
+ note text,
+ created_at timestamptz not null default now(),
+ constraint admins_user_id_or_email check (user_id is not null or email is not null)
+);
+
+create unique index if not exists admins_email_idx
+ on public.admins (lower(btrim(email)));
+
+-- RLS on with no policies: the Data API roles cannot read or change who is an
+-- admin; the service role bypasses RLS.
+alter table public.admins enable row level security;
+revoke all on table public.admins from anon, authenticated;
+
+-- security definer so the service role can match against auth.identities,
+-- which only the Auth server writes. identity_data.email is stored lowercase
+-- and email_verified is only present when the provider verified the address.
+create or replace function public.is_admin(p_user_id uuid)
+returns boolean
+language sql
+stable
+security definer
+set search_path = ''
+as $$
+ select exists (
+ select 1
+ from public.admins a
+ where a.user_id = p_user_id
+ or (
+ a.email is not null
+ and exists (
+ select 1
+ from auth.identities i
+ where i.user_id = p_user_id
+ and i.provider in ('google', 'github')
+ and i.identity_data ->> 'email' = lower(btrim(a.email))
+ and coalesce((i.identity_data ->> 'email_verified')::boolean, false)
+ )
+ )
+ );
+$$;
+
+revoke execute on function public.is_admin(uuid) from public, anon, authenticated;
+grant execute on function public.is_admin(uuid) to service_role;
From d9c01f2bec3ffe3bd1a16f2af5e851bffc6726be Mon Sep 17 00:00:00 2001
From: Cursor Agent
Date: Wed, 14 Oct 2026 18:29:50 +0000
Subject: [PATCH 09/13] Document managing admins in the admins table
Explain how to add and remove admins by email or user ID, how to move
the existing ADMIN_USER_IDS into the table before deploying, and why a
per-admin mcp.json is preferable to a Cursor Team MCP server.
Co-authored-by: Eric Zakariasson
---
AGENTS.md | 2 +-
README.md | 49 ++++++++++++++++++++++++++++++++++++++++++++-----
2 files changed, 45 insertions(+), 6 deletions(-)
diff --git a/AGENTS.md b/AGENTS.md
index 8939591d..ec958a4c 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -16,7 +16,7 @@ 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 ('')`. 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.
diff --git a/README.md b/README.md
index 814c56c2..ea70b988 100644
--- a/README.md
+++ b/README.md
@@ -152,18 +152,42 @@ 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 (`ADMIN_USER_IDS`)
- approve, and only for client IDs listed in `MCP_OAUTH_CLIENT_IDS`.
+- The consent page (`/oauth/consent`) only lets admins approve, and only for
+ client IDs listed in `MCP_OAUTH_CLIENT_IDS`.
- `/api/mcp` verifies every bearer token (signature and expiry via
`getClaims`, plus a session check with the Auth server), requires a
`client_id` from `MCP_OAUTH_CLIENT_IDS`, and requires the user to be an
- admin. Removing someone from `ADMIN_USER_IDS` or signing them out cuts off
- MCP access immediately. Website session tokens are not accepted by the MCP
+ 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
@@ -173,7 +197,17 @@ MCP is recorded in the append-only `plugin_moderation_events` table.
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`).
+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('', ',')) as id;
+ ```
+
3. Register the OAuth client and note the printed client ID:
```bash
@@ -200,3 +234,8 @@ MCP is recorded in the append-only `plugin_moderation_events` table.
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.
From 9b6a6062a4595edeb5f51773122f11825964e222 Mon Sep 17 00:00:00 2001
From: ericzakariasson
Date: Wed, 14 Oct 2026 17:27:26 -0700
Subject: [PATCH 10/13] Build in the production MCP OAuth client ID
Changing production env vars needs Vercel project admin, and the OAuth
client ID isn't a secret: it's a public client whose ID every admin puts
in their mcp.json. Use the production client by default and keep
MCP_OAUTH_CLIENT_IDS as a replacement for other Supabase projects, such
as a local stack. The README's mcp.json snippet now has the real ID.
Co-authored-by: Cursor
---
README.md | 23 ++++++++++++-------
apps/cursor/.env.example | 6 ++---
apps/cursor/src/lib/mcp/auth.ts | 11 ++++++---
.../src/scripts/create-mcp-oauth-client.ts | 6 +++--
4 files changed, 30 insertions(+), 16 deletions(-)
diff --git a/README.md b/README.md
index ea70b988..6dc43fc5 100644
--- a/README.md
+++ b/README.md
@@ -182,11 +182,12 @@ delete from public.admins where email = 'jane@cursor.com';
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
- client IDs listed in `MCP_OAUTH_CLIENT_IDS`.
+ 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 a
- `client_id` from `MCP_OAUTH_CLIENT_IDS`, and requires the user to be an
- admin. Deleting someone's `admins` row or signing them out cuts off MCP
+ `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.
@@ -208,7 +209,9 @@ delete from public.admins where email = 'jane@cursor.com';
from unnest(string_to_array('', ',')) as id;
```
-3. Register the OAuth client and note the printed client 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
@@ -217,8 +220,9 @@ delete from public.admins where email = 'jane@cursor.com';
It registers Cursor's fixed callbacks
(`https://www.cursor.com/agents/mcp/oauth/callback` and
`http://localhost:8787/callback`); pass `--redirect-uri ` for others.
-4. Set `MCP_OAUTH_CLIENT_IDS` to that client ID and add the `mcp-moderation`
- rate limit rule (60 requests / 1h, keyed by user) in Vercel Firewall.
+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
@@ -226,7 +230,10 @@ delete from public.admins where email = 'jane@cursor.com';
"mcpServers": {
"cursor-directory": {
"url": "https://cursor.directory/api/mcp",
- "auth": { "CLIENT_ID": "", "scopes": ["email"] }
+ "auth": {
+ "CLIENT_ID": "12bb4179-e29a-4f99-a718-335ef4d0edb2",
+ "scopes": ["email"]
+ }
}
}
}
diff --git a/apps/cursor/.env.example b/apps/cursor/.env.example
index 82169cda..16d59657 100644
--- a/apps/cursor/.env.example
+++ b/apps/cursor/.env.example
@@ -33,7 +33,7 @@ GITHUB_TOKEN=
CRON_SECRET=
# Community Plugins MCP server (/api/mcp). Comma-separated Supabase OAuth
-# client IDs whose access tokens the MCP server accepts and that admins may
-# approve on /oauth/consent. Create the client with
-# `bun run mcp:create-oauth-client` (see README).
+# client IDs the MCP server accepts. Unset, 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=
diff --git a/apps/cursor/src/lib/mcp/auth.ts b/apps/cursor/src/lib/mcp/auth.ts
index aed46b38..7052a4e0 100644
--- a/apps/cursor/src/lib/mcp/auth.ts
+++ b/apps/cursor/src/lib/mcp/auth.ts
@@ -17,12 +17,17 @@ export function getMcpAuthorizationServerUrl(): string {
return `${process.env.NEXT_PUBLIC_SUPABASE_URL}/auth/v1`;
}
+// "Cursor (Community Plugins MCP)", the public client registered in the
+// production Supabase project. Not a secret: admins put it in their mcp.json.
+const PRODUCTION_MCP_CLIENT_ID = "12bb4179-e29a-4f99-a718-335ef4d0edb2";
+
/**
* OAuth client IDs whose access tokens the MCP server accepts and that the
- * consent page lets admins approve (comma-separated `MCP_OAUTH_CLIENT_IDS`).
+ * consent page lets admins approve. A comma-separated `MCP_OAUTH_CLIENT_IDS`
+ * replaces the production client, e.g. for a local Supabase stack.
*/
export function getMcpClientIds(): string[] {
- return (process.env.MCP_OAUTH_CLIENT_IDS ?? "")
+ return (process.env.MCP_OAUTH_CLIENT_IDS ?? PRODUCTION_MCP_CLIENT_ID)
.split(",")
.map((id) => id.trim())
.filter(Boolean);
@@ -36,7 +41,7 @@ export function isMcpClient(clientId: string | null | undefined): boolean {
* `getAuthorizationDetails` for the consent flow. For a client the user
* approved before, the Auth server approves on this call and returns only the
* redirect URL, without the client. Revoking the user's grants to clients
- * outside MCP_OAUTH_CLIENT_IDS first means only allowlisted clients are ever
+ * outside the allowlist first means only allowlisted clients are ever
* approved that way; any other client comes back as a pending request, which
* the consent page refuses.
*/
diff --git a/apps/cursor/src/scripts/create-mcp-oauth-client.ts b/apps/cursor/src/scripts/create-mcp-oauth-client.ts
index a9feab06..29e5f4ab 100644
--- a/apps/cursor/src/scripts/create-mcp-oauth-client.ts
+++ b/apps/cursor/src/scripts/create-mcp-oauth-client.ts
@@ -1,7 +1,8 @@
/**
* Registers the public OAuth client Cursor uses to sign in to the Community
* Plugins MCP server (/api/mcp) and prints its client ID, which goes into
- * MCP_OAUTH_CLIENT_IDS and the `auth.CLIENT_ID` of each admin's mcp.json.
+ * the app's allowlist (PRODUCTION_MCP_CLIENT_ID in src/lib/mcp/auth.ts, or
+ * MCP_OAUTH_CLIENT_IDS) and the `auth.CLIENT_ID` of each admin's mcp.json.
*
* Re-running is safe: an existing client with the same name is reused, and
* any missing redirect URIs are added to it.
@@ -96,7 +97,8 @@ client_id: ${client.client_id}
redirect_uris: ${client.redirect_uris.join(", ")}
Next steps:
- 1. Add the client ID to MCP_OAUTH_CLIENT_IDS in the app environment.
+ 1. Allowlist it: MCP_OAUTH_CLIENT_IDS in the app environment, or
+ PRODUCTION_MCP_CLIENT_ID in src/lib/mcp/auth.ts for production.
2. Admins add this to their Cursor mcp.json:
"cursor-directory": {
From cf902b55c1633feae5204e425cf35f47be83dfea Mon Sep 17 00:00:00 2001
From: ericzakariasson
Date: Wed, 14 Oct 2026 17:37:31 -0700
Subject: [PATCH 11/13] Don't log expired MCP tokens as unexpected errors
getClaims() throws for an expired token, which mcp-handler caught and
logged as an unexpected error before answering 401. Every admin's token
expires hourly, so treat it as an invalid token here. The route still
answers 401 invalid_token, which tells Cursor to refresh.
Co-authored-by: Cursor
---
apps/cursor/src/lib/mcp/auth.ts | 7 ++++---
1 file changed, 4 insertions(+), 3 deletions(-)
diff --git a/apps/cursor/src/lib/mcp/auth.ts b/apps/cursor/src/lib/mcp/auth.ts
index 7052a4e0..1fe0a926 100644
--- a/apps/cursor/src/lib/mcp/auth.ts
+++ b/apps/cursor/src/lib/mcp/auth.ts
@@ -101,10 +101,11 @@ export async function verifyMcpAccessToken(
if (!bearerToken) return undefined;
const supabase = getVerifier();
- const { data, error } = await supabase.auth.getClaims(bearerToken);
- if (error || !data) return undefined;
+ // getClaims throws, rather than returning an error, for an expired token.
+ const result = await supabase.auth.getClaims(bearerToken).catch(() => null);
+ if (!result || result.error || !result.data) return undefined;
- const { claims } = data;
+ const { claims } = result.data;
const clientId =
typeof claims.client_id === "string" ? claims.client_id : undefined;
From 19c90ba3dcf657c3fae14ec1e025f753df1a9e7f Mon Sep 17 00:00:00 2001
From: ericzakariasson
Date: Wed, 14 Oct 2026 17:53:11 -0700
Subject: [PATCH 12/13] Treat an empty MCP_OAUTH_CLIENT_IDS as unset
Vercel and .env files keep empty values, and .env.example lists the
variable empty, so an empty string disabled MCP instead of falling back
to the built-in production client.
Co-authored-by: Cursor
---
apps/cursor/.env.example | 6 +++---
apps/cursor/src/lib/mcp/auth.ts | 5 +++--
2 files changed, 6 insertions(+), 5 deletions(-)
diff --git a/apps/cursor/.env.example b/apps/cursor/.env.example
index 16d59657..2c8c647c 100644
--- a/apps/cursor/.env.example
+++ b/apps/cursor/.env.example
@@ -33,7 +33,7 @@ GITHUB_TOKEN=
CRON_SECRET=
# Community Plugins MCP server (/api/mcp). Comma-separated Supabase OAuth
-# client IDs the MCP server accepts. Unset, 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).
+# 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=
diff --git a/apps/cursor/src/lib/mcp/auth.ts b/apps/cursor/src/lib/mcp/auth.ts
index 1fe0a926..468172e9 100644
--- a/apps/cursor/src/lib/mcp/auth.ts
+++ b/apps/cursor/src/lib/mcp/auth.ts
@@ -24,10 +24,11 @@ const PRODUCTION_MCP_CLIENT_ID = "12bb4179-e29a-4f99-a718-335ef4d0edb2";
/**
* OAuth client IDs whose access tokens the MCP server accepts and that the
* consent page lets admins approve. A comma-separated `MCP_OAUTH_CLIENT_IDS`
- * replaces the production client, e.g. for a local Supabase stack.
+ * replaces the production client, e.g. for a local Supabase stack. Empty
+ * counts as unset, since Vercel and `.env` files keep empty values.
*/
export function getMcpClientIds(): string[] {
- return (process.env.MCP_OAUTH_CLIENT_IDS ?? PRODUCTION_MCP_CLIENT_ID)
+ return (process.env.MCP_OAUTH_CLIENT_IDS || PRODUCTION_MCP_CLIENT_ID)
.split(",")
.map((id) => id.trim())
.filter(Boolean);
From cc1c12f5fd4214e0524fd42851833f1452733752 Mon Sep 17 00:00:00 2001
From: ericzakariasson
Date: Wed, 14 Oct 2026 17:53:11 -0700
Subject: [PATCH 13/13] Pass the origin to pluginUrl as a named option
Two adjacent string parameters could be swapped without a type error.
Co-authored-by: Cursor
---
apps/cursor/src/lib/mcp/tools.ts | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/apps/cursor/src/lib/mcp/tools.ts b/apps/cursor/src/lib/mcp/tools.ts
index ae2c0046..8fad1662 100644
--- a/apps/cursor/src/lib/mcp/tools.ts
+++ b/apps/cursor/src/lib/mcp/tools.ts
@@ -131,7 +131,7 @@ async function beginWrite(admin: Admin) {
});
}
-function pluginUrl(origin: string, slug: string) {
+function pluginUrl(slug: string, { origin }: { origin: string }) {
return `${origin}/plugins/${slug}`;
}
@@ -140,7 +140,7 @@ function summarize(plugin: PluginRow, origin: string) {
id: plugin.id,
slug: plugin.slug,
name: plugin.name,
- url: pluginUrl(origin, plugin.slug),
+ url: pluginUrl(plugin.slug, { origin }),
live: plugin.active,
scan_status: plugin.scan_status,
flag_severity: plugin.flag_severity,
@@ -334,7 +334,7 @@ export function registerModerationTools(server: McpServer) {
id: row.id,
slug: row.slug,
name: row.name,
- url: pluginUrl(admin.origin, row.slug),
+ url: pluginUrl(row.slug, { origin: admin.origin }),
description: row.description,
repository: row.repository,
homepage: row.homepage,