Repository navigation
Add a Community Plugins MCP server for admin moderation - #453
Conversation
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>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
There was a problem hiding this comment.
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 outsideMCP_OAUTH_CLIENT_IDSbefore 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.
- Added
- ✅ Fixed: Ambiguous blockPlugin string arguments
blockPluginnow takes(ctx, { pluginId, reason }), so the old positional call is a type error; both call sites were updated and verified end to end (the MCPblock_plugintool and the web confirm-flag action).
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.
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>
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>
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>
…27' into cursor/community-plugins-mcp-f627
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>
…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>
There was a problem hiding this comment.
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).
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||ingetMcpClientIdsnow falls back to the built-in production client, and unset, single-ID and comma-separated values behave as before.
- Confirmed that Next.js loads the example's blank
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.
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>


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.adminstable instead of theADMIN_USER_IDSenv 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 oldADMIN_USER_IDSloses admin access when this deploys, until they're added to the table.The demo shows the full flow:
/oauth/consent.list_moderation_queue,approve_plugin, andlist_moderation_events.Who is an admin
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.adminsnames 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 inauth.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_adminSQL 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 readingNEXT_PUBLIC_ADMIN_USER_IDS. As a result, admin user IDs no longer ship to the browser.Tools
list_moderation_queuesearch_pluginsget_pluginlist_moderation_eventsapprove_pluginapprove_flagged_pluginunpublish_pluginblock_pluginrescan_pluginset_plugin_verified,dismiss_verification_requestDeleting plugins is not available over MCP and stays in the web admin.
How access is secured
lib/mcp/auth.ts. It isn't a secret, since admins put it in theirmcp.json. A comma-separatedMCP_OAUTH_CLIENT_IDSreplaces it for other Supabase projects.getClaimsverifies the token's signature and expiry.getUserconfirms with the Auth server that the session is still active.public.admins.getSession()ignores tokens that carry aclient_id./oauth/consent) only lets admins approve, and only for allowlisted clients:bun run mcp:create-oauth-client. Dynamic client registration stays off.mcp-moderationVercel Firewall rule.plugin_moderation_eventstable records every change from the web admin and MCP. Each entry has the actor, source, OAuth client, reason, and before/after state.Refactor
lib/plugins/moderation.ts. Web and MCP run the same mutations, and both are audited.updateTagonly works inside server actions.mcp-handlerstreams tool results, but Next.js appliesrevalidateTagas soon as a route handler returns. So/api/mcpreads the full response body before returning. Without this, MCP changes left cached pages stale.Rollout checklist
Done in production:
plugin_moderation_eventsandadmins, recorded in the migration history like earlier ones. RLS is on, only the service role can callis_admin, and the security advisor flags nothing new.https://cursor.directory/oauth/consent.noneand Cursor's two redirect URIs. Its ID is built in, so no Vercel env var is needed.main. This branch includes it.Left to do:
mcp-moderationVercel 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.ADMIN_USER_IDSandNEXT_PUBLIC_ADMIN_USER_IDSfrom Vercel. Nothing reads them anymore. This needs Vercel project admin.mcp.jsonsnippet 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.curl -i https://cursor.directory/.well-known/oauth-protected-resource/api/mcpfrom a laptop returns JSON, not Vercel's Security Checkpoint. If you get the checkpoint, add a firewall bypass for/api/mcpand/.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.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.adminsor callis_admin: 401 without a session, 403 when signed in.@modelcontextprotocol/sdk1.32.1) and the v2 client (@modelcontextprotocol/client2.3.1).webaudit entries. Updates to the audit log are rejected.bunx biome ci .,bun run typecheck, andbun run buildpass.supabase db advisorsreports nothing for the new objects.minimumReleaseAge, which is whymcp-handleris 2.2.0 rather than 2.3.0.Vercel preview: the deployment check fails, but it has failed on every
maincommit 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).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.