diff --git a/apps/web/src/pages/admin/capabilities/MarketplaceTab.tsx b/apps/web/src/pages/admin/capabilities/MarketplaceTab.tsx
index 9fdfc5e..9a2606c 100644
--- a/apps/web/src/pages/admin/capabilities/MarketplaceTab.tsx
+++ b/apps/web/src/pages/admin/capabilities/MarketplaceTab.tsx
@@ -19,6 +19,7 @@ interface MarketplaceTabProps {
query: string
typeFilter: "mcp" | "skill"
canImport: boolean
+ canConnect: boolean
canManage: boolean
onSelectItem: (id: string | null) => void
onInstall: (capability: MarketplaceCapability) => void
@@ -33,6 +34,7 @@ export function MarketplaceTab(props: MarketplaceTabProps) {
itemID={mcpItemID}
query={props.query}
canImport={props.canImport}
+ canConnect={props.canConnect}
onSelectItem={(id) => props.onSelectItem(id ? `mcp:${id}` : null)}
onSelectMarketplaceItem={props.onSelectItem}
onInstallMarketplace={props.onInstall}
@@ -54,6 +56,7 @@ export function MarketplaceTab(props: MarketplaceTabProps) {
itemID={null}
query={props.query}
canImport={props.canImport}
+ canConnect={props.canConnect}
onSelectItem={(id) => props.onSelectItem(id ? `mcp:${id}` : null)}
onSelectMarketplaceItem={props.onSelectItem}
onInstallMarketplace={props.onInstall}
diff --git a/apps/web/src/pages/admin/capabilities/index.tsx b/apps/web/src/pages/admin/capabilities/index.tsx
index 07884cc..fd8b506 100644
--- a/apps/web/src/pages/admin/capabilities/index.tsx
+++ b/apps/web/src/pages/admin/capabilities/index.tsx
@@ -281,6 +281,7 @@ export function CapabilitiesPage() {
query={query}
typeFilter={typeFilter}
canImport={canImportDirectory}
+ canConnect={isAdmin}
canManage={isAdmin}
onSelectItem={(item) => navigate("capabilities", { tab: "marketplace", item })}
onInstall={goToAgentsForCapability}
diff --git a/apps/web/src/pages/admin/capabilities/mcp-directory/MCPDirectory.tsx b/apps/web/src/pages/admin/capabilities/mcp-directory/MCPDirectory.tsx
index 49179a6..dd8fcb9 100644
--- a/apps/web/src/pages/admin/capabilities/mcp-directory/MCPDirectory.tsx
+++ b/apps/web/src/pages/admin/capabilities/mcp-directory/MCPDirectory.tsx
@@ -25,6 +25,7 @@ interface MCPDirectoryProps {
itemID: string | null
query: string
canImport: boolean
+ canConnect: boolean
onSelectItem: (id: string | null) => void
onSelectMarketplaceItem: (id: string | null) => void
onInstallMarketplace: (capability: MarketplaceCapability) => void
@@ -37,6 +38,7 @@ export function MCPDirectory({
itemID,
query,
canImport,
+ canConnect,
onSelectItem,
onSelectMarketplaceItem,
onInstallMarketplace,
@@ -106,7 +108,7 @@ export function MCPDirectory({
}, [detailQ, directoryQ])
const connectOAuth = (id: string) => {
- if (!workspaceID) return
+ if (!workspaceID || !canConnect) return
setOAuthError(false)
const width = 620
const height = 760
@@ -172,6 +174,7 @@ export function MCPDirectory({
loading={detailQ.isLoading}
error={detailQ.error}
canImport={canImport}
+ canConnect={canConnect}
onBack={() => onSelectItem(null)}
onRetry={() => void detailQ.refetch()}
onImport={() => requestImport(itemID)}
@@ -228,7 +231,7 @@ export function MCPDirectory({
) : cards.length > 0 ? (
{cards.map((card) => card.kind === "directory" ? (
- onSelectItem(card.item.id)} onImport={() => requestImport(card.item.id)} onConnect={() => connectOAuth(card.item.id)} onViewCapability={onViewCapability} />
+ onSelectItem(card.item.id)} onImport={() => requestImport(card.item.id)} onConnect={() => connectOAuth(card.item.id)} onViewCapability={onViewCapability} />
) : (
onSelectMarketplaceItem(card.item.id)} onInstall={() => onInstallMarketplace(card.item)} onDelete={() => onDeleteMarketplace(card.item)} onViewCapability={() => onViewCapability(card.item.id)} />
))}
diff --git a/apps/web/src/pages/admin/capabilities/mcp-directory/MCPDirectoryCard.tsx b/apps/web/src/pages/admin/capabilities/mcp-directory/MCPDirectoryCard.tsx
index 70bdba3..f4151c2 100644
--- a/apps/web/src/pages/admin/capabilities/mcp-directory/MCPDirectoryCard.tsx
+++ b/apps/web/src/pages/admin/capabilities/mcp-directory/MCPDirectoryCard.tsx
@@ -6,9 +6,10 @@ import { Button } from "../../../../components/ui/button"
import { marketplaceSourceName, type MCPDirectoryItem, type MarketplaceCapability } from "../../../../lib/api-marketplace"
import { ConnectorIcon, VerifiedBadge } from "./shared"
-export function DirectoryCard({ item, canImport, onOpen, onImport, onConnect, onViewCapability }: {
+export function DirectoryCard({ item, canImport, canConnect, onOpen, onImport, onConnect, onViewCapability }: {
item: MCPDirectoryItem
canImport: boolean
+ canConnect: boolean
onOpen: () => void
onImport: () => void
onConnect: () => void
@@ -43,8 +44,8 @@ export function DirectoryCard({ item, canImport, onOpen, onImport, onConnect, on
{t("capabilities.mcpDirectory.actions.installed")}
) : item.authentication === "oauth2" && !item.connected ? (
-
{item.authentication === "oauth2" && !item.connected ? (
-
- {t("capabilities.mcpDirectory.oauth.connect")}
+
+ {canConnect ? t("capabilities.mcpDirectory.oauth.connect") : t("capabilities.permission.adminOnly")}
) : item.installed && item.installed_capability_id ? (
{
await expect(page.getByTestId("mcp-directory-card")).toHaveCount(3);
});
+test("allows workspace admins to start MCP OAuth", async ({ page }) => {
+ await mockApp(page, oauthDirectory, "admin");
+ await page.goto(`/?admin=capabilities&tab=marketplace&ws=${WORKSPACE_ID}`);
+
+ const card = page.locator('[data-catalog-id="notion"]');
+ await expect(card.getByRole("button", { name: "Connect", exact: true })).toBeEnabled();
+ await card.getByRole("heading", { name: "Notion" }).click();
+ await expect(page.getByTestId("mcp-directory-detail").getByRole("button", { name: "Connect", exact: true })).toBeEnabled();
+});
+
+for (const role of ["member", "viewer"] as const) {
+ test(`prevents ${role} users from starting MCP OAuth`, async ({ page }) => {
+ await mockApp(page, oauthDirectory, role);
+ await page.goto(`/?admin=capabilities&tab=marketplace&ws=${WORKSPACE_ID}`);
+
+ const card = page.locator('[data-catalog-id="notion"]');
+ await expect(card.getByRole("button", { name: "Owner / admin only", exact: true })).toBeDisabled();
+ await expect(card.getByRole("button", { name: "Connect", exact: true })).toHaveCount(0);
+ await card.getByRole("heading", { name: "Notion" }).click();
+
+ const detail = page.getByTestId("mcp-directory-detail");
+ await expect(detail.getByRole("button", { name: "Owner / admin only", exact: true })).toBeDisabled();
+ await expect(detail.getByRole("button", { name: "Connect", exact: true })).toHaveCount(0);
+ });
+}
+
+const oauthDirectoryItem = {
+ ...connector("notion", "Notion", "Productivity", 1),
+ authentication: "oauth2",
+ connected: false,
+};
+
+async function oauthDirectory(route: Route) {
+ await json(route, { items: [oauthDirectoryItem] });
+ return true;
+}
+
function connector(id: string, name: string, category: string, featuredRank: number) {
return {
id,
@@ -89,6 +127,7 @@ function connector(id: string, name: string, category: string, featuredRank: num
async function mockApp(
page: Page,
directoryOverride?: (route: Route) => Promise,
+ role: WorkspaceRole = "owner",
) {
await page.route("**/api/v1/**", async (route) => {
const request = route.request();
@@ -109,7 +148,7 @@ async function mockApp(
name: "Directory Test",
slug: "directory-test",
visibility: "private",
- role: "owner",
+ role,
created_at: "2026-07-23T00:00:00Z",
updated_at: "2026-07-23T00:00:00Z",
}],
@@ -159,6 +198,8 @@ async function mockApp(
}
if (path === `/api/v1/workspaces/${WORKSPACE_ID}/mcp-directory/context7`)
return json(route, { ...directoryItems[0], url: "https://mcp.context7.com/mcp" });
+ if (path === `/api/v1/workspaces/${WORKSPACE_ID}/mcp-directory/notion`)
+ return json(route, { ...oauthDirectoryItem, url: "https://mcp.notion.com/mcp" });
if (path === `/api/v1/workspaces/${WORKSPACE_ID}/mcp-directory/context7/import`)
return json(route, { installed: true, capability_id: CAPABILITY_ID }, 201);
return json(route, {});