diff --git a/.changeset/plugin-mcp-tools.md b/.changeset/plugin-mcp-tools.md new file mode 100644 index 0000000000..5504bccc61 --- /dev/null +++ b/.changeset/plugin-mcp-tools.md @@ -0,0 +1,9 @@ +--- +"emdash": minor +"@emdash-cms/admin": minor +"@emdash-cms/auth": minor +"@emdash-cms/plugin-cli": minor +"@emdash-cms/plugin-types": minor +--- + +Adds explicitly declared, administrator-enabled plugin MCP tools with per-route permissions, plugin-scoped token access, install and update consent, structured output schemas, and invocation auditing. diff --git a/demos/simple/astro.config.mjs b/demos/simple/astro.config.mjs index 726b23cf5a..dff892e4a5 100644 --- a/demos/simple/astro.config.mjs +++ b/demos/simple/astro.config.mjs @@ -1,6 +1,7 @@ import node from "@astrojs/node"; import react from "@astrojs/react"; import auditLog from "@emdash-cms/plugin-audit-log"; +import { mcpSmokePlugin } from "@emdash-cms/plugin-mcp-smoke"; import { defineConfig, fontProviders } from "astro/config"; import emdash, { local } from "emdash/astro"; import { sqlite } from "emdash/db"; @@ -22,7 +23,7 @@ export default defineConfig({ directory: "./uploads", baseUrl: "/_emdash/api/media/file", }), - plugins: [auditLog], + plugins: [auditLog, mcpSmokePlugin()], }), ], fonts: [ diff --git a/demos/simple/package.json b/demos/simple/package.json index 38c335bfaf..2f2d0841b0 100644 --- a/demos/simple/package.json +++ b/demos/simple/package.json @@ -21,6 +21,7 @@ "@emdash-cms/plugin-audit-log": "workspace:*", "@emdash-cms/plugin-color": "workspace:*", "@emdash-cms/plugin-cli": "workspace:*", + "@emdash-cms/plugin-mcp-smoke": "workspace:*", "astro": "catalog:", "better-sqlite3": "catalog:", "emdash": "workspace:*", diff --git a/docs/src/content/docs/plugins/creating-plugins/api-routes.mdx b/docs/src/content/docs/plugins/creating-plugins/api-routes.mdx index e8d182fd96..4eb61e521d 100644 --- a/docs/src/content/docs/plugins/creating-plugins/api-routes.mdx +++ b/docs/src/content/docs/plugins/creating-plugins/api-routes.mdx @@ -70,11 +70,21 @@ Routes mount at `/_emdash/api/plugins//`. Route names can incl ## Authentication and CSRF -**Plugin routes are authenticated by default.** The dispatcher requires a session (or a token with the `admin` scope) before it'll call your handler: +**Plugin routes are authenticated by default.** The dispatcher requires a session (or a token with the `admin` scope) before it'll call your handler. Private routes default to the `plugins:manage` permission for backwards compatibility. Set `permission` to a narrower EmDash RBAC permission when the operation belongs to an existing content, media, schema, or settings capability: -- Read methods (`GET`, `HEAD`, `OPTIONS`) require the `plugins:read` permission. -- Write methods (`POST`, `PUT`, `PATCH`, `DELETE`) require `plugins:manage`. -- State-changing methods on private routes also require the `X-EmDash-Request: 1` CSRF header (the admin UI's `usePluginAPI()` hook sends it automatically; cookie-authed external callers need to set it themselves; token-authed requests are exempt). +```typescript +routes: { + create: { + permission: "content:create", + input: z.object({ title: z.string() }), + handler: async (routeCtx, ctx) => { + // ... + }, + }, +}, +``` + +Private routes require the `X-EmDash-Request: 1` CSRF header for cookie-authenticated requests. The admin UI sends it automatically; token-authenticated requests are exempt. To opt a route out of auth and CSRF, mark it `public: true`: @@ -95,6 +105,44 @@ routes: { Public routes skip authentication, scope, and CSRF entirely. Anyone on the internet can call them. Use `public: true` only for endpoints that need to accept external traffic — webhooks, public-facing search endpoints — and validate the input carefully. +## Exposing a route as an MCP tool + +Plugins may explicitly expose selected private routes through EmDash's MCP server. MCP exposure is never inferred from the route list: + +```typescript +const createEventInput = z.object({ + title: z.string().min(1), + startsAt: z.string().datetime(), +}); + +export default { + routes: { + "events/create": { + permission: "content:create", + input: createEventInput, + handler: async (routeCtx, ctx) => { + return { id: await createEvent(routeCtx.input, ctx) }; + }, + }, + }, + mcp: { + tools: { + createEvent: { + description: "Create a calendar event when the user asks to add one.", + route: "events/create", + input: createEventInput, + output: z.object({ id: z.string() }), + destructive: false, + }, + }, + }, +} satisfies SandboxedPlugin; +``` + +EmDash exposes this as `__createEvent`. The referenced route must be private and declare `permission`. Input schemas are required; output schemas are optional. Set `destructive: true` for tools that delete, overwrite, publish, charge, or otherwise perform a difficult-to-reverse action. + +An administrator must separately enable a plugin's MCP tools after reviewing their names, descriptions, routes, permissions, and destructive flags. Calling the tool then requires both the route permission and either the `mcp:tools` token scope or `mcp:tools:`. + ## Input validation `input` accepts a Zod schema. The dispatcher parses the request body (POST/PUT/PATCH) or query string (GET/DELETE), validates it, and passes the typed result to your handler as `routeCtx.input`. Invalid input returns a 400 before your handler runs. diff --git a/docs/src/content/docs/reference/mcp-server.mdx b/docs/src/content/docs/reference/mcp-server.mdx index f8963a0b8c..07994075fd 100644 --- a/docs/src/content/docs/reference/mcp-server.mdx +++ b/docs/src/content/docs/reference/mcp-server.mdx @@ -41,9 +41,11 @@ Tokens are scoped to limit what operations a client can perform. Scopes are requ | `menus:manage` | Create, update, and delete navigation menus and their items. | | `settings:read` | Read site-wide settings. | | `settings:manage` | Update site-wide settings. | +| `mcp:tools` | Invoke explicitly enabled MCP tools from any plugin. | +| `mcp:tools:` | Invoke explicitly enabled MCP tools from one plugin. | | `admin` | Full access to all operations. | -The `admin` scope grants access to everything. Session-based auth (no token) also has full access based on the user's role. +The `admin` scope grants access to core operations, but it does not grant plugin MCP access. Plugin tools always require `mcp:tools` or the matching plugin-specific scope. Session-based auth has access based on the user's role and the plugin's explicit admin enablement. `content:write` implicitly grants `taxonomies:manage` and `menus:manage` so personal access tokens issued before those scopes were split out continue to work without re-issue. New tokens should request the granular scopes. @@ -51,6 +53,8 @@ The `admin` scope grants access to everything. Session-based auth (no token) als In addition to scopes, some tools require a minimum RBAC role. Both must be satisfied -- a token with the right scope still fails if the calling user's role is too low. +Plugin tools use the permission declared by their underlying route. They are absent from `tools/list` until an administrator enables that plugin's MCP surface. Tool names use the deterministic `__` form, and invocations are recorded in the audit log with plugin, tool, route, and actor provenance. + | Operation | Minimum role | | --- | --- | | Content read | Subscriber (10) for published items; Contributor (20) for drafts, scheduled, trash, and revisions | diff --git a/packages/admin/src/components/CapabilityConsentDialog.tsx b/packages/admin/src/components/CapabilityConsentDialog.tsx index f97c1eedd8..36d74df7e8 100644 --- a/packages/admin/src/components/CapabilityConsentDialog.tsx +++ b/packages/admin/src/components/CapabilityConsentDialog.tsx @@ -12,6 +12,7 @@ import { ShieldCheck, ShieldWarning, Warning } from "@phosphor-icons/react"; import * as React from "react"; import { describeCapability } from "../lib/api/marketplace.js"; +import type { PluginMcpConsentTool } from "../lib/api/marketplace.js"; import { cn } from "../lib/utils.js"; import { DialogError } from "./DialogError.js"; @@ -28,6 +29,8 @@ export interface CapabilityConsentDialogProps { newCapabilities?: string[]; /** Routes that change from private to public in an update. */ newlyPublicRoutes?: string[]; + /** Plugin routes explicitly exposed as MCP tools. */ + mcpTools?: PluginMcpConsentTool[]; /** Audit verdict badge */ auditVerdict?: "pass" | "warn" | "fail"; /** Whether the action is in progress */ @@ -47,6 +50,7 @@ export function CapabilityConsentDialog({ allowedHosts, newCapabilities = [], newlyPublicRoutes = [], + mcpTools = [], auditVerdict, isPending = false, error, @@ -130,6 +134,31 @@ export function CapabilityConsentDialog({ )} + {mcpTools.length > 0 && ( +
+
{t`Agent-callable MCP tools`}
+

+ {t`These tools remain disabled after installation until you explicitly enable agent access.`} +

+
    + {mcpTools.map((tool) => ( +
  • +
    {tool.name}
    +

    {tool.description}

    +

    + {t`Route: ${tool.route} · Permission: ${tool.permission}`} +

    + {tool.destructive && ( + + {t`Destructive`} + + )} +
  • + ))} +
+
+ )} + {/* Audit verdict banner */} {auditVerdict && auditVerdict !== "pass" && (
([]); const [showUninstallConfirm, setShowUninstallConfirm] = React.useState(false); const [lightboxIndex, setLightboxIndex] = React.useState(null); @@ -60,13 +63,21 @@ export function MarketplacePluginDetail({ mutationFn: () => installMarketplacePlugin(pluginId, { version: plugin?.latestVersion?.version, + confirmMcpTools: mcpConsentTools.length > 0, }), onSuccess: () => { setShowConsent(false); + setMcpConsentTools([]); void queryClient.invalidateQueries({ queryKey: ["plugins"] }); void queryClient.invalidateQueries({ queryKey: ["manifest"] }); void queryClient.invalidateQueries({ queryKey: ["marketplace"] }); }, + onError: (mutationError) => { + if (mutationError instanceof PluginMcpConsentRequiredError) { + setMcpConsentTools(mutationError.tools); + setShowConsent(true); + } + }, }); const uninstallMutation = useMutation({ @@ -337,12 +348,14 @@ export function MarketplacePluginDetail({ mode="install" pluginName={plugin.name} capabilities={plugin.capabilities} + mcpTools={mcpConsentTools} auditVerdict={latest?.audit?.verdict} isPending={installMutation.isPending} error={getMutationError(installMutation.error)} onConfirm={() => installMutation.mutate()} onCancel={() => { setShowConsent(false); + setMcpConsentTools([]); installMutation.reset(); }} /> diff --git a/packages/admin/src/components/PluginManager.tsx b/packages/admin/src/components/PluginManager.tsx index b83450911f..97c27e5044 100644 --- a/packages/admin/src/components/PluginManager.tsx +++ b/packages/admin/src/components/PluginManager.tsx @@ -20,6 +20,7 @@ import { Storefront, Trash, ShieldCheck, + Robot, } from "@phosphor-icons/react"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { Link } from "@tanstack/react-router"; @@ -29,15 +30,18 @@ import { fetchPlugins, enablePlugin, disablePlugin, + setPluginMcpEnabled, type PluginInfo, type AdminManifest, CAPABILITY_LABELS, } from "../lib/api"; import { checkPluginUpdates, + PluginMcpConsentRequiredError, updateMarketplacePlugin, uninstallMarketplacePlugin, type PluginUpdateInfo, + type PluginMcpConsentTool, } from "../lib/api/marketplace.js"; import { RegistryUpdateEscalationError, @@ -233,6 +237,7 @@ function PluginCard({ const { t } = useLingui(); const [expanded, setExpanded] = React.useState(false); const [showUpdateConsent, setShowUpdateConsent] = React.useState(false); + const [mcpUpdateTools, setMcpUpdateTools] = React.useState([]); const [showUninstallConfirm, setShowUninstallConfirm] = React.useState(false); const [registryEscalation, setRegistryEscalation] = React.useState(null); @@ -242,15 +247,20 @@ function PluginCard({ const isMarketplace = plugin.source === "marketplace"; const isRegistry = plugin.source === "registry"; const hasUpdate = !!updateInfo && updateInfo.installed !== updateInfo.latest; + const mcpTools = plugin.mcpTools ?? []; const updateMutation = useMutation({ mutationFn: (opts: RegistryUpdateOpts) => isRegistry ? updateRegistryPlugin(plugin.id, opts) - : updateMarketplacePlugin(plugin.id, { confirmCapabilities: true }), + : updateMarketplacePlugin(plugin.id, { + confirmCapabilities: true, + confirmMcpTools: mcpUpdateTools.length > 0, + }), onSuccess: () => { setShowUpdateConsent(false); setRegistryEscalation(null); + setMcpUpdateTools([]); void queryClient.invalidateQueries({ queryKey: ["plugins"] }); void queryClient.invalidateQueries({ queryKey: ["plugin-updates"] }); void queryClient.invalidateQueries({ queryKey: ["manifest"] }); @@ -264,6 +274,10 @@ function PluginCard({ setRegistryEscalation(err); setShowUpdateConsent(true); } + if (err instanceof PluginMcpConsentRequiredError) { + setMcpUpdateTools(err.tools); + setShowUpdateConsent(true); + } }, }); @@ -282,7 +296,10 @@ function PluginCard({ const handleUpdateConfirm = () => { if (isRegistry) { - const opts: RegistryUpdateOpts = { confirmCapabilityChanges: true }; + const opts: RegistryUpdateOpts = { + confirmCapabilityChanges: true, + confirmMcpTools: mcpUpdateTools.length > 0, + }; if (registryEscalation?.code === "ROUTE_VISIBILITY_ESCALATION") { opts.confirmRouteVisibilityChanges = true; } @@ -308,6 +325,17 @@ function PluginCard({ }, }); + const mcpMutation = useMutation({ + mutationFn: (enabled: boolean) => setPluginMcpEnabled(plugin.id, enabled), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: ["plugins"] }); + toastManager.add({ + title: t`Plugin MCP access updated`, + description: t`Agent access for ${plugin.name} has been updated`, + }); + }, + }); + const handleToggle = () => { if (plugin.enabled) { onDisable(); @@ -385,6 +413,12 @@ function PluginCard({ {t`Hooks`} )} + {mcpTools.length > 0 && ( + + + {plural(mcpTools.length, { one: "# MCP tool", other: "# MCP tools" })} + + )} {plugin.capabilities.length > 0 && ( )} + {mcpTools.length > 0 && ( +
+
+
+

{t`Agent access`}

+

+ {t`Allow MCP tokens with plugin-tool scope to invoke these routes.`} +

+
+ mcpMutation.mutate(enabled)} + disabled={mcpMutation.isPending || !plugin.enabled} + aria-label={ + (plugin.mcpToolsEnabled ?? false) + ? t`Disable plugin MCP tools` + : t`Enable plugin MCP tools` + } + /> +
+
    + {mcpTools.map((tool) => ( +
  • +
    + {`${plugin.id}__${tool.name}`} + {tool.destructive && {t`Destructive`}} +
    +

    {tool.description}

    +

    + {t`Route: ${tool.route} · Permission: ${tool.permission}`} +

    +
  • + ))} +
+
+ )} + {/* Source */} {isMarketplace && (
@@ -560,6 +631,7 @@ function PluginCard({ capabilities={plugin.capabilities} newCapabilities={registryEscalation?.capabilityChanges.added ?? []} newlyPublicRoutes={registryEscalation?.routeVisibilityChanges?.newlyPublic ?? []} + mcpTools={mcpUpdateTools} isPending={updateMutation.isPending} error={ updateMutation.error instanceof RegistryUpdateEscalationError @@ -570,6 +642,7 @@ function PluginCard({ onCancel={() => { setShowUpdateConsent(false); setRegistryEscalation(null); + setMcpUpdateTools([]); updateMutation.reset(); }} /> diff --git a/packages/admin/src/components/RegistryPluginDetail.tsx b/packages/admin/src/components/RegistryPluginDetail.tsx index e9bd653bb0..b8b95cb465 100644 --- a/packages/admin/src/components/RegistryPluginDetail.tsx +++ b/packages/admin/src/components/RegistryPluginDetail.tsx @@ -26,6 +26,10 @@ import { Link } from "@tanstack/react-router"; import * as React from "react"; import { fetchManifest } from "../lib/api/client.js"; +import { + PluginMcpConsentRequiredError, + type PluginMcpConsentTool, +} from "../lib/api/marketplace.js"; import { artifactProxyUrl, canonicalCapabilitiesForDriftCheck, @@ -60,6 +64,7 @@ export function RegistryPluginDetail({ pluginId, config }: RegistryPluginDetailP const { t } = useLingui(); const queryClient = useQueryClient(); const [showConsent, setShowConsent] = React.useState(false); + const [mcpConsentTools, setMcpConsentTools] = React.useState([]); // Plugins list — used to compute whether this package is already // installed. Same query key as elsewhere so the install mutation's @@ -358,14 +363,22 @@ export function RegistryPluginDetail({ pluginId, config }: RegistryPluginDetailP // permissions past an empty consent dialog -- the // server will refuse with `DECLARED_ACCESS_REQUIRED`. acknowledgedDeclaredAccess: capabilities, + acknowledgedMcpTools: mcpConsentTools, }); }, onSuccess: () => { setShowConsent(false); + setMcpConsentTools([]); void queryClient.invalidateQueries({ queryKey: ["plugins"] }); void queryClient.invalidateQueries({ queryKey: ["manifest"] }); void queryClient.invalidateQueries({ queryKey: ["registry"] }); }, + onError: (error) => { + if (error instanceof PluginMcpConsentRequiredError) { + setMcpConsentTools(error.tools); + setShowConsent(true); + } + }, }); if (isLoadingPkg) { @@ -773,11 +786,13 @@ export function RegistryPluginDetail({ pluginId, config }: RegistryPluginDetailP mode="install" pluginName={displayName ?? slug} capabilities={capabilities} + mcpTools={mcpConsentTools} isPending={installMutation.isPending} error={getMutationError(installMutation.error)} onConfirm={() => installMutation.mutate()} onCancel={() => { setShowConsent(false); + setMcpConsentTools([]); installMutation.reset(); }} /> diff --git a/packages/admin/src/components/settings/ApiTokenSettings.tsx b/packages/admin/src/components/settings/ApiTokenSettings.tsx index 89561c8f14..5b64cf0ec7 100644 --- a/packages/admin/src/components/settings/ApiTokenSettings.tsx +++ b/packages/admin/src/components/settings/ApiTokenSettings.tsx @@ -20,6 +20,7 @@ import { type ApiTokenCreateResult, type ApiTokenScopeValue, } from "../../lib/api/api-tokens.js"; +import { fetchPlugins } from "../../lib/api/plugins.js"; import { getMutationError } from "../DialogError.js"; import { BackToSettingsLink } from "./BackToSettingsLink.js"; @@ -90,6 +91,11 @@ const API_TOKEN_SCOPE_VALUES: { label: msg`Settings Manage`, description: msg`Update site settings`, }, + { + scope: API_TOKEN_SCOPES.McpTools, + label: msg`Plugin MCP Tools`, + description: msg`Invoke MCP tools from all enabled plugins`, + }, { scope: API_TOKEN_SCOPES.Admin, label: msg`Admin`, @@ -128,6 +134,10 @@ export function ApiTokenSettings() { queryKey: ["api-tokens"], queryFn: fetchApiTokens, }); + const { data: plugins = [] } = useQuery({ + queryKey: ["plugins"], + queryFn: fetchPlugins, + }); // Create mutation const createMutation = useMutation({ @@ -242,6 +252,9 @@ export function ApiTokenSettings() { expirySelectItems={expirySelectItems} isCreating={createMutation.isPending} error={createMutation.error?.message ?? null} + pluginScopes={plugins + .filter((plugin) => (plugin.mcpTools?.length ?? 0) > 0) + .map((plugin) => ({ scope: `mcp:tools:${plugin.id}`, name: plugin.name }))} onSubmit={(input) => createMutation.mutate({ name: input.name, @@ -350,6 +363,7 @@ interface CreateTokenFormProps { expirySelectItems: Record; isCreating: boolean; error: string | null; + pluginScopes: Array<{ scope: string; name: string }>; onSubmit: (input: { name: string; scopes: string[]; expiresAt?: string }) => void; onCancel: () => void; } @@ -358,6 +372,7 @@ function CreateTokenForm({ expirySelectItems, isCreating, error, + pluginScopes, onSubmit, onCancel, }: CreateTokenFormProps) { @@ -427,6 +442,20 @@ function CreateTokenForm({ ); })} + {pluginScopes.map((plugin) => ( + + ))}
diff --git a/packages/admin/src/lib/api/api-tokens.ts b/packages/admin/src/lib/api/api-tokens.ts index 010bb62a59..a3f33c721c 100644 --- a/packages/admin/src/lib/api/api-tokens.ts +++ b/packages/admin/src/lib/api/api-tokens.ts @@ -53,6 +53,7 @@ export const API_TOKEN_SCOPES = { MenusManage: "menus:manage", SettingsRead: "settings:read", SettingsManage: "settings:manage", + McpTools: "mcp:tools", Admin: "admin", } as const; diff --git a/packages/admin/src/lib/api/index.ts b/packages/admin/src/lib/api/index.ts index f4eb905825..960cdd41a9 100644 --- a/packages/admin/src/lib/api/index.ts +++ b/packages/admin/src/lib/api/index.ts @@ -105,6 +105,7 @@ export { fetchPlugin, enablePlugin, disablePlugin, + setPluginMcpEnabled, } from "./plugins.js"; // Settings diff --git a/packages/admin/src/lib/api/marketplace.ts b/packages/admin/src/lib/api/marketplace.ts index 5a27c6fb0f..b35eef672d 100644 --- a/packages/admin/src/lib/api/marketplace.ts +++ b/packages/admin/src/lib/api/marketplace.ts @@ -92,12 +92,53 @@ export interface PluginUpdateInfo { /** Install request body */ export interface InstallPluginOpts { version?: string; + confirmMcpTools?: boolean; +} + +export interface PluginMcpConsentTool { + name: string; + description: string; + route: string; + permission: string; + destructive: boolean; +} + +export class PluginMcpConsentRequiredError extends Error { + constructor(readonly tools: PluginMcpConsentTool[]) { + super(i18n._(msg`Plugin MCP tools require explicit consent`)); + this.name = "PluginMcpConsentRequiredError"; + } +} + +function isPluginMcpConsentTool(value: unknown): value is PluginMcpConsentTool { + if (!value || typeof value !== "object") return false; + return ( + typeof Reflect.get(value, "name") === "string" && + typeof Reflect.get(value, "description") === "string" && + typeof Reflect.get(value, "route") === "string" && + typeof Reflect.get(value, "permission") === "string" && + typeof Reflect.get(value, "destructive") === "boolean" + ); +} + +function getMcpConsentTools(body: unknown): PluginMcpConsentTool[] | null { + if (!body || typeof body !== "object") return null; + const error = Reflect.get(body, "error"); + if (!error || typeof error !== "object") return null; + if (Reflect.get(error, "code") !== "MCP_TOOL_CONSENT_REQUIRED") return null; + const details = Reflect.get(error, "details"); + if (!details || typeof details !== "object") return null; + const tools = Reflect.get(details, "mcpTools"); + if (!Array.isArray(tools) || tools.length === 0) return null; + const valid = tools.filter(isPluginMcpConsentTool); + return valid.length > 0 ? valid : null; } /** Update request body */ export interface UpdatePluginOpts { /** User has confirmed new capabilities */ confirmCapabilities?: boolean; + confirmMcpTools?: boolean; } /** Uninstall request body */ @@ -157,7 +198,15 @@ export async function installMarketplacePlugin( headers: { "Content-Type": "application/json" }, body: JSON.stringify(opts), }); - if (!response.ok) await throwResponseError(response, i18n._(msg`Failed to install plugin`)); + if (!response.ok) { + const body: unknown = await response + .clone() + .json() + .catch(() => null); + const mcpTools = getMcpConsentTools(body); + if (mcpTools) throw new PluginMcpConsentRequiredError(mcpTools); + await throwResponseError(response, i18n._(msg`Failed to install plugin`)); + } } /** @@ -173,7 +222,15 @@ export async function updateMarketplacePlugin( headers: { "Content-Type": "application/json" }, body: JSON.stringify(opts), }); - if (!response.ok) await throwResponseError(response, i18n._(msg`Failed to update plugin`)); + if (!response.ok) { + const body: unknown = await response + .clone() + .json() + .catch(() => null); + const mcpTools = getMcpConsentTools(body); + if (mcpTools) throw new PluginMcpConsentRequiredError(mcpTools); + await throwResponseError(response, i18n._(msg`Failed to update plugin`)); + } } /** diff --git a/packages/admin/src/lib/api/plugins.ts b/packages/admin/src/lib/api/plugins.ts index 4eaab9f284..ca6f94bf9f 100644 --- a/packages/admin/src/lib/api/plugins.ts +++ b/packages/admin/src/lib/api/plugins.ts @@ -33,6 +33,15 @@ export interface PluginInfo { description?: string; /** URL to the plugin icon (marketplace plugins use the icon proxy) */ iconUrl?: string; + /** Absent when talking to an older core that predates plugin MCP tools. */ + mcpToolsEnabled?: boolean; + mcpTools?: Array<{ + name: string; + description: string; + route: string; + permission: string; + destructive: boolean; + }>; } /** @@ -92,3 +101,14 @@ export async function disablePlugin(pluginId: string): Promise { ); return result.item; } + +export async function setPluginMcpEnabled(pluginId: string, enabled: boolean): Promise { + const response = await apiFetch(`${API_BASE}/admin/plugins/${pluginId}/mcp`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ enabled }), + }); + if (!response.ok) { + await throwResponseError(response, i18n._(msg`Failed to update plugin MCP access`)); + } +} diff --git a/packages/admin/src/lib/api/registry.ts b/packages/admin/src/lib/api/registry.ts index b586a482e4..d9ed332da0 100644 --- a/packages/admin/src/lib/api/registry.ts +++ b/packages/admin/src/lib/api/registry.ts @@ -38,6 +38,7 @@ import { throwResponseError, type AdminManifest, } from "./client.js"; +import { PluginMcpConsentRequiredError, type PluginMcpConsentTool } from "./marketplace.js"; export type { Did, Handle }; export type { HostEnv }; @@ -82,6 +83,7 @@ export interface RegistryInstallRequest { slug: string; version?: string; acknowledgedDeclaredAccess?: unknown; + acknowledgedMcpTools?: PluginMcpConsentTool[]; } export interface RegistryInstallResult { @@ -689,6 +691,14 @@ export async function installRegistryPlugin( headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }); + if (!response.ok) { + const errorBody: unknown = await response + .clone() + .json() + .catch(() => null); + const mcpConsent = parseMcpConsent(errorBody); + if (mcpConsent) throw mcpConsent; + } return parseApiResponse(response, i18n._(msg`Failed to install plugin`)); } @@ -700,6 +710,7 @@ export interface RegistryUpdateOpts { version?: string; confirmCapabilityChanges?: boolean; confirmRouteVisibilityChanges?: boolean; + confirmMcpTools?: boolean; } export interface RegistryUninstallOpts { @@ -760,9 +771,25 @@ export async function updateRegistryPlugin( .catch(() => undefined); const escalation = parseEscalation(body); if (escalation) throw escalation; + const mcpConsent = parseMcpConsent(body); + if (mcpConsent) throw mcpConsent; await throwResponseError(response, i18n._(msg`Failed to update plugin`)); } +function parseMcpConsent(body: unknown): PluginMcpConsentRequiredError | null { + if (!body || typeof body !== "object" || !("error" in body)) return null; + const error = body.error; + if (!error || typeof error !== "object" || !("code" in error)) return null; + if (error.code !== "MCP_TOOL_CONSENT_REQUIRED") return null; + const details = + "details" in error && error.details && typeof error.details === "object" + ? (error.details as { mcpTools?: PluginMcpConsentTool[] }) + : {}; + return details.mcpTools && details.mcpTools.length > 0 + ? new PluginMcpConsentRequiredError(details.mcpTools) + : null; +} + function parseEscalation(body: unknown): RegistryUpdateEscalationError | null { if (!body || typeof body !== "object" || !("error" in body)) return null; const error = body.error; diff --git a/packages/admin/tests/lib/marketplace.test.ts b/packages/admin/tests/lib/marketplace.test.ts index 4e0a35030b..59d01340f1 100644 --- a/packages/admin/tests/lib/marketplace.test.ts +++ b/packages/admin/tests/lib/marketplace.test.ts @@ -9,6 +9,7 @@ import { checkPluginUpdates, describeCapability, CAPABILITY_LABELS, + PluginMcpConsentRequiredError, } from "../../src/lib/api/marketplace"; describe("marketplace API client", () => { @@ -151,6 +152,54 @@ describe("marketplace API client", () => { "Failed to install plugin: Server Error", ); }); + + it.each([ + ["missing details", undefined], + ["an empty tool list", { mcpTools: [] }], + ["no valid tools", { mcpTools: [{ name: 42 }] }], + ])("preserves the server error when MCP consent has %s", async (_label, details) => { + fetchSpy.mockResolvedValue( + new Response( + JSON.stringify({ + error: { + code: "MCP_TOOL_CONSENT_REQUIRED", + message: "Consent payload is invalid", + ...(details ? { details } : {}), + }, + }), + { status: 400 }, + ), + ); + + await expect(installMarketplacePlugin("my-plugin")).rejects.toThrow( + "Consent payload is invalid", + ); + }); + + it("throws a consent error when the response contains valid MCP tools", async () => { + const tool = { + name: "sync", + description: "Sync content", + route: "sync", + permission: "content:write", + destructive: false, + }; + fetchSpy.mockResolvedValue( + new Response( + JSON.stringify({ + error: { + code: "MCP_TOOL_CONSENT_REQUIRED", + details: { mcpTools: [tool] }, + }, + }), + { status: 409 }, + ), + ); + + const error = await installMarketplacePlugin("my-plugin").catch((reason: unknown) => reason); + expect(error).toBeInstanceOf(PluginMcpConsentRequiredError); + expect((error as PluginMcpConsentRequiredError).tools).toEqual([tool]); + }); }); // ----------------------------------------------------------------------- diff --git a/packages/admin/tests/lib/registry-consent.test.ts b/packages/admin/tests/lib/registry-consent.test.ts new file mode 100644 index 0000000000..60afe6a2de --- /dev/null +++ b/packages/admin/tests/lib/registry-consent.test.ts @@ -0,0 +1,65 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { PluginMcpConsentRequiredError } from "../../src/lib/api/marketplace"; +import { updateRegistryPlugin } from "../../src/lib/api/registry"; + +describe("registry MCP consent errors", () => { + let fetchSpy: ReturnType; + const originalFetch = globalThis.fetch; + + beforeEach(() => { + fetchSpy = vi.fn(); + globalThis.fetch = fetchSpy as typeof globalThis.fetch; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + it.each([ + ["missing details", undefined], + ["an empty tool list", { mcpTools: [] }], + ])("preserves the server error when MCP consent has %s", async (_label, details) => { + fetchSpy.mockResolvedValue( + new Response( + JSON.stringify({ + error: { + code: "MCP_TOOL_CONSENT_REQUIRED", + message: "Registry consent payload is invalid", + ...(details ? { details } : {}), + }, + }), + { status: 400 }, + ), + ); + + await expect(updateRegistryPlugin("my-plugin")).rejects.toThrow( + "Registry consent payload is invalid", + ); + }); + + it("throws a consent error when the response contains MCP tools", async () => { + const tool = { + name: "sync", + description: "Sync content", + route: "sync", + permission: "content:write", + destructive: false, + }; + fetchSpy.mockResolvedValue( + new Response( + JSON.stringify({ + error: { + code: "MCP_TOOL_CONSENT_REQUIRED", + details: { mcpTools: [tool] }, + }, + }), + { status: 409 }, + ), + ); + + const error = await updateRegistryPlugin("my-plugin").catch((reason: unknown) => reason); + expect(error).toBeInstanceOf(PluginMcpConsentRequiredError); + expect((error as PluginMcpConsentRequiredError).tools).toEqual([tool]); + }); +}); diff --git a/packages/auth/src/index.ts b/packages/auth/src/index.ts index 58cb856ac9..257ddc9cbf 100644 --- a/packages/auth/src/index.ts +++ b/packages/auth/src/index.ts @@ -65,6 +65,7 @@ export { // Scopes VALID_SCOPES, validateScopes, + isValidScope, hasScope, type ApiTokenScope, // PKCE diff --git a/packages/auth/src/rbac.ts b/packages/auth/src/rbac.ts index 66ceb0f34a..d34e274323 100644 --- a/packages/auth/src/rbac.ts +++ b/packages/auth/src/rbac.ts @@ -188,7 +188,7 @@ export class PermissionError extends Error { * (RBAC roles and API token scopes). When issuing a token, the granted * scopes must be intersected with the scopes allowed by the user's role. */ -const SCOPE_MIN_ROLE: Record = { +const SCOPE_MIN_ROLE: Record, RoleLevel> = { "content:read": Role.SUBSCRIBER, "content:write": Role.CONTRIBUTOR, "media:read": Role.SUBSCRIBER, @@ -199,6 +199,7 @@ const SCOPE_MIN_ROLE: Record = { "menus:manage": Role.EDITOR, "settings:read": Role.EDITOR, "settings:manage": Role.ADMIN, + "mcp:tools": Role.ADMIN, admin: Role.ADMIN, }; @@ -226,5 +227,7 @@ export function scopesForRole(role: RoleLevel): ApiTokenScope[] { */ export function clampScopes(requested: string[], role: RoleLevel): string[] { const allowed = new Set(scopesForRole(role)); - return requested.filter((s) => allowed.has(s)); + return requested.filter( + (scope) => allowed.has(scope) || (scope.startsWith("mcp:tools:") && role >= Role.SUBSCRIBER), + ); } diff --git a/packages/auth/src/tokens.test.ts b/packages/auth/src/tokens.test.ts index 496ecb5c10..161b786097 100644 --- a/packages/auth/src/tokens.test.ts +++ b/packages/auth/src/tokens.test.ts @@ -10,12 +10,32 @@ import { computeS256Challenge, encrypt, decrypt, + validateScopes, } from "./tokens.js"; const BASE64URL_REGEX = /^[A-Za-z0-9_-]+$/; const NO_PADDING_REGEX = /^[A-Za-z0-9_-]+$/; describe("tokens", () => { + describe("validateScopes", () => { + it("accepts only plugin MCP scopes with valid plugin identifiers", () => { + expect(validateScopes(["mcp:tools", "mcp:tools:calendar-plugin"])).toEqual([]); + expect( + validateScopes([ + "mcp:tools:", + "mcp:tools:@acme/calendar", + "mcp:tools:Calendar", + "mcp:tools:-calendar", + ]), + ).toEqual([ + "mcp:tools:", + "mcp:tools:@acme/calendar", + "mcp:tools:Calendar", + "mcp:tools:-calendar", + ]); + }); + }); + describe("generateToken", () => { it("generates a base64url-encoded token", () => { const token = generateToken(); diff --git a/packages/auth/src/tokens.ts b/packages/auth/src/tokens.ts index 42052d58a1..6d011f1c1c 100644 --- a/packages/auth/src/tokens.ts +++ b/packages/auth/src/tokens.ts @@ -40,18 +40,26 @@ export const VALID_SCOPES = [ "menus:manage", "settings:read", "settings:manage", + "mcp:tools", "admin", ] as const; -export type ApiTokenScope = (typeof VALID_SCOPES)[number]; +export type ApiTokenScope = (typeof VALID_SCOPES)[number] | `mcp:tools:${string}`; + +const PLUGIN_MCP_SCOPE_PATTERN = /^mcp:tools:[a-z][a-z0-9_-]*$/; + +export function isValidScope(scope: string): scope is ApiTokenScope { + return ( + (VALID_SCOPES as readonly string[]).includes(scope) || PLUGIN_MCP_SCOPE_PATTERN.test(scope) + ); +} /** * Validate that scopes are all valid. * Returns the invalid scopes, or empty array if all valid. */ export function validateScopes(scopes: string[]): string[] { - const validSet = new Set(VALID_SCOPES); - return scopes.filter((s) => !validSet.has(s)); + return scopes.filter((scope) => !isValidScope(scope)); } /** @@ -81,6 +89,9 @@ const IMPLICIT_SCOPE_GRANTS = new Map([ * compatibility with PATs issued before those scopes were split out. */ export function hasScope(scopes: string[], required: string): boolean { + if (required === "mcp:tools" || required.startsWith("mcp:tools:")) { + return scopes.includes("mcp:tools") || scopes.includes(required); + } if (scopes.includes("admin")) return true; if (scopes.includes(required)) return true; for (const held of scopes) { diff --git a/packages/core/src/api/handlers/device-flow.ts b/packages/core/src/api/handlers/device-flow.ts index 6cca4c1d69..4654ae1b96 100644 --- a/packages/core/src/api/handlers/device-flow.ts +++ b/packages/core/src/api/handlers/device-flow.ts @@ -17,8 +17,8 @@ import type { Kysely } from "kysely"; import { generatePrefixedToken, hashApiToken, + isValidScope, TOKEN_PREFIXES, - VALID_SCOPES, } from "../../auth/api-tokens.js"; import { withTransaction } from "../../database/transaction.js"; import type { Database } from "../../database/types.js"; @@ -112,8 +112,7 @@ function normalizeScopes(requested?: string[]): string[] { if (!requested || requested.length === 0) { return [...DEFAULT_SCOPES]; } - const validSet = new Set(VALID_SCOPES); - return requested.filter((s) => validSet.has(s)); + return requested.filter(isValidScope); } // --------------------------------------------------------------------------- diff --git a/packages/core/src/api/handlers/marketplace.ts b/packages/core/src/api/handlers/marketplace.ts index 1bfde39f51..4da06579d2 100644 --- a/packages/core/src/api/handlers/marketplace.ts +++ b/packages/core/src/api/handlers/marketplace.ts @@ -328,6 +328,7 @@ export async function handleMarketplaceInstall( * Skip the SANDBOX_NOT_AVAILABLE gate so the install can proceed. */ sandboxBypassed?: boolean; + confirmMcpTools?: boolean; }, ): Promise> { const client = getClient(marketplaceUrl, opts?.siteOrigin); @@ -448,6 +449,21 @@ export async function handleMarketplaceInstall( const bundleIdentityError = validateBundleIdentity(bundle, pluginId, version); if (bundleIdentityError) return bundleIdentityError; + if ((bundle.manifest.mcp?.tools.length ?? 0) > 0 && !opts?.confirmMcpTools) { + return { + success: false, + error: { + code: "MCP_TOOL_CONSENT_REQUIRED", + message: "Plugin MCP tools require explicit consent", + details: { + mcpTools: bundle.manifest.mcp?.tools.map( + ({ inputSchema: _, outputSchema: __, ...tool }) => tool, + ), + }, + }, + }; + } + // Store bundle in site-local R2 await storeBundleInR2(storage, pluginId, version, bundle); @@ -534,6 +550,7 @@ export async function handleMarketplaceUpdate( version?: string; confirmCapabilityChanges?: boolean; confirmRouteVisibilityChanges?: boolean; + confirmMcpTools?: boolean; /** * When true, sandbox: false bypass mode is active. The sandbox runner * is the noop runner (isAvailable() === false) but the runtime will @@ -664,6 +681,25 @@ export async function handleMarketplaceUpdate( }; } + const oldMcpTools = [...(oldBundle?.manifest.mcp?.tools ?? [])].toSorted((a, b) => + a.name.localeCompare(b.name), + ); + const newMcpTools = [...(bundle.manifest.mcp?.tools ?? [])].toSorted((a, b) => + a.name.localeCompare(b.name), + ); + if (JSON.stringify(oldMcpTools) !== JSON.stringify(newMcpTools) && !opts?.confirmMcpTools) { + return { + success: false, + error: { + code: "MCP_TOOL_CONSENT_REQUIRED", + message: "Plugin update changes its MCP tools", + details: { + mcpTools: newMcpTools.map(({ inputSchema: _, outputSchema: __, ...tool }) => tool), + }, + }, + }; + } + // Store new bundle await storeBundleInR2(storage, pluginId, newVersion, bundle); @@ -673,6 +709,8 @@ export async function handleMarketplaceUpdate( marketplaceVersion: newVersion, displayName: pluginDetail.name, description: pluginDetail.description ?? undefined, + mcpToolsEnabled: false, + mcpToolsConsent: null, }); // Clean up old bundle from R2 (best-effort) diff --git a/packages/core/src/api/handlers/oauth-authorization.ts b/packages/core/src/api/handlers/oauth-authorization.ts index 8b383bb065..a21d41a8eb 100644 --- a/packages/core/src/api/handlers/oauth-authorization.ts +++ b/packages/core/src/api/handlers/oauth-authorization.ts @@ -16,8 +16,8 @@ import type { Kysely } from "kysely"; import { generatePrefixedToken, hashApiToken, + isValidScope, TOKEN_PREFIXES, - VALID_SCOPES, } from "../../auth/api-tokens.js"; import { withTransaction } from "../../database/transaction.js"; import type { Database } from "../../database/types.js"; @@ -87,11 +87,7 @@ export { validateRedirectUri }; function normalizeScopes(requested?: string): string[] { if (!requested) return []; - const validSet = new Set(VALID_SCOPES); - const scopes = requested - .split(" ") - .filter(Boolean) - .filter((s) => validSet.has(s)); + const scopes = requested.split(" ").filter(Boolean).filter(isValidScope); return scopes; } diff --git a/packages/core/src/api/handlers/plugins.ts b/packages/core/src/api/handlers/plugins.ts index 24e1736b5a..a8abf3ee43 100644 --- a/packages/core/src/api/handlers/plugins.ts +++ b/packages/core/src/api/handlers/plugins.ts @@ -36,6 +36,14 @@ export interface PluginInfo { description?: string; /** URL to the plugin icon on the marketplace */ iconUrl?: string; + mcpToolsEnabled: boolean; + mcpTools: Array<{ + name: string; + description: string; + route: string; + permission: string; + destructive: boolean; + }>; } export interface PluginListResponse { @@ -84,6 +92,21 @@ function buildPluginInfo( description: state?.description ?? undefined, iconUrl: isMarketplace && marketplaceUrl ? marketplaceIconUrl(marketplaceUrl, plugin.id) : undefined, + mcpToolsEnabled: state?.mcpToolsEnabled ?? false, + mcpTools: Object.entries(plugin.mcp?.tools ?? {}).flatMap(([name, tool]) => { + const permission = plugin.routes[tool.route]?.permission; + return permission + ? [ + { + name, + description: tool.description, + route: tool.route, + permission, + destructive: tool.destructive ?? false, + }, + ] + : []; + }), }; } @@ -114,6 +137,8 @@ function buildSandboxedPluginInfo( activatedAt: state?.activatedAt?.toISOString() ?? undefined, deactivatedAt: state?.deactivatedAt?.toISOString() ?? undefined, description: state?.description ?? undefined, + mcpToolsEnabled: state?.mcpToolsEnabled ?? false, + mcpTools: entry.mcp?.tools.map(({ inputSchema: _, outputSchema: __, ...tool }) => tool) ?? [], }; } @@ -174,6 +199,8 @@ export async function handlePluginList( state.source === "marketplace" && marketplaceUrl ? marketplaceIconUrl(marketplaceUrl, state.pluginId) : undefined, + mcpToolsEnabled: state.mcpToolsEnabled, + mcpTools: [], }); } @@ -270,6 +297,8 @@ function buildStateOnlyPluginInfo( activatedAt: state.activatedAt?.toISOString() ?? undefined, deactivatedAt: state.deactivatedAt?.toISOString() ?? undefined, description: state.description ?? undefined, + mcpToolsEnabled: state.mcpToolsEnabled, + mcpTools: [], }; } diff --git a/packages/core/src/api/handlers/registry.ts b/packages/core/src/api/handlers/registry.ts index 138bd40ee5..c6039eed68 100644 --- a/packages/core/src/api/handlers/registry.ts +++ b/packages/core/src/api/handlers/registry.ts @@ -121,6 +121,7 @@ export interface RegistryInstallInput { * surface a consent UI before posting (e.g. CI scripts) opt out. */ acknowledgedDeclaredAccess?: unknown; + acknowledgedMcpTools?: unknown; } export interface RegistryInstallResult { @@ -1098,6 +1099,22 @@ export async function handleRegistryInstall( } } + const actualMcpTools = (bundle.manifest.mcp?.tools ?? []).map( + ({ inputSchema: _, outputSchema: __, ...tool }) => tool, + ); + if (actualMcpTools.length > 0) { + if (JSON.stringify(input.acknowledgedMcpTools) !== JSON.stringify(actualMcpTools)) { + return { + success: false, + error: { + code: "MCP_TOOL_CONSENT_REQUIRED", + message: "Plugin MCP tools require explicit consent", + details: { mcpTools: actualMcpTools }, + }, + }; + } + } + // Step 7: store in R2 under the registry prefix. await storeBundleInR2(storage, pluginId, version, bundle, "registry"); @@ -1318,6 +1335,7 @@ export async function handleRegistryUpdate( version?: string; confirmCapabilityChanges?: boolean; confirmRouteVisibilityChanges?: boolean; + confirmMcpTools?: boolean; hostEnv?: HostEnv; }, ): Promise> { @@ -1590,6 +1608,25 @@ export async function handleRegistryUpdate( }; } + const oldMcpTools = [...(oldBundle?.manifest.mcp?.tools ?? [])].toSorted((a, b) => + a.name.localeCompare(b.name), + ); + const newMcpTools = [...(bundle.manifest.mcp?.tools ?? [])].toSorted((a, b) => + a.name.localeCompare(b.name), + ); + if (JSON.stringify(oldMcpTools) !== JSON.stringify(newMcpTools) && !opts?.confirmMcpTools) { + return { + success: false, + error: { + code: "MCP_TOOL_CONSENT_REQUIRED", + message: "Plugin update changes its MCP tools", + details: { + mcpTools: newMcpTools.map(({ inputSchema: _, outputSchema: __, ...tool }) => tool), + }, + }, + }; + } + // Store new bundle. R2 prefix is deterministic per (pluginId, version), // so a retry of the same update is idempotent. await storeBundleInR2(storage, pluginId, newVersion, bundle, "registry"); @@ -1605,6 +1642,8 @@ export async function handleRegistryUpdate( registrySlug: slug, displayName: existing.displayName ?? slug, description: existing.description ?? undefined, + mcpToolsEnabled: false, + mcpToolsConsent: null, }); // Best-effort cleanup of the old bundle. Failures here don't roll diff --git a/packages/core/src/astro/integration/routes.ts b/packages/core/src/astro/integration/routes.ts index 2174640914..b156159e1f 100644 --- a/packages/core/src/astro/integration/routes.ts +++ b/packages/core/src/astro/integration/routes.ts @@ -419,6 +419,10 @@ export function injectCoreRoutes( pattern: "/_emdash/api/admin/plugins/[id]/disable", entrypoint: resolveRoute("api/admin/plugins/[id]/disable.ts"), }); + injectRoute({ + pattern: "/_emdash/api/admin/plugins/[id]/mcp", + entrypoint: resolveRoute("api/admin/plugins/[id]/mcp.ts"), + }); // Marketplace plugin routes injectRoute({ diff --git a/packages/core/src/astro/integration/runtime.ts b/packages/core/src/astro/integration/runtime.ts index 1ad3866299..64a19d6360 100644 --- a/packages/core/src/astro/integration/runtime.ts +++ b/packages/core/src/astro/integration/runtime.ts @@ -13,6 +13,7 @@ import type { MediaProviderDescriptor } from "../../media/types.js"; import type { ObjectCacheDescriptor } from "../../object-cache/types.js"; import type { FieldWidgetConfig, + PluginMcpManifestConfig, PortableTextBlockConfig, ResolvedPlugin, } from "../../plugins/types.js"; @@ -131,6 +132,8 @@ export interface PluginDescriptor> { * Sandboxed plugins can only access declared collections. */ storage?: Record; + /** Serialized MCP declarations emitted by the plugin build. */ + mcp?: PluginMcpManifestConfig; } /** diff --git a/packages/core/src/astro/integration/virtual-modules.ts b/packages/core/src/astro/integration/virtual-modules.ts index 755c928629..578c5c492a 100644 --- a/packages/core/src/astro/integration/virtual-modules.ts +++ b/packages/core/src/astro/integration/virtual-modules.ts @@ -640,6 +640,7 @@ export const sandboxedPlugins = []; capabilities: ${JSON.stringify(descriptor.capabilities ?? [])}, allowedHosts: ${JSON.stringify(descriptor.allowedHosts ?? [])}, storage: ${JSON.stringify(descriptor.storage ?? {})}, + mcp: ${JSON.stringify(descriptor.mcp)}, adminPages: ${JSON.stringify(descriptor.adminPages ?? [])}, adminWidgets: ${JSON.stringify(descriptor.adminWidgets ?? [])}, portableTextBlocks: ${JSON.stringify(descriptor.portableTextBlocks ?? [])}, diff --git a/packages/core/src/astro/middleware.ts b/packages/core/src/astro/middleware.ts index 987bcb9400..e623999257 100644 --- a/packages/core/src/astro/middleware.ts +++ b/packages/core/src/astro/middleware.ts @@ -734,6 +734,11 @@ export const onRequest = defineMiddleware(async (context, next) => { handlePluginApiRoute: runtime.handlePluginApiRoute.bind(runtime), handlePublicPluginApiRoute: createPublicPluginApiRouteHandler(runtime), getPluginRouteMeta: runtime.getPluginRouteMeta.bind(runtime), + getPluginMcpTools: runtime.getPluginMcpTools.bind(runtime), + getEnabledPluginMcpTools: runtime.getEnabledPluginMcpTools.bind(runtime), + serializePluginMcpConsent: runtime.serializePluginMcpConsent.bind(runtime), + handlePluginMcpTool: runtime.handlePluginMcpTool.bind(runtime), + handlePluginMcpDenied: runtime.handlePluginMcpDenied.bind(runtime), // Media provider methods getMediaProvider: runtime.getMediaProvider.bind(runtime), diff --git a/packages/core/src/astro/middleware/auth.ts b/packages/core/src/astro/middleware/auth.ts index ff925c0a7e..db54deac4e 100644 --- a/packages/core/src/astro/middleware/auth.ts +++ b/packages/core/src/astro/middleware/auth.ts @@ -764,9 +764,6 @@ const SCOPE_RULES: Array<[prefix: string, method: string, scope: string]> = [ // settings:manage are not rejected at the middleware level. ["/_emdash/api/settings", "GET", "settings:read"], ["/_emdash/api/settings", "WRITE", "settings:manage"], - - // MCP endpoint — scopes enforced per-tool inside mcp/server.ts - ["/_emdash/api/mcp", "*", "content:read"], ]; const WRITE_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]); @@ -785,6 +782,11 @@ function enforceTokenScope( // Session auth — implicit full access, no scope restrictions if (!tokenScopes) return null; + // MCP is authenticated here, but each tool enforces its own scope. A + // blanket content:read/admin requirement would prevent plugin-scoped tokens + // from reaching `mcp/server.ts`, where the actual tool policy lives. + if (pathname === MCP_ENDPOINT_PATH) return null; + const isWrite = WRITE_METHODS.has(method); for (const [prefix, ruleMethod, scope] of SCOPE_RULES) { diff --git a/packages/core/src/astro/routes/api/admin/api-tokens/index.ts b/packages/core/src/astro/routes/api/admin/api-tokens/index.ts index a4745e5150..e107f1818f 100644 --- a/packages/core/src/astro/routes/api/admin/api-tokens/index.ts +++ b/packages/core/src/astro/routes/api/admin/api-tokens/index.ts @@ -12,13 +12,13 @@ import { z } from "zod"; import { apiError, handleError, unwrapResult } from "#api/error.js"; import { handleApiTokenCreate, handleApiTokenList } from "#api/handlers/api-tokens.js"; import { isParseError, parseBody } from "#api/parse.js"; -import { VALID_SCOPES } from "#auth/api-tokens.js"; +import { isValidScope } from "#auth/api-tokens.js"; export const prerender = false; const createTokenSchema = z.object({ name: z.string().min(1).max(100), - scopes: z.array(z.enum(VALID_SCOPES)).min(1), + scopes: z.array(z.string().refine(isValidScope)).min(1), expiresAt: z.string().datetime().optional(), }); diff --git a/packages/core/src/astro/routes/api/admin/plugins/[id]/index.ts b/packages/core/src/astro/routes/api/admin/plugins/[id]/index.ts index 06c6a7f969..5745d3d32f 100644 --- a/packages/core/src/astro/routes/api/admin/plugins/[id]/index.ts +++ b/packages/core/src/astro/routes/api/admin/plugins/[id]/index.ts @@ -34,6 +34,12 @@ export const GET: APIRoute = async ({ params, locals }) => { id, emdash.config.marketplace, ); + if (result.success) { + const tools = await emdash.getPluginMcpTools(id); + result.data.item.mcpTools = tools.map( + ({ inputSchema: _, outputSchema: __, pluginId: ___, ...tool }) => tool, + ); + } return unwrapResult(result); }; diff --git a/packages/core/src/astro/routes/api/admin/plugins/[id]/mcp.ts b/packages/core/src/astro/routes/api/admin/plugins/[id]/mcp.ts new file mode 100644 index 0000000000..eceff59eb7 --- /dev/null +++ b/packages/core/src/astro/routes/api/admin/plugins/[id]/mcp.ts @@ -0,0 +1,42 @@ +import type { APIRoute } from "astro"; +import { z } from "zod"; + +import { requirePerm } from "#api/authorize.js"; +import { apiError, apiSuccess } from "#api/error.js"; +import { parseBody } from "#api/parse.js"; +import { PluginStateRepository } from "#plugins/state.js"; + +export const prerender = false; + +const bodySchema = z.object({ enabled: z.boolean() }); + +export const PUT: APIRoute = async ({ params, request, locals }) => { + const { emdash, user } = locals; + if (!emdash?.db) return apiError("NOT_CONFIGURED", "EmDash is not initialized", 500); + const denied = requirePerm(user, "plugins:manage"); + if (denied) return denied; + if (!params.id) return apiError("INVALID_REQUEST", "Plugin ID required", 400); + + const parsed = await parseBody(request, bodySchema); + if (parsed instanceof Response) return parsed; + const tools = await emdash.getPluginMcpTools(params.id); + if (parsed.enabled && tools.length === 0) { + return apiError("NO_MCP_TOOLS", "Plugin does not declare MCP tools", 400); + } + const stateRepo = new PluginStateRepository(emdash.db); + const existing = await stateRepo.get(params.id); + const configuredVersion = + emdash.configuredPlugins.find((plugin) => plugin.id === params.id)?.version ?? + emdash.sandboxedPluginEntries.find((plugin) => plugin.id === params.id)?.version; + const version = existing?.version ?? configuredVersion; + if (!version) return apiError("NOT_FOUND", "Plugin not found", 404); + const consent = parsed.enabled ? emdash.serializePluginMcpConsent(tools, params.id) : null; + const item = await stateRepo.upsert(params.id, version, existing?.status ?? "active", { + mcpToolsEnabled: parsed.enabled, + mcpToolsConsent: consent, + }); + return apiSuccess({ + enabled: item.mcpToolsEnabled, + tools: tools.map(({ inputSchema: _, outputSchema: __, ...tool }) => tool), + }); +}; diff --git a/packages/core/src/astro/routes/api/admin/plugins/[id]/update.ts b/packages/core/src/astro/routes/api/admin/plugins/[id]/update.ts index 17f76794ae..3129a2d538 100644 --- a/packages/core/src/astro/routes/api/admin/plugins/[id]/update.ts +++ b/packages/core/src/astro/routes/api/admin/plugins/[id]/update.ts @@ -18,6 +18,7 @@ const updateBodySchema = z.object({ version: z.string().min(1).optional(), confirmCapabilityChanges: z.boolean().optional(), confirmRouteVisibilityChanges: z.boolean().optional(), + confirmMcpTools: z.boolean().optional(), }); export const POST: APIRoute = async ({ params, request, locals }) => { @@ -48,6 +49,7 @@ export const POST: APIRoute = async ({ params, request, locals }) => { version: body.version, confirmCapabilityChanges: body.confirmCapabilityChanges, confirmRouteVisibilityChanges: body.confirmRouteVisibilityChanges, + confirmMcpTools: body.confirmMcpTools, sandboxBypassed: emdash.isSandboxBypassed(), }, ); diff --git a/packages/core/src/astro/routes/api/admin/plugins/index.ts b/packages/core/src/astro/routes/api/admin/plugins/index.ts index a4a4d90ed1..2c88a35656 100644 --- a/packages/core/src/astro/routes/api/admin/plugins/index.ts +++ b/packages/core/src/astro/routes/api/admin/plugins/index.ts @@ -28,6 +28,14 @@ export const GET: APIRoute = async ({ locals }) => { emdash.sandboxedPluginEntries, emdash.config.marketplace, ); + if (result.success) { + const tools = await emdash.getPluginMcpTools(); + for (const item of result.data.items) { + item.mcpTools = tools + .filter((tool) => tool.pluginId === item.id) + .map(({ inputSchema: _, outputSchema: __, pluginId: ___, ...tool }) => tool); + } + } return unwrapResult(result); }; diff --git a/packages/core/src/astro/routes/api/admin/plugins/marketplace/[id]/install.ts b/packages/core/src/astro/routes/api/admin/plugins/marketplace/[id]/install.ts index 7f9e7ee7f8..d8d3b7c5c3 100644 --- a/packages/core/src/astro/routes/api/admin/plugins/marketplace/[id]/install.ts +++ b/packages/core/src/astro/routes/api/admin/plugins/marketplace/[id]/install.ts @@ -16,6 +16,7 @@ export const prerender = false; const installBodySchema = z.object({ version: z.string().min(1).optional(), + confirmMcpTools: z.boolean().optional(), }); export const POST: APIRoute = async ({ params, request, locals }) => { @@ -54,6 +55,7 @@ export const POST: APIRoute = async ({ params, request, locals }) => { configuredPluginIds, siteOrigin, sandboxBypassed: emdash.isSandboxBypassed(), + confirmMcpTools: body.confirmMcpTools, }, ); diff --git a/packages/core/src/astro/routes/api/admin/plugins/registry/[id]/update.ts b/packages/core/src/astro/routes/api/admin/plugins/registry/[id]/update.ts index 7d85b5c1e6..8f4f163197 100644 --- a/packages/core/src/astro/routes/api/admin/plugins/registry/[id]/update.ts +++ b/packages/core/src/astro/routes/api/admin/plugins/registry/[id]/update.ts @@ -36,6 +36,7 @@ const updateBodySchema = z.object({ * version newly exposes a public (unauthenticated) route. */ confirmRouteVisibilityChanges: z.boolean().optional(), + confirmMcpTools: z.boolean().optional(), }); export const POST: APIRoute = async ({ params, request, locals }) => { @@ -67,6 +68,7 @@ export const POST: APIRoute = async ({ params, request, locals }) => { version: body.version, confirmCapabilityChanges: body.confirmCapabilityChanges, confirmRouteVisibilityChanges: body.confirmRouteVisibilityChanges, + confirmMcpTools: body.confirmMcpTools, hostEnv: hostEnvFromVersions(VERSION, emdash.config.astroVersion), }, ); diff --git a/packages/core/src/astro/routes/api/admin/plugins/registry/install.ts b/packages/core/src/astro/routes/api/admin/plugins/registry/install.ts index 20e7a966d8..137536cd35 100644 --- a/packages/core/src/astro/routes/api/admin/plugins/registry/install.ts +++ b/packages/core/src/astro/routes/api/admin/plugins/registry/install.ts @@ -57,6 +57,7 @@ const installBodySchema = z.object({ * dialog and the install POST. */ acknowledgedDeclaredAccess: z.unknown().optional(), + acknowledgedMcpTools: z.unknown().optional(), }); export const POST: APIRoute = async ({ request, locals }) => { @@ -93,6 +94,7 @@ export const POST: APIRoute = async ({ request, locals }) => { slug: body.slug, version: body.version, acknowledgedDeclaredAccess: body.acknowledgedDeclaredAccess, + acknowledgedMcpTools: body.acknowledgedMcpTools, }, { configuredPluginIds: reservedPluginIds, diff --git a/packages/core/src/astro/routes/api/mcp.ts b/packages/core/src/astro/routes/api/mcp.ts index 86e4319b8e..3ce1511a02 100644 --- a/packages/core/src/astro/routes/api/mcp.ts +++ b/packages/core/src/astro/routes/api/mcp.ts @@ -30,7 +30,8 @@ export const POST: APIRoute = async ({ request, locals }) => { return apiError("UNAUTHORIZED", "Authentication required", 401); } - const server = createMcpServer(); + const pluginTools = await emdash.getEnabledPluginMcpTools(); + const server = createMcpServer(pluginTools, request); try { const transport = new WebStandardStreamableHTTPServerTransport({ diff --git a/packages/core/src/astro/routes/api/oauth/authorize.ts b/packages/core/src/astro/routes/api/oauth/authorize.ts index 9728e2c860..f935710457 100644 --- a/packages/core/src/astro/routes/api/oauth/authorize.ts +++ b/packages/core/src/astro/routes/api/oauth/authorize.ts @@ -23,7 +23,7 @@ import { } from "#api/handlers/oauth-authorization.js"; import { lookupOAuthClient, validateClientRedirectUri } from "#api/handlers/oauth-clients.js"; import { getPublicOrigin } from "#api/public-url.js"; -import { VALID_SCOPES } from "#auth/api-tokens.js"; +import { isValidScope } from "#auth/api-tokens.js"; export const prerender = false; @@ -141,11 +141,7 @@ export const GET: APIRoute = async ({ url, request, locals }) => { } // Parse and validate scopes - const validSet = new Set(VALID_SCOPES); - const requestedScopes = (scope ?? "") - .split(" ") - .filter(Boolean) - .filter((s) => validSet.has(s)); + const requestedScopes = (scope ?? "").split(" ").filter(Boolean).filter(isValidScope); if (requestedScopes.length === 0) { return new Response(renderErrorPage("No valid scopes requested."), { diff --git a/packages/core/src/astro/routes/api/plugins/[pluginId]/[...path].ts b/packages/core/src/astro/routes/api/plugins/[pluginId]/[...path].ts index bdd852aa82..aec51f0a6d 100644 --- a/packages/core/src/astro/routes/api/plugins/[pluginId]/[...path].ts +++ b/packages/core/src/astro/routes/api/plugins/[pluginId]/[...path].ts @@ -9,6 +9,7 @@ * Private routes (the default) require authentication and appropriate permissions. */ +import { Permissions, type Permission } from "@emdash-cms/auth"; import type { APIRoute } from "astro"; import { requirePerm } from "#api/authorize.js"; @@ -47,8 +48,13 @@ const handleRequest: APIRoute = async ({ params, request, locals }) => { // since HEAD is CORS-safe) invoke a mutating admin route by choosing the // method (#1853). Astro also dispatches HEAD to the GET export, so no // explicit HEAD handler is needed to reach a "GET" route. Gate every - // private invocation on `plugins:manage` + CSRF regardless of method. - const denied = requirePerm(user, "plugins:manage"); + // private invocation on the route's declared permission (defaulting to + // `plugins:manage`) + CSRF regardless of method. + const permission = routeMeta.permission ?? "plugins:manage"; + if (!(permission in Permissions)) { + return apiError("INVALID_PLUGIN_ROUTE", "Plugin route declares an invalid permission", 500); + } + const denied = requirePerm(user, permission as Permission); if (denied) return denied; // Token scope enforcement — plugin routes require "admin" scope. diff --git a/packages/core/src/astro/types.ts b/packages/core/src/astro/types.ts index f3cd045a5e..ba6e71b1c9 100644 --- a/packages/core/src/astro/types.ts +++ b/packages/core/src/astro/types.ts @@ -411,7 +411,54 @@ export interface EmDashHandlers { ) => Promise; // Plugin route metadata (for auth decisions before dispatch) - getPluginRouteMeta: (pluginId: string, path: string) => { public: boolean } | null; + getPluginRouteMeta: ( + pluginId: string, + path: string, + ) => { public: boolean; permission?: string } | null; + getEnabledPluginMcpTools: () => Promise< + Array<{ + pluginId: string; + name: string; + description: string; + route: string; + permission: string; + destructive: boolean; + inputSchema: import("zod").ZodType; + outputSchema?: import("zod").ZodType; + }> + >; + getPluginMcpTools: (pluginId?: string) => Promise< + Array<{ + pluginId: string; + name: string; + description: string; + route: string; + permission: string; + destructive: boolean; + inputSchema: import("zod").ZodType; + outputSchema?: import("zod").ZodType; + }> + >; + serializePluginMcpConsent: ( + tools: Awaited>, + pluginId: string, + ) => string; + handlePluginMcpTool: ( + pluginId: string, + toolName: string, + route: string, + input: unknown, + actorId: string, + request: Request, + ) => Promise; + handlePluginMcpDenied: ( + pluginId: string, + toolName: string, + route: string, + actorId: string, + request: Request, + reason: string, + ) => Promise; // Media provider handlers getMediaProvider: (providerId: string) => import("../media/types.js").MediaProvider | undefined; diff --git a/packages/core/src/auth/api-tokens.ts b/packages/core/src/auth/api-tokens.ts index 93ed2ee06e..214da7b747 100644 --- a/packages/core/src/auth/api-tokens.ts +++ b/packages/core/src/auth/api-tokens.ts @@ -19,6 +19,7 @@ export { hashPrefixedToken as hashApiToken, VALID_SCOPES, validateScopes, + isValidScope, hasScope, computeS256Challenge, type ApiTokenScope, diff --git a/packages/core/src/cli/commands/bundle-utils.ts b/packages/core/src/cli/commands/bundle-utils.ts index 21b45031ef..6db1053d08 100644 --- a/packages/core/src/cli/commands/bundle-utils.ts +++ b/packages/core/src/cli/commands/bundle-utils.ts @@ -12,6 +12,7 @@ import { pipeline } from "node:stream/promises"; import { imageSize } from "image-size"; import { packTar } from "modern-tar/fs"; +import { z } from "zod"; import { capabilitiesToDeclaredAccess } from "../../plugins/types.js"; import type { @@ -19,6 +20,8 @@ import type { ResolvedPlugin, HookName, ManifestHookEntry, + ManifestMcpTool, + ManifestRouteEntry, } from "../../plugins/types.js"; // ── Constants ──────────────────────────────────────────────────────────────── @@ -33,6 +36,7 @@ export const MAX_SCREENSHOTS = 5; export const MAX_SCREENSHOT_WIDTH = 1920; export const MAX_SCREENSHOT_HEIGHT = 1080; export const ICON_SIZE = 256; +const MCP_TOOL_NAME_PATTERN = /^[a-zA-Z0-9_-]+$/; // ── Regex patterns (module-scope to avoid re-compilation) ──────────────────── @@ -149,6 +153,33 @@ export function extractManifest(plugin: ResolvedPlugin): PluginManifest { } } + const routes: Array = Object.entries(plugin.routes).map( + ([name, route]) => + route.public !== undefined || route.permission !== undefined + ? { name, public: route.public, permission: route.permission } + : name, + ); + const tools: ManifestMcpTool[] = Object.entries(plugin.mcp?.tools ?? {}).map(([name, tool]) => { + if (!MCP_TOOL_NAME_PATTERN.test(name)) throw new Error(`Invalid MCP tool name "${name}"`); + const route = plugin.routes[tool.route]; + if (!route?.permission || route.public) { + throw new Error(`MCP tool "${name}" must reference a private route with a permission`); + } + return { + name, + description: tool.description, + route: tool.route, + permission: route.permission, + destructive: tool.destructive ?? false, + inputSchema: z.toJSONSchema(tool.input, { target: "draft-7" }), + ...(tool.output + ? { + outputSchema: z.toJSONSchema(tool.output, { target: "draft-7" }), + } + : {}), + }; + }); + return { id: plugin.id, version: plugin.version, @@ -157,7 +188,8 @@ export function extractManifest(plugin: ResolvedPlugin): PluginManifest { allowedHosts: plugin.allowedHosts, storage: plugin.storage, hooks, - routes: Object.keys(plugin.routes), + routes, + ...(tools.length > 0 ? { mcp: { tools } } : {}), admin: { // Omit entry (it's a module specifier for the host, not relevant in bundles) settingsSchema: plugin.admin.settingsSchema, diff --git a/packages/core/src/database/migrations/052_plugin_mcp_tools.ts b/packages/core/src/database/migrations/052_plugin_mcp_tools.ts new file mode 100644 index 0000000000..68115b1ced --- /dev/null +++ b/packages/core/src/database/migrations/052_plugin_mcp_tools.ts @@ -0,0 +1,21 @@ +import type { Kysely } from "kysely"; + +import { columnExists } from "../dialect-helpers.js"; + +/** Persist explicit per-plugin MCP enablement and the declaration consented to by an admin. */ +export async function up(db: Kysely): Promise { + if (!(await columnExists(db, "_plugin_state", "mcp_tools_enabled"))) { + await db.schema + .alterTable("_plugin_state") + .addColumn("mcp_tools_enabled", "integer", (col) => col.notNull().defaultTo(0)) + .execute(); + } + if (!(await columnExists(db, "_plugin_state", "mcp_tools_consent"))) { + await db.schema.alterTable("_plugin_state").addColumn("mcp_tools_consent", "text").execute(); + } +} + +export async function down(db: Kysely): Promise { + await db.schema.alterTable("_plugin_state").dropColumn("mcp_tools_consent").execute(); + await db.schema.alterTable("_plugin_state").dropColumn("mcp_tools_enabled").execute(); +} diff --git a/packages/core/src/database/migrations/runner.ts b/packages/core/src/database/migrations/runner.ts index ca2020144e..2270bc44e9 100644 --- a/packages/core/src/database/migrations/runner.ts +++ b/packages/core/src/database/migrations/runner.ts @@ -54,6 +54,7 @@ import * as m048 from "./048_restore_content_taxonomies_term_index.js"; import * as m049 from "./049_taxonomies_name_locale_index.js"; import * as m050 from "./050_media_usage_index_status.js"; import * as m051 from "./051_content_taxonomies_denorm.js"; +import * as m052 from "./052_plugin_mcp_tools.js"; const MIGRATIONS: Readonly> = Object.freeze({ "001_initial": m001, @@ -106,6 +107,7 @@ const MIGRATIONS: Readonly> = Object.freeze({ "049_taxonomies_name_locale_index": m049, "050_media_usage_index_status": m050, "051_content_taxonomies_denorm": m051, + "052_plugin_mcp_tools": m052, }); /** Total number of registered migrations. Exported for use in tests. */ diff --git a/packages/core/src/database/repositories/audit.ts b/packages/core/src/database/repositories/audit.ts index 5ab950dfed..fbe46bda89 100644 --- a/packages/core/src/database/repositories/audit.ts +++ b/packages/core/src/database/repositories/audit.ts @@ -14,7 +14,8 @@ export type AuditAction = | "logout" | "password_change" | "settings_update" - | "schema_change"; + | "schema_change" + | "plugin_tool_invoke"; export type AuditStatus = "success" | "failure" | "denied"; diff --git a/packages/core/src/database/types.ts b/packages/core/src/database/types.ts index b6023ed022..0f4ba13ed7 100644 --- a/packages/core/src/database/types.ts +++ b/packages/core/src/database/types.ts @@ -362,6 +362,8 @@ export interface PluginStateTable { // `source = 'config' | 'marketplace'`; populated for `source = 'registry'`. registry_publisher_did: string | null; registry_slug: string | null; + mcp_tools_enabled: Generated; + mcp_tools_consent: string | null; } export interface PluginIndexTable { diff --git a/packages/core/src/emdash-runtime.ts b/packages/core/src/emdash-runtime.ts index ecf6fa0c82..66229e664d 100644 --- a/packages/core/src/emdash-runtime.ts +++ b/packages/core/src/emdash-runtime.ts @@ -7,9 +7,11 @@ * Created once per worker lifetime, cached and reused across requests. */ +import { Permissions } from "@emdash-cms/auth"; import type { Element } from "@emdash-cms/blocks"; import { Kysely, sql, type Dialect } from "kysely"; import virtualConfig from "virtual:emdash/config"; +import { z } from "zod"; import { validateRev } from "./api/rev.js"; import type { @@ -27,6 +29,7 @@ import { MIGRATION_RACE_WAIT_MS, runMigrations, } from "./database/migrations/runner.js"; +import { AuditRepository } from "./database/repositories/audit.js"; import { RevisionRepository } from "./database/repositories/revision.js"; import type { ContentItem as ContentItemInternal, @@ -48,6 +51,7 @@ import type { PluginManifest, PluginCapability, PluginStorageConfig, + PluginMcpManifestConfig, PublicPageContext, PageMetadataContribution, PageFragmentContribution, @@ -221,6 +225,8 @@ export interface SandboxedPluginEntry { allowedHosts: string[]; /** Declared storage collections */ storage: PluginStorageConfig; + /** Serialized MCP declarations emitted at plugin build time. */ + mcp?: PluginMcpManifestConfig; /** Admin pages */ adminPages?: Array<{ path: string; label?: string; icon?: string }>; /** Dashboard widgets */ @@ -471,6 +477,7 @@ const marketplaceManifestCache = new Map< pages?: PluginAdminPage[]; widgets?: PluginDashboardWidget[]; }; + mcp?: PluginMcpManifestConfig; } >(); /** Route metadata for sandboxed plugins: pluginId -> routeName -> RouteMeta */ @@ -870,6 +877,7 @@ export class EmDashRuntime { id: bundle.manifest.id, version: bundle.manifest.version, admin: bundle.manifest.admin, + mcp: bundle.manifest.mcp, }); // Cache route metadata from manifest for auth decisions @@ -877,7 +885,10 @@ export class EmDashRuntime { const routeMetaMap = new Map(); for (const entry of bundle.manifest.routes) { const normalized = normalizeManifestRoute(entry); - routeMetaMap.set(normalized.name, { public: normalized.public === true }); + routeMetaMap.set(normalized.name, { + public: normalized.public === true, + permission: normalized.permission, + }); } sandboxedRouteMetaCache.set(pluginId, routeMetaMap); } else { @@ -984,12 +995,16 @@ export class EmDashRuntime { id: bundle.manifest.id, version: bundle.manifest.version, admin: bundle.manifest.admin, + mcp: bundle.manifest.mcp, }); if (bundle.manifest.routes.length > 0) { const routeMetaMap = new Map(); for (const entry of bundle.manifest.routes) { const normalized = normalizeManifestRoute(entry); - routeMetaMap.set(normalized.name, { public: normalized.public === true }); + routeMetaMap.set(normalized.name, { + public: normalized.public === true, + permission: normalized.permission, + }); } sandboxedRouteMetaCache.set(pluginId, routeMetaMap); } else { @@ -1906,6 +1921,7 @@ export class EmDashRuntime { hooks: [], routes: [], admin: {}, + mcp: entry.mcp, }; const plugin = await sandboxRunner.load(manifest, entry.code); @@ -2003,6 +2019,7 @@ export class EmDashRuntime { id: bundle.manifest.id, version: bundle.manifest.version, admin: bundle.manifest.admin, + mcp: bundle.manifest.mcp, }); // Cache route metadata from manifest for auth decisions @@ -2010,7 +2027,10 @@ export class EmDashRuntime { const routeMeta = new Map(); for (const entry of bundle.manifest.routes) { const normalized = normalizeManifestRoute(entry); - routeMeta.set(normalized.name, { public: normalized.public === true }); + routeMeta.set(normalized.name, { + public: normalized.public === true, + permission: normalized.permission, + }); } sandboxedRouteMetaCache.set(plugin.pluginId, routeMeta); } @@ -2074,12 +2094,16 @@ export class EmDashRuntime { id: bundle.manifest.id, version: bundle.manifest.version, admin: bundle.manifest.admin, + mcp: bundle.manifest.mcp, }); if (bundle.manifest.routes.length > 0) { const routeMeta = new Map(); for (const entry of bundle.manifest.routes) { const normalized = normalizeManifestRoute(entry); - routeMeta.set(normalized.name, { public: normalized.public === true }); + routeMeta.set(normalized.name, { + public: normalized.public === true, + permission: normalized.permission, + }); } sandboxedRouteMetaCache.set(plugin.pluginId, routeMeta); } @@ -3286,7 +3310,7 @@ export class EmDashRuntime { if (trustedPlugin) { const route = trustedPlugin.routes[routeKey]; if (!route) return null; - return { public: route.public === true }; + return { public: route.public === true, permission: route.permission }; } // Check sandboxed plugin route metadata cache @@ -3364,6 +3388,168 @@ export class EmDashRuntime { }; } + async getPluginMcpTools(pluginId?: string) { + const tools: Array<{ + pluginId: string; + name: string; + description: string; + route: string; + permission: string; + destructive: boolean; + inputSchema: z.ZodType; + outputSchema?: z.ZodType; + }> = []; + const seen = new Set(); + + for (const plugin of this.configuredPlugins) { + if (pluginId && plugin.id !== pluginId) continue; + for (const [name, tool] of Object.entries(plugin.mcp?.tools ?? {})) { + const route = plugin.routes[tool.route]; + if (!route || route.public || !route.permission || !(route.permission in Permissions)) + continue; + const key = `${plugin.id}__${name}`; + if (seen.has(key)) continue; + seen.add(key); + tools.push({ + pluginId: plugin.id, + name, + description: tool.description, + route: tool.route, + permission: route.permission, + destructive: tool.destructive ?? false, + inputSchema: tool.input, + outputSchema: tool.output, + }); + } + } + + const addManifestTools = (id: string, mcp: PluginMcpManifestConfig | undefined) => { + if (pluginId && id !== pluginId) return; + for (const tool of mcp?.tools ?? []) { + const key = `${id}__${tool.name}`; + const routeMeta = this.getPluginRouteMeta(id, tool.route); + if ( + seen.has(key) || + !routeMeta || + routeMeta.public || + routeMeta.permission !== tool.permission || + !(tool.permission in Permissions) + ) { + continue; + } + seen.add(key); + tools.push({ + pluginId: id, + name: tool.name, + description: tool.description, + route: tool.route, + permission: tool.permission, + destructive: tool.destructive, + inputSchema: z.fromJSONSchema({ ...tool.inputSchema }), + outputSchema: tool.outputSchema ? z.fromJSONSchema({ ...tool.outputSchema }) : undefined, + }); + } + }; + + for (const entry of this.sandboxedPluginEntries) addManifestTools(entry.id, entry.mcp); + for (const [id, manifest] of marketplaceManifestCache) addManifestTools(id, manifest.mcp); + + return tools; + } + + async getEnabledPluginMcpTools() { + const [tools, states] = await Promise.all([ + this.getPluginMcpTools(), + new PluginStateRepository(this.db).getAll(), + ]); + const stateByPlugin = new Map(states.map((state) => [state.pluginId, state])); + return tools.filter((tool) => { + const state = stateByPlugin.get(tool.pluginId); + if ( + !state?.mcpToolsEnabled || + state.status !== "active" || + !this.isPluginEnabled(tool.pluginId) + ) { + return false; + } + const consented = state.mcpToolsConsent; + return consented === this.serializePluginMcpConsent(tools, tool.pluginId); + }); + } + + serializePluginMcpConsent( + tools: Awaited>, + pluginId: string, + ): string { + return JSON.stringify( + tools + .filter((tool) => tool.pluginId === pluginId) + .map((tool) => ({ + name: tool.name, + description: tool.description, + route: tool.route, + permission: tool.permission, + destructive: tool.destructive, + inputSchema: z.toJSONSchema(tool.inputSchema, { target: "draft-7" }), + ...(tool.outputSchema + ? { outputSchema: z.toJSONSchema(tool.outputSchema, { target: "draft-7" }) } + : {}), + })) + .toSorted((a, b) => a.name.localeCompare(b.name)), + ); + } + + async handlePluginMcpTool( + pluginId: string, + toolName: string, + route: string, + input: unknown, + actorId: string, + request: Request, + ) { + const requestMeta = extractRequestMeta(request, getTrustedProxyHeaders(this.config)); + const audit = new AuditRepository(this.db); + const headers = new Headers(request.headers); + headers.delete("content-length"); + headers.delete("content-encoding"); + const internalRequest = new Request(request.url, { + method: "POST", + headers, + body: JSON.stringify(input), + }); + const result = await this.handlePluginApiRoute(pluginId, "POST", route, internalRequest); + await audit.log({ + actorId, + actorIp: requestMeta.ip ?? undefined, + action: "plugin_tool_invoke", + resourceType: "plugin_mcp_tool", + resourceId: `${pluginId}__${toolName}`, + details: { pluginId, tool: toolName, route }, + status: result.success ? "success" : "failure", + }); + return result; + } + + async handlePluginMcpDenied( + pluginId: string, + toolName: string, + route: string, + actorId: string, + request: Request, + reason: string, + ): Promise { + const requestMeta = extractRequestMeta(request, getTrustedProxyHeaders(this.config)); + await new AuditRepository(this.db).log({ + actorId, + actorIp: requestMeta.ip ?? undefined, + action: "plugin_tool_invoke", + resourceType: "plugin_mcp_tool", + resourceId: `${pluginId}__${toolName}`, + details: { pluginId, tool: toolName, route, reason }, + status: "denied", + }); + } + // ========================================================================= // Sandboxed Plugin Helpers // ========================================================================= diff --git a/packages/core/src/mcp/server.ts b/packages/core/src/mcp/server.ts index 9dd2ca9965..8e299864de 100644 --- a/packages/core/src/mcp/server.ts +++ b/packages/core/src/mcp/server.ts @@ -10,7 +10,7 @@ */ import type { Permission, RoleLevel } from "@emdash-cms/auth"; -import { canActOnOwn, hasPermission, Role } from "@emdash-cms/auth"; +import { canActOnOwn, hasPermission, Permissions, Role } from "@emdash-cms/auth"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; @@ -88,6 +88,7 @@ type HandlerResult = { type SuccessEnvelope = { content: Array<{ type: "text"; text: string }>; + structuredContent?: Record; _meta?: Record; }; @@ -450,7 +451,21 @@ function extractContentId(data: unknown): string | undefined { // Server factory // --------------------------------------------------------------------------- -export function createMcpServer(): McpServer { +export interface PluginMcpRegistration { + pluginId: string; + name: string; + description: string; + route: string; + permission: string; + destructive: boolean; + inputSchema: z.ZodType; + outputSchema?: z.ZodType; +} + +export function createMcpServer( + pluginTools: PluginMcpRegistration[] = [], + request?: Request, +): McpServer { const server = new McpServer( { name: "emdash", version: "0.1.0" }, { capabilities: { logging: {} } }, @@ -484,6 +499,78 @@ export function createMcpServer(): McpServer { )(name, config, wrapped); }) as typeof server.registerTool; + for (const tool of pluginTools) { + if (!(tool.permission in Permissions)) continue; + server.registerTool( + `${tool.pluginId}__${tool.name}`, + { + description: tool.description, + inputSchema: tool.inputSchema, + outputSchema: tool.outputSchema, + annotations: { destructiveHint: tool.destructive }, + }, + async (input, extra) => { + const payload = getExtra(extra); + const requiredScope = `mcp:tools:${tool.pluginId}`; + if (payload.tokenScopes && !hasScope(payload.tokenScopes, requiredScope)) { + if (request) { + await payload.emdash.handlePluginMcpDenied( + tool.pluginId, + tool.name, + tool.route, + payload.userId, + request, + `Missing scope: ${requiredScope}`, + ); + } + throw new EmDashAuthError( + `Insufficient scope: requires ${requiredScope}`, + "INSUFFICIENT_SCOPE", + ); + } + if (!hasPermission({ role: payload.userRole }, tool.permission as Permission)) { + if (request) { + await payload.emdash.handlePluginMcpDenied( + tool.pluginId, + tool.name, + tool.route, + payload.userId, + request, + `Missing permission: ${tool.permission}`, + ); + } + throw new EmDashAuthError( + `Insufficient permission: requires ${tool.permission}`, + "INSUFFICIENT_PERMISSIONS", + ); + } + if (!request) return respondError("INTERNAL_ERROR", "Missing MCP request context"); + const result = await payload.emdash.handlePluginMcpTool( + tool.pluginId, + tool.name, + tool.route, + input, + payload.userId, + request, + ); + if (!result.success) return unwrap(result); + if (tool.outputSchema) { + if (!result.data || typeof result.data !== "object" || Array.isArray(result.data)) { + return respondError( + "INVALID_PLUGIN_OUTPUT", + "Plugin tool output must be an object when outputSchema is declared", + ); + } + return { + content: [{ type: "text", text: JSON.stringify(result.data, null, 2) }], + structuredContent: result.data as Record, + }; + } + return respondData(result.data); + }, + ); + } + // ===================================================================== // Content tools // ===================================================================== diff --git a/packages/core/src/plugin-types.ts b/packages/core/src/plugin-types.ts index 2950f0194c..d7e594bf7e 100644 --- a/packages/core/src/plugin-types.ts +++ b/packages/core/src/plugin-types.ts @@ -38,6 +38,9 @@ * per-hook return contracts so misuse fails at compile time. */ +import type { Permission } from "@emdash-cms/auth"; +import type { ZodType } from "zod"; + import type { CommentAfterCreateEvent, CommentAfterCreateHandler, @@ -201,8 +204,17 @@ export type RouteEntry = handler: RouteHandler; public?: boolean; input?: unknown; + permission?: Permission; }; +export interface SandboxedMcpTool { + description: string; + route: string; + input: ZodType; + output?: ZodType; + destructive?: boolean; +} + /** * The shape of a sandboxed plugin's default export. * @@ -217,6 +229,7 @@ export interface SandboxedPlugin { [K in keyof HookHandlers]?: HookEntry; }; routes?: Record; + mcp?: { tools: Record }; } /** diff --git a/packages/core/src/plugins/adapt-sandbox-entry.ts b/packages/core/src/plugins/adapt-sandbox-entry.ts index 760b4e42c0..159c2d957c 100644 --- a/packages/core/src/plugins/adapt-sandbox-entry.ts +++ b/packages/core/src/plugins/adapt-sandbox-entry.ts @@ -109,6 +109,7 @@ function normalizeRouteEntry(entry: RouteEntry): { handler: RouteHandler; public?: boolean; input?: PluginRoute["input"]; + permission?: PluginRoute["permission"]; } { if (typeof entry === "function") { return { handler: entry }; @@ -116,6 +117,7 @@ function normalizeRouteEntry(entry: RouteEntry): { return { handler: entry.handler, public: entry.public, + permission: entry.permission, // eslint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- RouteEntry.input is intentionally `unknown` (sandboxed plugins) and validated by the runtime at invocation time input: entry.input as PluginRoute["input"], }; @@ -196,10 +198,11 @@ export function adaptSandboxEntry( if (definition.routes) { for (const [routeName, rawEntry] of Object.entries(definition.routes)) { const normalized = normalizeRouteEntry(rawEntry); - const { handler, public: publicFlag, input: inputSchema } = normalized; + const { handler, public: publicFlag, input: inputSchema, permission } = normalized; resolvedRoutes[routeName] = { input: inputSchema, public: publicFlag, + permission, handler: async (ctx) => { // `ctx.request` is a real WHATWG `Request` (this is the // in-process adapter; the worker-sandbox adapter handles @@ -305,6 +308,20 @@ export function adaptSandboxEntry( storage, hooks: resolvedHooks, routes: resolvedRoutes, + mcp: { + tools: Object.fromEntries( + Object.entries(definition.mcp?.tools ?? {}).map(([name, tool]) => [ + name, + { + description: tool.description, + route: tool.route, + input: tool.input, + output: tool.output, + destructive: tool.destructive, + }, + ]), + ), + }, admin, }; } diff --git a/packages/core/src/plugins/define-plugin.ts b/packages/core/src/plugins/define-plugin.ts index 1cea2d70e7..60d9d6419e 100644 --- a/packages/core/src/plugins/define-plugin.ts +++ b/packages/core/src/plugins/define-plugin.ts @@ -22,6 +22,8 @@ import type { PluginStorageConfig, } from "./types.js"; +const MCP_TOOL_NAME_PATTERN = /^[a-zA-Z0-9_-]+$/; + /** * Define a native EmDash plugin. * @@ -110,6 +112,7 @@ function defineNativePlugin( allowedHosts = [], hooks = {}, routes = {}, + mcp = { tools: {} }, admin = {}, } = definition; @@ -132,6 +135,18 @@ function defineNativePlugin( throw new Error(`Invalid plugin version "${version}". Must be semver format (e.g., "1.0.0").`); } + for (const [name, tool] of Object.entries(mcp.tools)) { + if (!MCP_TOOL_NAME_PATTERN.test(name)) { + throw new Error(`Invalid MCP tool name "${name}" in plugin "${id}".`); + } + const route = routes[tool.route]; + if (!route) throw new Error(`MCP tool "${name}" references unknown route "${tool.route}".`); + if (route.public) throw new Error(`MCP tool "${name}" cannot reference a public route.`); + if (!route.permission) { + throw new Error(`MCP route "${tool.route}" must declare a permission.`); + } + } + // Validate capabilities. Both current names and deprecated aliases are // accepted; aliases are silently rewritten to current names below so the // runtime only ever sees the canonical form. Authors are warned at @@ -202,6 +217,7 @@ function defineNativePlugin( storage, hooks: resolvedHooks, routes, + mcp, admin, }; } diff --git a/packages/core/src/plugins/manifest-schema.ts b/packages/core/src/plugins/manifest-schema.ts index da6716ecf6..430ae125b3 100644 --- a/packages/core/src/plugins/manifest-schema.ts +++ b/packages/core/src/plugins/manifest-schema.ts @@ -8,6 +8,7 @@ * - Marketplace ingest extends this with publishing-specific fields */ +import { Permissions } from "@emdash-cms/auth"; import { capabilitiesToDeclaredAccess, declaredAccessToCapabilities, @@ -137,6 +138,26 @@ const routeNamePattern = /^[a-zA-Z0-9][a-zA-Z0-9_\-/]*$/; const manifestRouteEntrySchema = z.object({ name: z.string().min(1).regex(routeNamePattern, "Route name must be a safe path segment"), public: z.boolean().optional(), + permission: z + .string() + .refine((permission) => permission in Permissions) + .optional(), +}); + +const pluginJsonSchema = z.record(z.string(), z.unknown()); +const mcpToolNamePattern = /^[a-zA-Z0-9_-]+$/; +const pluginMcpConfigSchema = z.object({ + tools: z.array( + z.object({ + name: z.string().min(1).max(64).regex(mcpToolNamePattern, "Invalid MCP tool name"), + description: z.string().min(1), + route: z.string().min(1).regex(routeNamePattern, "Route name must be a safe path segment"), + permission: z.string().refine((permission) => permission in Permissions), + destructive: z.boolean(), + inputSchema: pluginJsonSchema, + outputSchema: pluginJsonSchema.optional(), + }), + ), }); // ── Sub-schemas ───────────────────────────────────────────────── @@ -307,6 +328,7 @@ export const pluginManifestSchema = z.object({ manifestRouteEntrySchema, ]), ), + mcp: pluginMcpConfigSchema.optional(), admin: pluginAdminConfigSchema, }); @@ -349,9 +371,12 @@ export function normalizeManifestHook( /** * Normalize a manifest route entry — plain strings become `{ name }` objects. */ -export function normalizeManifestRoute(entry: string | { name: string; public?: boolean }): { +export function normalizeManifestRoute( + entry: string | { name: string; public?: boolean; permission?: string }, +): { name: string; public?: boolean; + permission?: string; } { if (typeof entry === "string") { return { name: entry }; diff --git a/packages/core/src/plugins/routes.ts b/packages/core/src/plugins/routes.ts index 24b02a58df..b47be54624 100644 --- a/packages/core/src/plugins/routes.ts +++ b/packages/core/src/plugins/routes.ts @@ -54,6 +54,7 @@ function guardConsumedRequestBody(request: Request): Request { */ export interface RouteMeta { public: boolean; + permission?: string; } /** @@ -199,7 +200,7 @@ export class PluginRouteHandler { getRouteMeta(name: string): RouteMeta | null { const route: PluginRoute | undefined = this.plugin.routes[name]; if (!route) return null; - return { public: route.public === true }; + return { public: route.public === true, permission: route.permission }; } } diff --git a/packages/core/src/plugins/state.ts b/packages/core/src/plugins/state.ts index a75817469c..498b868d93 100644 --- a/packages/core/src/plugins/state.ts +++ b/packages/core/src/plugins/state.ts @@ -49,6 +49,8 @@ export interface PluginState { * `packages/core/src/registry/plugin-id.ts`. */ registrySlug: string | null; + mcpToolsEnabled: boolean; + mcpToolsConsent: string | null; } /** @@ -121,6 +123,8 @@ export class PluginStateRepository { description?: string; registryPublisherDid?: string; registrySlug?: string; + mcpToolsEnabled?: boolean; + mcpToolsConsent?: string | null; }, ): Promise { const now = new Date().toISOString(); @@ -128,7 +132,7 @@ export class PluginStateRepository { if (existing) { // Update existing state - const updates: Record = { + const updates: Record = { status, version, }; @@ -155,6 +159,12 @@ export class PluginStateRepository { if (opts?.registrySlug !== undefined) { updates.registry_slug = opts.registrySlug; } + if (opts?.mcpToolsEnabled !== undefined) { + updates.mcp_tools_enabled = opts.mcpToolsEnabled ? 1 : 0; + } + if (opts?.mcpToolsConsent !== undefined) { + updates.mcp_tools_consent = opts.mcpToolsConsent; + } await this.db .updateTable("_plugin_state") @@ -179,6 +189,8 @@ export class PluginStateRepository { description: opts?.description ?? null, registry_publisher_did: opts?.registryPublisherDid ?? null, registry_slug: opts?.registrySlug ?? null, + mcp_tools_enabled: opts?.mcpToolsEnabled ? 1 : 0, + mcp_tools_consent: opts?.mcpToolsConsent ?? null, }) .execute(); } @@ -234,6 +246,8 @@ interface PluginStateRow { description: string | null; registry_publisher_did: string | null; registry_slug: string | null; + mcp_tools_enabled: number; + mcp_tools_consent: string | null; } function rowToPluginState(row: PluginStateRow): PluginState { @@ -250,5 +264,7 @@ function rowToPluginState(row: PluginStateRow): PluginState { description: row.description ?? null, registryPublisherDid: row.registry_publisher_did ?? null, registrySlug: row.registry_slug ?? null, + mcpToolsEnabled: row.mcp_tools_enabled === 1, + mcpToolsConsent: row.mcp_tools_consent ?? null, }; } diff --git a/packages/core/src/plugins/types.ts b/packages/core/src/plugins/types.ts index 981172f0c1..07d453ff88 100644 --- a/packages/core/src/plugins/types.ts +++ b/packages/core/src/plugins/types.ts @@ -9,6 +9,7 @@ * */ +import type { Permission } from "@emdash-cms/auth"; import type { Element } from "@emdash-cms/blocks"; // The plugin capability vocabulary, the legacy-rename map, and the manifest // shape are authored once in @emdash-cms/plugin-types and shared between core @@ -28,7 +29,9 @@ import { type DeclaredAccess, type DeprecatedPluginCapability, type ManifestHookEntry, + type ManifestMcpTool, type ManifestRouteEntry, + type PluginMcpManifestConfig, type PluginCapability, type PluginStorageConfig, type StorageCollectionConfig, @@ -52,7 +55,9 @@ export { type DeclaredAccess, type DeprecatedPluginCapability, type ManifestHookEntry, + type ManifestMcpTool, type ManifestRouteEntry, + type PluginMcpManifestConfig, type PluginCapability, type PluginStorageConfig, type StorageCollectionConfig, @@ -1173,10 +1178,24 @@ export interface PluginRoute { * Public routes skip session/token auth and CSRF checks. */ public?: boolean; + /** RBAC permission required to invoke the route. Legacy routes default to plugins:manage. */ + permission?: Permission; /** Route handler */ handler: (ctx: RouteContext) => Promise; } +export interface PluginMcpToolDefinition { + description: string; + route: string; + input: z.ZodType; + output?: z.ZodType; + destructive?: boolean; +} + +export interface PluginMcpConfig { + tools: Record; +} + // ============================================================================= // Plugin Definition // ============================================================================= @@ -1358,6 +1377,9 @@ export interface PluginDefinition; + /** Routes explicitly exposed as agent-callable MCP tools. */ + mcp?: PluginMcpConfig; + /** Admin UI configuration */ admin?: PluginAdminConfig; } @@ -1373,6 +1395,7 @@ export interface ResolvedPlugin; + mcp?: PluginMcpConfig; admin: PluginAdminConfig; } @@ -1453,6 +1476,7 @@ export interface PluginManifest { hooks: Array; /** Route declarations — either plain name strings or structured objects */ routes: Array; + mcp?: PluginMcpManifestConfig; admin: PluginAdminConfig; } diff --git a/packages/core/tests/integration/astro/admin-plugins-sandboxed.test.ts b/packages/core/tests/integration/astro/admin-plugins-sandboxed.test.ts index d2c698f641..08da877151 100644 --- a/packages/core/tests/integration/astro/admin-plugins-sandboxed.test.ts +++ b/packages/core/tests/integration/astro/admin-plugins-sandboxed.test.ts @@ -70,6 +70,7 @@ function facade(runtime: EmDashRuntime) { sandboxedPluginEntries: runtime.sandboxedPluginEntries, config: runtime.config, setPluginStatus: runtime.setPluginStatus.bind(runtime), + getPluginMcpTools: runtime.getPluginMcpTools.bind(runtime), }; } diff --git a/packages/core/tests/integration/database/migrations.test.ts b/packages/core/tests/integration/database/migrations.test.ts index 6380bf5d04..bd1624e660 100644 --- a/packages/core/tests/integration/database/migrations.test.ts +++ b/packages/core/tests/integration/database/migrations.test.ts @@ -138,6 +138,7 @@ describe("Database Migrations (Integration)", () => { "049_taxonomies_name_locale_index", "050_media_usage_index_status", "051_content_taxonomies_denorm", + "052_plugin_mcp_tools", ]; await db.deleteFrom("_emdash_migrations").where("name", "in", trailing).execute(); diff --git a/packages/core/tests/integration/runtime/plugin-media-route.test.ts b/packages/core/tests/integration/runtime/plugin-media-route.test.ts index 323992079e..4bfe88469a 100644 --- a/packages/core/tests/integration/runtime/plugin-media-route.test.ts +++ b/packages/core/tests/integration/runtime/plugin-media-route.test.ts @@ -11,7 +11,7 @@ import { randomUUID } from "node:crypto"; import Database from "better-sqlite3"; import { SqliteDialect } from "kysely"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { EmDashRuntime } from "../../../src/emdash-runtime.js"; import type { RuntimeDependencies } from "../../../src/emdash-runtime.js"; @@ -113,3 +113,44 @@ describe("EmDashRuntime.handlePluginApiRoute — media:write", () => { } }); }); + +describe("EmDashRuntime.handlePluginMcpTool", () => { + it("removes stale representation headers when replacing the request body", async () => { + const { storage } = createFakeStorage(); + const runtime = await EmDashRuntime.create(createDeps(storage)); + let capturedRequest: Request | undefined; + vi.spyOn(runtime, "handlePluginApiRoute").mockImplementation( + async (_pluginId, _method, _route, request) => { + capturedRequest = request; + return { success: true, data: { ok: true } }; + }, + ); + + try { + const input = { message: "short" }; + await runtime.handlePluginMcpTool( + "media-uploader", + "echo", + "echo", + input, + "test-actor", + new Request("http://test.local/_emdash/api/mcp", { + method: "POST", + headers: { + "content-type": "application/json", + "content-length": "4096", + "content-encoding": "gzip", + }, + body: JSON.stringify({ jsonrpc: "2.0", method: "tools/call", params: input }), + }), + ); + + expect(capturedRequest).toBeDefined(); + expect(capturedRequest!.headers.get("content-length")).toBeNull(); + expect(capturedRequest!.headers.get("content-encoding")).toBeNull(); + expect(await capturedRequest!.json()).toEqual(input); + } finally { + await runtime.stopCron(); + } + }); +}); diff --git a/packages/core/tests/integration/smoke/site-matrix-smoke.test.ts b/packages/core/tests/integration/smoke/site-matrix-smoke.test.ts index c2a39f6b85..b250e78376 100644 --- a/packages/core/tests/integration/smoke/site-matrix-smoke.test.ts +++ b/packages/core/tests/integration/smoke/site-matrix-smoke.test.ts @@ -4,6 +4,7 @@ import { join, resolve } from "node:path"; import { pathToFileURL } from "node:url"; import { promisify } from "node:util"; +import Database from "better-sqlite3"; import { describe, expect, it } from "vitest"; import { ensureBuilt } from "../server.js"; @@ -292,6 +293,13 @@ const MCP_SITES: SiteCase[] = SITE_MATRIX.filter( (s) => s.name === "templates/blog" || s.name === "templates/starter-cloudflare", ); +const PLUGIN_MCP_SITE: SiteCase = { + name: "demos/simple", + dir: resolve(WORKSPACE_ROOT, "demos/simple"), + port: 4620, + startupTimeoutMs: 90_000, +}; + describe.sequential("MCP endpoint verification", () => { for (const site of MCP_SITES) { it( @@ -421,6 +429,158 @@ describe.sequential("MCP endpoint verification", () => { }, ); } + + it( + "plugin MCP enablement, scoped invocation, and auditing work end to end", + { timeout: PLUGIN_MCP_SITE.startupTimeoutMs + 120_000 }, + async () => { + const server = await bootSite(PLUGIN_MCP_SITE); + const headers = (token: string) => ({ + Accept: "application/json, text/event-stream", + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + "X-EmDash-Request": "1", + }); + const listTools = async (token: string) => { + const response = await fetch(`${server.baseUrl}/_emdash/api/mcp`, { + method: "POST", + headers: headers(token), + body: JSON.stringify([ + { jsonrpc: "2.0", method: "notifications/initialized" }, + { jsonrpc: "2.0", id: 1, method: "tools/list", params: {} }, + ]), + }); + expect(response.status).toBe(200); + return parseSSE(await response.text()); + }; + const callEcho = async (token: string, message: string) => { + const response = await fetch(`${server.baseUrl}/_emdash/api/mcp`, { + method: "POST", + headers: headers(token), + body: JSON.stringify([ + { jsonrpc: "2.0", method: "notifications/initialized" }, + { + jsonrpc: "2.0", + id: 2, + method: "tools/call", + params: { name: "mcp-smoke__echo", arguments: { message } }, + }, + ]), + }); + expect(response.status).toBe(200); + return parseSSE(await response.text()); + }; + + try { + const setupResponse = await fetchWithRetry( + `${server.baseUrl}/_emdash/api/setup/dev-bypass?token=1`, + ); + expect(setupResponse.status).toBe(200); + const setup = (await setupResponse.json()) as { data?: { token?: string } }; + const adminToken = setup.data?.token; + expect(adminToken).toBeTruthy(); + if (!adminToken) throw new Error("Dev bypass did not return an admin token"); + + const beforeEnable = await listTools(adminToken); + expect(beforeEnable).not.toHaveProperty( + "result.tools", + expect.arrayContaining([expect.objectContaining({ name: "mcp-smoke__echo" })]), + ); + + const enableResponse = await fetch( + `${server.baseUrl}/_emdash/api/admin/plugins/mcp-smoke/mcp`, + { + method: "PUT", + headers: headers(adminToken), + body: JSON.stringify({ enabled: true }), + }, + ); + expect(enableResponse.status).toBe(200); + + const tokenResponse = await fetch(`${server.baseUrl}/_emdash/api/admin/api-tokens`, { + method: "POST", + headers: headers(adminToken), + body: JSON.stringify({ name: "Plugin MCP smoke", scopes: ["mcp:tools:mcp-smoke"] }), + }); + expect(tokenResponse.status).toBe(201); + const created = (await tokenResponse.json()) as { data?: { token?: string } }; + const scopedToken = created.data?.token; + expect(scopedToken).toBeTruthy(); + if (!scopedToken) throw new Error("Token creation did not return a token"); + + const listed = await listTools(scopedToken); + expect(listed).toHaveProperty( + "result.tools", + expect.arrayContaining([ + expect.objectContaining({ + name: "mcp-smoke__echo", + annotations: expect.objectContaining({ destructiveHint: false }), + }), + ]), + ); + + const denied = await callEcho(adminToken, "blocked"); + expect(denied).toHaveProperty("result.isError", true); + expect(denied).toHaveProperty("result._meta.code", "INSUFFICIENT_SCOPE"); + + const invoked = await callEcho(scopedToken, "hello"); + expect(invoked).not.toHaveProperty("result.isError", true); + expect(invoked).toHaveProperty("result.structuredContent", { + message: "hello", + length: 5, + }); + + const db = new Database(join(PLUGIN_MCP_SITE.dir, "data.db"), { readonly: true }); + try { + const auditRows = db + .prepare( + "SELECT action, resource_type, resource_id, status FROM audit_logs WHERE resource_id = ? ORDER BY timestamp", + ) + .all("mcp-smoke__echo"); + expect(auditRows).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + action: "plugin_tool_invoke", + resource_type: "plugin_mcp_tool", + status: "denied", + }), + expect.objectContaining({ + action: "plugin_tool_invoke", + resource_type: "plugin_mcp_tool", + status: "success", + }), + ]), + ); + } finally { + db.close(); + } + + const disableResponse = await fetch( + `${server.baseUrl}/_emdash/api/admin/plugins/mcp-smoke/mcp`, + { + method: "PUT", + headers: headers(adminToken), + body: JSON.stringify({ enabled: false }), + }, + ); + expect(disableResponse.status).toBe(200); + + const afterDisable = await listTools(scopedToken); + expect(afterDisable).not.toHaveProperty( + "result.tools", + expect.arrayContaining([expect.objectContaining({ name: "mcp-smoke__echo" })]), + ); + } catch (error) { + throw new Error( + `Plugin MCP smoke failed: ${error instanceof Error ? error.message : String(error)}\n\n` + + server.output.slice(-3000), + { cause: error }, + ); + } finally { + await killServer(server.process); + } + }, + ); }); /** diff --git a/packages/core/tests/unit/api/marketplace-handlers.test.ts b/packages/core/tests/unit/api/marketplace-handlers.test.ts index 1bb5d418e0..09bacc1c2d 100644 --- a/packages/core/tests/unit/api/marketplace-handlers.test.ts +++ b/packages/core/tests/unit/api/marketplace-handlers.test.ts @@ -349,6 +349,47 @@ describe("Marketplace handlers", () => { expect(state?.status).toBe("active"); }); + it("requires explicit consent before installing plugin MCP tools", async () => { + const manifest: PluginManifest = { + ...mockManifest("test-seo", "1.0.0"), + routes: [{ name: "events/create", permission: "content:create" }], + mcp: { + tools: [ + { + name: "createEvent", + description: "Create a calendar event.", + route: "events/create", + permission: "content:create", + destructive: false, + inputSchema: { type: "object" }, + }, + ], + }, + }; + const bundleBytes = await createMockBundle(manifest); + const detail = mockPluginDetail("test-seo", "1.0.0"); + detail.latestVersion!.checksum = ""; + fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify(detail), { status: 200 })); + fetchSpy.mockResolvedValueOnce(new Response(bundleBytes, { status: 200 })); + + const result = await handleMarketplaceInstall( + db, + storage, + sandboxRunner, + MARKETPLACE_URL, + "test-seo", + ); + + expect(result).toMatchObject({ + success: false, + error: { + code: "MCP_TOOL_CONSENT_REQUIRED", + details: { mcpTools: [expect.objectContaining({ name: "createEvent" })] }, + }, + }); + expect(await new PluginStateRepository(db).get("test-seo")).toBeNull(); + }); + it("rejects install if plugin already installed", async () => { // Pre-install the plugin const repo = new PluginStateRepository(db); diff --git a/packages/core/tests/unit/astro/middleware-prerender.test.ts b/packages/core/tests/unit/astro/middleware-prerender.test.ts index 5475e0c7da..a118523dc7 100644 --- a/packages/core/tests/unit/astro/middleware-prerender.test.ts +++ b/packages/core/tests/unit/astro/middleware-prerender.test.ts @@ -66,6 +66,11 @@ const { handleRevisionRestore: ok, getPluginRouteMeta, handlePluginApiRoute, + getPluginMcpTools: async () => [], + getEnabledPluginMcpTools: async () => [], + serializePluginMcpConsent: () => "[]", + handlePluginMcpTool: ok, + handlePluginMcpDenied: async () => undefined, getMediaProvider: () => undefined, getMediaProviderList: () => [], collectPageMetadata: async () => [], diff --git a/packages/core/tests/unit/astro/plugin-api-route-auth.test.ts b/packages/core/tests/unit/astro/plugin-api-route-auth.test.ts index b5f66440e6..2dc993fca7 100644 --- a/packages/core/tests/unit/astro/plugin-api-route-auth.test.ts +++ b/packages/core/tests/unit/astro/plugin-api-route-auth.test.ts @@ -5,8 +5,8 @@ * (the HTTP method never selects a different handler), a route reached via GET * or HEAD runs the same code as one reached via POST. The route must not tier * permission or CSRF by method — every private invocation needs - * `plugins:manage` and the CSRF header, so an editor or a cross-origin HEAD - * can't invoke a mutating admin route by choosing the method. + * the route's declared permission (defaulting to `plugins:manage`) and the + * CSRF header, so a caller cannot change authorization by choosing the method. */ import type { RoleLevel } from "@emdash-cms/auth"; @@ -16,14 +16,14 @@ import { describe, expect, it, vi } from "vitest"; import { GET, POST } from "../../../src/astro/routes/api/plugins/[pluginId]/[...path].js"; -function createLocals(role: RoleLevel | null, routePublic: boolean) { +function createLocals(role: RoleLevel | null, routePublic: boolean, permission?: string) { const handlePluginApiRoute = vi.fn(async () => ({ success: true, data: { ok: true } })); return { locals: { user: role == null ? null : { id: "u1", role }, emdash: { handlePluginApiRoute, - getPluginRouteMeta: () => ({ public: routePublic }), + getPluginRouteMeta: () => ({ public: routePublic, permission }), }, }, handlePluginApiRoute, @@ -72,6 +72,13 @@ describe("plugin API catch-all auth (#1853)", () => { expect(handlePluginApiRoute).not.toHaveBeenCalled(); }); + it("honors a private route's explicitly declared permission", async () => { + const { locals, handlePluginApiRoute } = createLocals(Role.EDITOR, false, "content:create"); + const res = await invoke(POST, "POST", locals); + expect(res.status).toBe(200); + expect(handlePluginApiRoute).toHaveBeenCalledOnce(); + }); + it("rejects a private GET without the CSRF header even for an admin", async () => { const { locals, handlePluginApiRoute } = createLocals(Role.ADMIN, false); const res = await invoke(GET, "GET", locals, { csrf: false }); diff --git a/packages/core/tests/unit/auth/api-tokens.test.ts b/packages/core/tests/unit/auth/api-tokens.test.ts index 313e624410..1ba329e644 100644 --- a/packages/core/tests/unit/auth/api-tokens.test.ts +++ b/packages/core/tests/unit/auth/api-tokens.test.ts @@ -89,6 +89,22 @@ describe("hashApiToken", () => { }); describe("validateScopes", () => { + it("accepts broad and plugin-specific MCP tool scopes", () => { + expect(validateScopes(["mcp:tools", "mcp:tools:calendar-plugin"])).toEqual([]); + expect( + validateScopes([ + "mcp:tools:", + "mcp:tools:@acme/calendar", + "mcp:tools:Calendar", + "mcp:tools:-calendar", + ]), + ).toEqual([ + "mcp:tools:", + "mcp:tools:@acme/calendar", + "mcp:tools:Calendar", + "mcp:tools:-calendar", + ]); + }); it("returns empty array for valid scopes", () => { const invalid = validateScopes(["content:read", "media:write"]); expect(invalid).toEqual([]); @@ -110,6 +126,12 @@ describe("validateScopes", () => { }); describe("hasScope", () => { + it("keeps plugin MCP scopes independent from admin", () => { + expect(hasScope(["admin"], "mcp:tools:calendar")).toBe(false); + expect(hasScope(["mcp:tools"], "mcp:tools:calendar")).toBe(true); + expect(hasScope(["mcp:tools:calendar"], "mcp:tools:calendar")).toBe(true); + expect(hasScope(["mcp:tools:forms"], "mcp:tools:calendar")).toBe(false); + }); it("returns true when scope is present", () => { expect(hasScope(["content:read", "media:write"], "content:read")).toBe(true); }); diff --git a/packages/core/tests/unit/mcp/authorization.test.ts b/packages/core/tests/unit/mcp/authorization.test.ts index 179864abad..8f628bdaa0 100644 --- a/packages/core/tests/unit/mcp/authorization.test.ts +++ b/packages/core/tests/unit/mcp/authorization.test.ts @@ -13,9 +13,10 @@ import type { RoleLevel } from "@emdash-cms/auth"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { z } from "zod"; import type { EmDashHandlers } from "../../../src/astro/types.js"; -import { createMcpServer } from "../../../src/mcp/server.js"; +import { createMcpServer, type PluginMcpRegistration } from "../../../src/mcp/server.js"; // --------------------------------------------------------------------------- // Test constants @@ -225,9 +226,13 @@ async function setupMcpPair(opts: { userRole: RoleLevel; handlers?: EmDashHandlers; tokenScopes?: string[]; + pluginTools?: PluginMcpRegistration[]; }): Promise<{ client: Client; cleanup: () => Promise }> { const handlers = opts.handlers ?? createMockHandlers(); - const server = createMcpServer(); + const server = createMcpServer( + opts.pluginTools, + new Request("https://example.com/_emdash/api/mcp", { method: "POST" }), + ); const [clientTransport, serverTransport] = createAuthenticatedPair({ emdash: handlers, userId: opts.userId, @@ -261,6 +266,102 @@ describe("MCP Authorization", () => { if (cleanup) await cleanup(); }); + describe("plugin-declared tools", () => { + const pluginTool: PluginMcpRegistration = { + pluginId: "calendar", + name: "createEvent", + description: "Create a calendar event.", + route: "events/create", + permission: "content:create", + destructive: false, + inputSchema: z.object({ title: z.string() }), + outputSchema: z.object({ id: z.string() }), + }; + + it("does not let the legacy admin scope bypass plugin-tool scope", async () => { + const handlers = createMockHandlers(); + handlers.handlePluginMcpDenied = vi.fn().mockResolvedValue(undefined); + handlers.handlePluginMcpTool = vi.fn().mockResolvedValue({ + success: true, + data: { id: "event-1" }, + }); + ({ client, cleanup } = await setupMcpPair({ + userId: AUTHOR_USER_ID, + userRole: Role.ADMIN, + tokenScopes: ["admin"], + handlers, + pluginTools: [pluginTool], + })); + + const result = await client.callTool({ + name: "calendar__createEvent", + arguments: { title: "Launch" }, + }); + + expect(result.isError).toBe(true); + expect(result.content).toEqual( + expect.arrayContaining([ + expect.objectContaining({ text: expect.stringMatching(INSUFFICIENT_SCOPE_RE) }), + ]), + ); + expect(handlers.handlePluginMcpTool).not.toHaveBeenCalled(); + }); + + it("dispatches with a plugin-specific scope and returns structured output", async () => { + const handlers = createMockHandlers(); + handlers.handlePluginMcpTool = vi.fn().mockResolvedValue({ + success: true, + data: { id: "event-1" }, + }); + ({ client, cleanup } = await setupMcpPair({ + userId: AUTHOR_USER_ID, + userRole: Role.CONTRIBUTOR, + tokenScopes: ["mcp:tools:calendar"], + handlers, + pluginTools: [pluginTool], + })); + + const listed = await client.listTools(); + expect(listed.tools.map((tool) => tool.name)).toContain("calendar__createEvent"); + const result = await client.callTool({ + name: "calendar__createEvent", + arguments: { title: "Launch" }, + }); + + expect(result.isError).toBeFalsy(); + expect(result.structuredContent).toEqual({ id: "event-1" }); + expect(handlers.handlePluginMcpTool).toHaveBeenCalledWith( + "calendar", + "createEvent", + "events/create", + { title: "Launch" }, + AUTHOR_USER_ID, + expect.any(Request), + ); + }); + + it("still enforces the route permission", async () => { + const handlers = createMockHandlers(); + handlers.handlePluginMcpDenied = vi.fn().mockResolvedValue(undefined); + handlers.handlePluginMcpTool = vi.fn(); + ({ client, cleanup } = await setupMcpPair({ + userId: AUTHOR_USER_ID, + userRole: Role.SUBSCRIBER, + tokenScopes: ["mcp:tools"], + handlers, + pluginTools: [pluginTool], + })); + + const result = await client.callTool({ + name: "calendar__createEvent", + arguments: { title: "Launch" }, + }); + + expect(result.isError).toBe(true); + expect(handlers.handlePluginMcpTool).not.toHaveBeenCalled(); + }); + }); + // ----------------------------------------------------------------------- // Ownership checks: CONTRIBUTOR cannot modify others' content // ----------------------------------------------------------------------- diff --git a/packages/core/tests/unit/plugins/manifest-schema.test.ts b/packages/core/tests/unit/plugins/manifest-schema.test.ts index 5b9c6cda34..9802421b1e 100644 --- a/packages/core/tests/unit/plugins/manifest-schema.test.ts +++ b/packages/core/tests/unit/plugins/manifest-schema.test.ts @@ -105,6 +105,52 @@ describe("pluginManifestSchema — route entries", () => { }); }); +describe("pluginManifestSchema — MCP tools", () => { + it("keeps MCP declarations optional for backwards compatibility", () => { + expect(pluginManifestSchema.safeParse(makeManifest({})).success).toBe(true); + }); + + it("accepts a plugin-scoped MCP tool declaration", () => { + const result = pluginManifestSchema.safeParse({ + ...makeManifest({}), + mcp: { + tools: [ + { + name: "createEvent", + description: "Create a calendar event.", + route: "events/create", + permission: "content:create", + destructive: false, + inputSchema: { type: "object", properties: { title: { type: "string" } } }, + outputSchema: { type: "object", properties: { id: { type: "string" } } }, + }, + ], + }, + }); + + expect(result.success).toBe(true); + }); + + it("rejects unsafe local tool names", () => { + const result = pluginManifestSchema.safeParse({ + ...makeManifest({}), + mcp: { + tools: [ + { + name: "calendar.create", + description: "Create a calendar event.", + route: "events/create", + permission: "content:create", + inputSchema: { type: "object" }, + }, + ], + }, + }); + + expect(result.success).toBe(false); + }); +}); + describe("normalizeManifestRoute", () => { it("should convert a plain string to { name } object", () => { expect(normalizeManifestRoute("webhook")).toEqual({ name: "webhook" }); diff --git a/packages/plugin-cli/src/build/api.ts b/packages/plugin-cli/src/build/api.ts index 480cbce6b3..dcc3a290d9 100644 --- a/packages/plugin-cli/src/build/api.ts +++ b/packages/plugin-cli/src/build/api.ts @@ -193,6 +193,7 @@ export async function buildPlugin(options: BuildOptions): Promise { ({ descriptor, descriptorTypes } = await writeDescriptor({ outDir, manifest: sources.manifest, + wireManifest, packageName: sources.packageName, })); log.success?.("Wrote index.mjs"); @@ -242,6 +243,7 @@ async function runPipelineStep(fn: () => Promise): Promise { interface WriteDescriptorContext { outDir: string; manifest: NormalisedManifest; + wireManifest: PluginManifest; packageName: string; } @@ -264,7 +266,7 @@ interface DescriptorFiles { * `./dist/plugin.mjs` — the runtime bytes the integration loads. */ async function writeDescriptor(ctx: WriteDescriptorContext): Promise { - const { outDir, manifest, packageName } = ctx; + const { outDir, manifest, wireManifest, packageName } = ctx; const descriptorObject = { id: manifest.slug, @@ -274,6 +276,7 @@ async function writeDescriptor(ctx: WriteDescriptorContext): Promise 0 ? { adminPages: manifest.admin.pages } : {}), ...(manifest.admin.widgets.length > 0 ? { adminWidgets: manifest.admin.widgets } : {}), }; diff --git a/packages/plugin-cli/src/build/pipeline.ts b/packages/plugin-cli/src/build/pipeline.ts index 4d51f9b4fd..c8c38f502b 100644 --- a/packages/plugin-cli/src/build/pipeline.ts +++ b/packages/plugin-cli/src/build/pipeline.ts @@ -266,6 +266,7 @@ export async function probeAndAssemble(ctx: ProbeAndAssembleContext): Promise { diff --git a/packages/plugin-cli/src/build/probe-schema.ts b/packages/plugin-cli/src/build/probe-schema.ts index a0c5f18e0e..d9239dd75a 100644 --- a/packages/plugin-cli/src/build/probe-schema.ts +++ b/packages/plugin-cli/src/build/probe-schema.ts @@ -89,6 +89,8 @@ export const HookEntrySchema = z.preprocess(normaliseEntry, HookEntryConfigSchem const RouteEntryConfigSchema = z.looseObject({ handler: FunctionSchema, public: z.boolean().optional(), + input: z.unknown().optional(), + permission: z.string().optional(), }); export const RouteEntrySchema = z.preprocess(normaliseEntry, RouteEntryConfigSchema); @@ -122,6 +124,20 @@ function coerceOptionalRecord(value: unknown): unknown { export const ProbedDefaultSchema = z.looseObject({ hooks: z.preprocess(coerceOptionalRecord, z.record(z.string(), HookEntrySchema)).optional(), routes: z.preprocess(coerceOptionalRecord, z.record(z.string(), RouteEntrySchema)).optional(), + mcp: z + .object({ + tools: z.record( + z.string(), + z.object({ + description: z.string().min(1), + route: z.string().min(1), + input: z.unknown(), + output: z.unknown().optional(), + destructive: z.boolean().optional(), + }), + ), + }) + .optional(), }); export type ProbedDefault = z.infer; diff --git a/packages/plugin-cli/src/bundle/types.ts b/packages/plugin-cli/src/bundle/types.ts index dc1bbc5f1c..025e4241f6 100644 --- a/packages/plugin-cli/src/bundle/types.ts +++ b/packages/plugin-cli/src/bundle/types.ts @@ -22,7 +22,9 @@ export { type DeclaredAccess, type DeprecatedPluginCapability, type ManifestHookEntry, + type ManifestMcpTool, type ManifestRouteEntry, + type PluginMcpManifestConfig, type PluginAdminConfig, type PluginCapability, type PluginManifest, @@ -36,6 +38,14 @@ import type { PluginStorageConfig, } from "@emdash-cms/plugin-types"; +export interface ResolvedMcpTool { + description: string; + route: string; + input: unknown; + output?: unknown; + destructive?: boolean; +} + /** * The bundler's view of a "resolved" plugin -- whatever the user's plugin * module exposes after we build + import it. Loose by design: the third-party @@ -66,7 +76,9 @@ export interface ResolvedPlugin { { handler?: unknown; public?: boolean; + permission?: string; } >; + mcp?: { tools: Record }; admin: PluginAdminConfig; } diff --git a/packages/plugin-cli/src/bundle/utils.ts b/packages/plugin-cli/src/bundle/utils.ts index 18d9781ab1..e049db607d 100644 --- a/packages/plugin-cli/src/bundle/utils.ts +++ b/packages/plugin-cli/src/bundle/utils.ts @@ -14,9 +14,16 @@ import { pipeline } from "node:stream/promises"; import { imageSize } from "image-size"; import { packTar } from "modern-tar/fs"; +import { z } from "zod"; import { capabilitiesToDeclaredAccess } from "./types.js"; -import type { ManifestHookEntry, PluginManifest, ResolvedPlugin } from "./types.js"; +import type { + ManifestHookEntry, + ManifestMcpTool, + ManifestRouteEntry, + PluginManifest, + ResolvedPlugin, +} from "./types.js"; // ── Constants ──────────────────────────────────────────────────────────────── @@ -30,6 +37,7 @@ export const MAX_SCREENSHOTS = 8; export const MAX_SCREENSHOT_WIDTH = 1920; export const MAX_SCREENSHOT_HEIGHT = 1080; export const ICON_SIZE = 256; +const MCP_TOOL_NAME_PATTERN = /^[a-zA-Z0-9_-]+$/; // ── Regex patterns (module-scope to avoid re-compilation) ──────────────────── @@ -145,6 +153,35 @@ export function extractManifest(plugin: ResolvedPlugin): PluginManifest { } } + const routes: Array = Object.entries(plugin.routes).map( + ([name, route]) => + route.public !== undefined || route.permission !== undefined + ? { name, public: route.public, permission: route.permission } + : name, + ); + const tools: ManifestMcpTool[] = Object.entries(plugin.mcp?.tools ?? {}).map(([name, tool]) => { + if (!MCP_TOOL_NAME_PATTERN.test(name)) throw new Error(`Invalid MCP tool name "${name}"`); + const route = plugin.routes[tool.route]; + if (!route) { + throw new Error(`MCP tool "${name}" references unknown route "${tool.route}"`); + } + if (route.public) { + throw new Error(`MCP tool "${name}" cannot reference public route "${tool.route}"`); + } + if (!route.permission) { + throw new Error(`MCP route "${tool.route}" must declare a permission`); + } + return { + name, + description: tool.description, + route: tool.route, + permission: route.permission, + destructive: tool.destructive ?? false, + inputSchema: toPluginJsonSchema(tool.input), + ...(tool.output ? { outputSchema: toPluginJsonSchema(tool.output) } : {}), + }; + }); + return { id: plugin.id, version: plugin.version, @@ -153,7 +190,8 @@ export function extractManifest(plugin: ResolvedPlugin): PluginManifest { allowedHosts: plugin.allowedHosts, storage: plugin.storage, hooks, - routes: Object.keys(plugin.routes), + routes, + ...(tools.length > 0 ? { mcp: { tools } } : {}), admin: { // Omit `entry` (it's a module specifier for the host, not relevant in bundles) settingsSchema: plugin.admin.settingsSchema, @@ -163,6 +201,16 @@ export function extractManifest(plugin: ResolvedPlugin): PluginManifest { }; } +function toPluginJsonSchema(schema: unknown): Record { + if (schema && typeof schema === "object" && "_zod" in schema) { + return z.toJSONSchema(schema as z.ZodType, { target: "draft-7" }); + } + if (schema && typeof schema === "object" && !Array.isArray(schema)) { + return schema as Record; + } + throw new Error("MCP tool schemas must be Zod schemas or JSON Schema objects"); +} + // ── Node.js built-in detection ─────────────────────────────────────────────── /** diff --git a/packages/plugin-cli/tests/bundle-utils.test.ts b/packages/plugin-cli/tests/bundle-utils.test.ts index 941d4de01e..a3d1aeff42 100644 --- a/packages/plugin-cli/tests/bundle-utils.test.ts +++ b/packages/plugin-cli/tests/bundle-utils.test.ts @@ -25,6 +25,7 @@ const minimalResolved = (overrides: Partial = {}): ResolvedPlugi storage: {}, hooks: {}, routes: {}, + mcp: { tools: {} }, admin: {}, ...overrides, }); @@ -72,6 +73,40 @@ describe("extractManifest", () => { expect(manifest.routes.toSorted((a, b) => a.localeCompare(b))).toEqual(["admin", "api"]); }); + it("serializes explicitly declared MCP tools", () => { + const manifest = extractManifest( + minimalResolved({ + routes: { + "events/create": { + handler: () => {}, + permission: "content:create", + }, + }, + mcp: { + tools: { + createEvent: { + description: "Create a calendar event.", + route: "events/create", + input: { type: "object", properties: { title: { type: "string" } } }, + destructive: false, + }, + }, + }, + }), + ); + + expect(manifest.mcp?.tools).toEqual([ + { + name: "createEvent", + description: "Create a calendar event.", + route: "events/create", + permission: "content:create", + destructive: false, + inputSchema: { type: "object", properties: { title: { type: "string" } } }, + }, + ]); + }); + it("strips the runtime entry pointer from admin", () => { const manifest = extractManifest( minimalResolved({ diff --git a/packages/plugin-types/src/index.ts b/packages/plugin-types/src/index.ts index 6d934382a6..254e54e146 100644 --- a/packages/plugin-types/src/index.ts +++ b/packages/plugin-types/src/index.ts @@ -312,6 +312,26 @@ export interface ManifestHookEntry { export interface ManifestRouteEntry { name: string; public?: boolean; + /** RBAC permission required to invoke this route. */ + permission?: string; +} + +/** JSON Schema persisted in plugin manifests for cross-isolate discovery. */ +export type PluginJsonSchema = Record; + +/** An explicitly agent-callable plugin route. Tool names are local to the plugin. */ +export interface ManifestMcpTool { + name: string; + description: string; + route: string; + permission: string; + destructive: boolean; + inputSchema: PluginJsonSchema; + outputSchema?: PluginJsonSchema; +} + +export interface PluginMcpManifestConfig { + tools: ManifestMcpTool[]; } /** @@ -403,6 +423,8 @@ export interface PluginManifest { hooks: Array; /** Route declarations -- plain name strings or structured objects. */ routes: Array; + /** Explicit, opt-in MCP surface. Absent manifests expose no plugin tools. */ + mcp?: PluginMcpManifestConfig; admin: PluginAdminConfig; } diff --git a/packages/plugins/mcp-smoke/package.json b/packages/plugins/mcp-smoke/package.json new file mode 100644 index 0000000000..e982c7ed41 --- /dev/null +++ b/packages/plugins/mcp-smoke/package.json @@ -0,0 +1,20 @@ +{ + "name": "@emdash-cms/plugin-mcp-smoke", + "private": true, + "version": "0.0.1", + "description": "Test fixture for end-to-end plugin MCP verification", + "type": "module", + "exports": { + ".": "./src/index.ts", + "./sandbox": "./src/sandbox-entry.ts" + }, + "scripts": { + "typecheck": "tsgo --noEmit" + }, + "peerDependencies": { + "emdash": "workspace:*" + }, + "dependencies": { + "zod": "catalog:" + } +} diff --git a/packages/plugins/mcp-smoke/src/index.ts b/packages/plugins/mcp-smoke/src/index.ts new file mode 100644 index 0000000000..bd5c180c22 --- /dev/null +++ b/packages/plugins/mcp-smoke/src/index.ts @@ -0,0 +1,11 @@ +import type { PluginDescriptor } from "emdash"; + +export function mcpSmokePlugin(): PluginDescriptor { + return { + id: "mcp-smoke", + version: "0.0.1", + format: "standard", + entrypoint: "@emdash-cms/plugin-mcp-smoke/sandbox", + options: {}, + }; +} diff --git a/packages/plugins/mcp-smoke/src/sandbox-entry.ts b/packages/plugins/mcp-smoke/src/sandbox-entry.ts new file mode 100644 index 0000000000..b291be5046 --- /dev/null +++ b/packages/plugins/mcp-smoke/src/sandbox-entry.ts @@ -0,0 +1,35 @@ +import type { SandboxedPlugin } from "emdash/plugin"; +import { z } from "zod"; + +const echoInput = z.object({ + message: z.string().min(1).max(200), +}); + +const echoOutput = z.object({ + message: z.string(), + length: z.number().int().nonnegative(), +}); + +export default { + routes: { + echo: { + permission: "content:read", + input: echoInput, + handler: async (routeCtx) => { + const { message } = echoInput.parse(routeCtx.input); + return { message, length: message.length }; + }, + }, + }, + mcp: { + tools: { + echo: { + description: "Echo a short message to verify plugin MCP connectivity.", + route: "echo", + input: echoInput, + output: echoOutput, + destructive: false, + }, + }, + }, +} satisfies SandboxedPlugin; diff --git a/packages/plugins/mcp-smoke/tsconfig.json b/packages/plugins/mcp-smoke/tsconfig.json new file mode 100644 index 0000000000..7cc4bd5db5 --- /dev/null +++ b/packages/plugins/mcp-smoke/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../tsconfig.base.json", + "compilerOptions": { + "rootDir": "./src" + }, + "include": ["src/**/*"], + "exclude": ["node_modules"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 03cb66159c..b7fa3ef33e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -647,6 +647,9 @@ importers: '@emdash-cms/plugin-color': specifier: workspace:* version: link:../../packages/plugins/color + '@emdash-cms/plugin-mcp-smoke': + specifier: workspace:* + version: link:../../packages/plugins/mcp-smoke astro: specifier: 'catalog:' version: 7.0.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@25.9.1)(jiti@2.7.0)(rollup@4.55.2)(yaml@2.9.0) @@ -2132,6 +2135,15 @@ importers: specifier: 'catalog:' version: 6.0.3 + packages/plugins/mcp-smoke: + dependencies: + emdash: + specifier: workspace:* + version: link:../../core + zod: + specifier: 'catalog:' + version: 4.4.1 + packages/plugins/sandboxed-test: dependencies: emdash: