Skip to content

Add a Community Plugins MCP server for admin moderation - #453

Merged
ericzakariasson merged 15 commits into
mainfrom
cursor/community-plugins-mcp-f627
Oct 15, 2026
Merged

ericzakariasson merged 15 commits into
mainfrom
cursor/community-plugins-mcp-f627

Conversation

@ericzakariasson

@ericzakariasson ericzakariasson commented Oct 14, 2026 •

Copy link
Copy Markdown
Member

Summary

Cursor Directory admins can moderate Community Plugins from Cursor through a new MCP server at /api/mcp. Supabase Auth's OAuth 2.1 server issues the access tokens, and admins approve the connection on a new consent page.

Admins are now rows in a new public.admins table instead of the ADMIN_USER_IDS env var. This covers both the web admin and the MCP server. Granting or revoking access takes effect on the next request, with no redeploy. Production already has the table, with Lee and Eric as admins and a row for Rohan that applies once he signs in. Anyone else in the old ADMIN_USER_IDS loses admin access when this deploys, until they're added to the table.

Stacked on #452 (server-side session verification). Merge #452 first: this PR's consent page and admin checks depend on it.

mcp_oauth_consent_and_moderation_demo.mp4

The demo shows the full flow:

  1. An MCP client opens the Supabase authorize URL.
  2. The admin approves on /oauth/consent.
  3. The client finishes OAuth and calls list_moderation_queue, approve_plugin, and list_moderation_events.
  4. The plugin page now shows Live.

Consent screen

Who is an admin

admins-table-studio-demo.mp4

In this demo, a signed-in non-admin has no Admin panel. A row for them is added in Supabase Studio, and after a reload they see the Admin panel and the moderation queue. When the row is deleted, both disappear again.

Each row in public.admins names one of:

  • user_id: an existing account.
  • email: works before the person has signed in. It only matches a Google or GitHub identity whose provider verified that address. That flag is in auth.identities.identity_data.email_verified, which only Supabase Auth writes. So an address typed into an email/password signup can't claim the row.

The check runs in the is_admin SQL function. The table has RLS on with no policies, and only the service role can execute the function, so the Data API returns 401 or 403 for both. Admin-only UI on plugin pages now asks the server through a server action instead of reading NEXT_PUBLIC_ADMIN_USER_IDS. As a result, admin user IDs no longer ship to the browser.

Tools

Tool What it does
list_moderation_queue Lists one moderation queue: flagged, scan issues, verification requests, or hidden
search_plugins Finds plugins by name or slug, including hidden ones
get_plugin Shows a plugin's status, scan verdict, owner, and components. Component content is only included on request
list_moderation_events Reads the moderation audit log (web admin and MCP), including history for deleted plugins
approve_plugin Publishes a hidden plugin that isn't flagged or blocked
approve_flagged_plugin The only way to publish a flagged or blocked plugin
unpublish_plugin Hides a plugin. The owner can publish it again
block_plugin Hides and permanently blocks a plugin. Requires a reason
rescan_plugin Queues a new security scan. Refused while a scan is already in progress
set_plugin_verified, dismiss_verification_request Manage the verified badge

Deleting plugins is not available over MCP and stays in the web admin.

How access is secured

  • Every request is checked:
    • The bearer token must be issued to the allowlisted OAuth client. Production's client ID is built into lib/mcp/auth.ts. It isn't a secret, since admins put it in their mcp.json. A comma-separated MCP_OAUTH_CLIENT_IDS replaces it for other Supabase projects.
    • getClaims verifies the token's signature and expiry.
    • getUser confirms with the Auth server that the session is still active.
    • The user must have a row in public.admins.
    • A missing or invalid token gets 401 with an RFC 9728 challenge. A valid token from a non-admin gets 403.
  • Tokens don't cross between the website and MCP. The MCP server rejects website session tokens. The website rejects MCP tokens, because getSession() ignores tokens that carry a client_id.
  • The consent page (/oauth/consent) only lets admins approve, and only for allowlisted clients:
    • The approve server action re-checks both conditions.
    • Before fetching the authorization details, the page revokes the user's grants to clients outside the allowlist. Otherwise Supabase would silently auto-approve a client that was approved before but has since been removed from the list.
    • If the client was approved before, the page still asks for a click instead of redirecting straight away.
    • The page can't be loaded inside another site's frame.
  • One pre-registered OAuth client. It's a public client (PKCE, no secret), created with bun run mcp:create-oauth-client. Dynamic client registration stays off.
  • Prompt-injection defenses. Plugin content is returned labeled as untrusted user data, and the server instructions tell the model never to act on it. Write tools carry non-read-only annotations, so they can be kept off Cursor's auto-run allowlist.
  • Rate limit. Write tools are limited per admin by the new mcp-moderation Vercel Firewall rule.
  • Audit log. The new append-only plugin_moderation_events table records every change from the web admin and MCP. Each entry has the actor, source, OAuth client, reason, and before/after state.

Refactor

  • The admin server actions now call a shared lib/plugins/moderation.ts. Web and MCP run the same mutations, and both are audited.
  • Cache invalidation is passed in by the caller, because updateTag only works inside server actions.
  • mcp-handler streams tool results, but Next.js applies revalidateTag as soon as a route handler returns. So /api/mcp reads the full response body before returning. Without this, MCP changes left cached pages stale.

Rollout checklist

Done in production:

  • Migrations applied: plugin_moderation_events and admins, recorded in the migration history like earlier ones. RLS is on, only the service role can call is_admin, and the security advisor flags nothing new.
  • Admins added: Lee and Eric by account, and Rohan by email.
  • OAuth Server enabled: an authorize request redirects to https://cursor.directory/oauth/consent.
  • Client registered: "Cursor (Community Plugins MCP)" is a public client with token endpoint auth none and Cursor's two redirect URIs. Its ID is built in, so no Vercel env var is needed.
  • Vercel builds fixed: Fix Vercel builds: Node.js 24 and a lighter plugin list #455 moved builds to Node.js 24 and slimmed the plugin list query. It's merged, and production deployed from main. This branch includes it.

Left to do:

  1. Merge Verify session tokens before trusting server-side identity #452, then this PR.
  2. Add the mcp-moderation Vercel Firewall rule: 60 requests per hour, keyed by user. This is optional for launch; until it exists, write tools aren't rate limited and the server logs an error.
  3. After the deploy, delete ADMIN_USER_IDS and NEXT_PUBLIC_ADMIN_USER_IDS from Vercel. Nothing reads them anymore. This needs Vercel project admin.
  4. Admins add the mcp.json snippet from the README. Prefer that over a Cursor Team MCP server, because team servers are also available to Cloud Agents, which run tools without asking for approval.
  5. Check that curl -i https://cursor.directory/.well-known/oauth-protected-resource/api/mcp from a laptop returns JSON, not Vercel's Security Checkpoint. If you get the checkpoint, add a firewall bypass for /api/mcp and /.well-known/oauth-protected-resource*.

Testing

All testing was local, against the Supabase stack and bun dev, using the official MCP SDK as the client. It behaved like Cursor: static client ID, PKCE, and discovery triggered by the 401.

  • Full flow: 401, discovery, Supabase authorize, consent, approve, token exchange (ES256 token with client_id, plus a refresh token), then connect. All 11 tools work as described. The guardrails refuse the wrong operations, and every write is audited with the client and reason.
  • Rejected cases:
    • a member on the consent page, and a member calling the approve action
    • a website session token
    • a non-admin's MCP token (403)
    • an admin token issued to a different OAuth client
    • a garbage token
    • an MCP token sent as a website cookie
    • a token used after sign-out
  • Client removed from the allowlist after an admin approved it (Bugbot finding): before the fix, the Continue screen handed that client a code. Now the page shows "Unknown application" and the approve action refuses. Re-authorizing the allowlisted client still shows Continue and completes.
  • Admins table, with both admin env vars removed locally:
    • Adding or deleting a row grants or revokes the admin page, admin actions, and MCP access on the next request, with no restart. The same MCP token went 403, then 200, then 403.
    • An email row matched a Google identity with a verified email (200). It did not match an unverified Google email, or the same address on an email/password identity (403).
    • The Data API can't read admins or call is_admin: 401 without a session, 403 when signed in.
    • In SQL, emails match regardless of case and stray spaces. Duplicate emails, empty rows, and a UUID pasted into the email column are rejected.
    • Every earlier suite passes with table-backed admins.
  • Protocol versions: works with the v1 client (@modelcontextprotocol/sdk 1.32.1) and the v2 client (@modelcontextprotocol/client 2.3.1).
  • Web admin regression: all refactored actions still work and write web audit entries. Updates to the audit log are rejected.
  • Caching: after an MCP unpublish or approve, the cached homepage listing updates immediately.
  • Checks: bunx biome ci ., bun run typecheck, and bun run build pass. supabase db advisors reports nothing for the new objects.
  • Dependency versions: added with Bun 1.3.1, the version CI pins. They respect the repo's 7-day minimumReleaseAge, which is why mcp-handler is 2.2.0 rather than 2.3.0.

Vercel preview: the deployment check fails, but it has failed on every main commit since #434 (Sep 11), which was the first deploy after a three-month gap. This PR doesn't cause it, but it blocks shipping until someone checks the Vercel build logs (npx vercel inspect <deployment> --logs).

Open in Web Open in Cursor 

Note

High Risk
Introduces OAuth-gated admin write APIs and replaces env-based admin allowlists with DB-backed roles, so misconfiguration or incomplete migration can revoke access or expose moderation actions.

Reviewed by Cursor Bugbot for commit cc1c12f. Bugbot is set up for automated code reviews on this repo. Configure here.

cursoragent and others added 4 commits October 14, 2026 17:33
Move the admin moderation operations (approve, unpublish, approve flagged,
block, rescan, verify, dismiss verification request, delete) out of the
server actions into lib/plugins/moderation.ts so the web admin and the
upcoming MCP server run the same code. Every operation now records an
entry in the new append-only plugin_moderation_events table with the
actor, source, and before/after state.

Cache invalidation is injected by the caller because updateTag only works
inside server actions.

Co-authored-by: Eric Zakariasson <ericzakariasson@users.noreply.github.com>
/api/mcp (mcp-handler, MCP SDK v2) exposes admin moderation tools:
moderation queues, plugin search and details, the audit log, and the
reversible write operations. Deleting stays in the web admin.

Access requires a Supabase OAuth access token issued to a client listed in
MCP_OAUTH_CLIENT_IDS (signature and expiry via getClaims, plus a session
check with the Auth server) and an admin user; non-admins get 403. RFC 9728
protected resource metadata points clients at Supabase Auth. Write tools
are rate-limited per admin, flagged plugins can only be published through
approve_flagged_plugin, and plugin content is returned as untrusted data.

Adds bun run mcp:create-oauth-client to register Cursor's public OAuth
client.

Co-authored-by: Eric Zakariasson <ericzakariasson@users.noreply.github.com>
Enable the Supabase OAuth 2.1 server locally and add /oauth/consent, where
admins approve registered MCP clients. The approve action re-checks the
admin and client allowlists, previously approved clients still need a
click, and the page cannot be framed. Tokens issued to OAuth clients are
no longer accepted as website sessions, and the sign-in buttons encode the
next path so login returns to the consent page.

Co-authored-by: Eric Zakariasson <ericzakariasson@users.noreply.github.com>
Co-authored-by: Eric Zakariasson <ericzakariasson@users.noreply.github.com>
@vercel

vercel Bot commented Oct 14, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
cursor-directory Ready Ready Preview Oct 15, 2026 12:56am UTC

Request Review

@cursor cursor Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 2 potential issues.

Autofix Details

Bugbot Autofix prepared fixes for both issues found in the latest run.

  • ✅ Fixed: Re-auth skips client allowlist
    • Added getMcpAuthorizationDetails, which revokes the user's grants to clients outside MCP_OAUTH_CLIENT_IDS before fetching authorization details, so the Auth server can only auto-approve allowlisted clients; the consent page and approve action now use it, and an end-to-end run against local GoTrue showed a removed client getting a code before the fix and "Unknown application" / "not allowed" after.
  • ✅ Fixed: Ambiguous blockPlugin string arguments
    • blockPlugin now takes (ctx, { pluginId, reason }), so the old positional call is a type error; both call sites were updated and verified end to end (the MCP block_plugin tool and the web confirm-flag action).

Create PR

Or push these changes by commenting:

@cursor push 9defa8020d
Preview (9defa8020d)
diff --git a/apps/cursor/src/actions/oauth-consent.ts b/apps/cursor/src/actions/oauth-consent.ts
--- 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 @@
   .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(

diff --git a/apps/cursor/src/actions/review-flagged-plugin.ts b/apps/cursor/src/actions/review-flagged-plugin.ts
--- a/apps/cursor/src/actions/review-flagged-plugin.ts
+++ b/apps/cursor/src/actions/review-flagged-plugin.ts
@@ -23,7 +23,7 @@
   .metadata({ actionName: "confirm-flag" })
   .schema(pluginIdSchema)
   .action(async ({ parsedInput: { pluginId }, ctx: { userId } }) => {
-    await blockPlugin(webModerationContext(userId), pluginId);
+    await blockPlugin(webModerationContext(userId), { pluginId });
     return { success: true };
   });
 

diff --git a/apps/cursor/src/app/oauth/consent/page.tsx b/apps/cursor/src/app/oauth/consent/page.tsx
--- a/apps/cursor/src/app/oauth/consent/page.tsx
+++ b/apps/cursor/src/app/oauth/consent/page.tsx
@@ -3,7 +3,7 @@
 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 @@
   }
 
   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 (

diff --git a/apps/cursor/src/lib/mcp/auth.ts b/apps/cursor/src/lib/mcp/auth.ts
--- 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,31 @@
   return !!clientId && getMcpClientIds().includes(clientId);
 }
 
+/**
+ * `getAuthorizationDetails` for the consent flow. The Auth server
+ * auto-approves a client the user consented to before and then returns only
+ * the redirect URL, not the client, so the user's grants to clients outside
+ * MCP_OAUTH_CLIENT_IDS are revoked first. A client removed from the list then
+ * has to ask for consent again, which the consent page refuses.
+ */
+export async function getMcpAuthorizationDetails(
+  supabase: SupabaseClient,
+  authorizationId: string,
+): Promise<AuthOAuthAuthorizationDetailsResponse> {
+  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;

diff --git a/apps/cursor/src/lib/mcp/tools.ts b/apps/cursor/src/lib/mcp/tools.ts
--- a/apps/cursor/src/lib/mcp/tools.ts
+++ b/apps/cursor/src/lib/mcp/tools.ts
@@ -515,7 +515,10 @@
       }
       return changed(
         "Blocked",
-        await blockPlugin(await beginWrite(admin), state.id, reason),
+        await blockPlugin(await beginWrite(admin), {
+          pluginId: state.id,
+          reason,
+        }),
       );
     }),
   );

diff --git a/apps/cursor/src/lib/plugins/moderation.ts b/apps/cursor/src/lib/plugins/moderation.ts
--- a/apps/cursor/src/lib/plugins/moderation.ts
+++ b/apps/cursor/src/lib/plugins/moderation.ts
@@ -191,8 +191,7 @@
 
 export function blockPlugin(
   ctx: ModerationContext,
-  pluginId: string,
-  reason?: string,
+  { pluginId, reason }: { pluginId: string; reason?: string },
 ) {
   return updatePlugin(
     ctx,

You can send follow-ups to the cloud agent here.

Comment thread apps/cursor/src/app/oauth/consent/page.tsx
Comment thread apps/cursor/src/lib/plugins/moderation.ts
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 <ericzakariasson@users.noreply.github.com>
cursoragent and others added 2 commits October 14, 2026 17:57
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 <ericzakariasson@users.noreply.github.com>
Two adjacent string parameters (pluginId, reason) were easy to swap.

Co-authored-by: Eric Zakariasson <ericzakariasson@users.noreply.github.com>
cursoragent and others added 2 commits October 14, 2026 18:29
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 <ericzakariasson@users.noreply.github.com>
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 <ericzakariasson@users.noreply.github.com>
ericzakariasson and others added 2 commits October 14, 2026 17:20
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 <cursoragent@cursor.com>

@cursor cursor Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

Comment thread apps/cursor/src/lib/mcp/tools.ts
ericzakariasson and others added 2 commits October 14, 2026 17:37
…27' into cursor/community-plugins-mcp-f627

Co-authored-by: Cursor <cursoragent@cursor.com>

# Conflicts:
#	apps/cursor/src/utils/supabase/auth.ts
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 <cursoragent@cursor.com>
@ericzakariasson
ericzakariasson deleted the branch cursor/verify-server-sessions-f627 October 15, 2026 00:43
@ericzakariasson
ericzakariasson changed the base branch from cursor/verify-server-sessions-f627 to main October 15, 2026 00:43

@cursor cursor Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

There are 2 total unresolved issues (including 1 from previous review).

Fix All in Cursor

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Empty client allowlist disables MCP
    • Confirmed that Next.js loads the example's blank MCP_OAUTH_CLIENT_IDS= as an empty string, which ?? kept and turned into an empty allowlist; changing ?? to || in getMcpClientIds now falls back to the built-in production client, and unset, single-ID and comma-separated values behave as before.

Create PR

Or push these changes by commenting:

@cursor push 44c440a9f8
Preview (44c440a9f8)
diff --git a/apps/cursor/src/lib/mcp/auth.ts b/apps/cursor/src/lib/mcp/auth.ts
--- a/apps/cursor/src/lib/mcp/auth.ts
+++ b/apps/cursor/src/lib/mcp/auth.ts
@@ -27,7 +27,7 @@
  * replaces the production client, e.g. for a local Supabase stack.
  */
 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);

You can send follow-ups to the cloud agent here.

Reviewed by Cursor Bugbot for commit cf902b5. Configure here.

Comment thread apps/cursor/src/lib/mcp/auth.ts
@ericzakariasson
ericzakariasson marked this pull request as ready for review October 15, 2026 00:46
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 <cursoragent@cursor.com>
Two adjacent string parameters could be swapped without a type error.

Co-authored-by: Cursor <cursoragent@cursor.com>
@ericzakariasson
ericzakariasson merged commit e816606 into main Oct 15, 2026
4 checks passed

This branch was successfully deployed

1 active deployment
Preview – cursor-directory — cc1c12f5 Deployed Oct 15, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants