Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/plugin-mcp-tools.md
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 2 additions & 1 deletion demos/simple/astro.config.mjs
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -22,7 +23,7 @@ export default defineConfig({
directory: "./uploads",
baseUrl: "/_emdash/api/media/file",
}),
plugins: [auditLog],
plugins: [auditLog, mcpSmokePlugin()],
}),
],
fonts: [
Expand Down
1 change: 1 addition & 0 deletions demos/simple/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:*",
Expand Down
56 changes: 52 additions & 4 deletions docs/src/content/docs/plugins/creating-plugins/api-routes.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -70,11 +70,21 @@ Routes mount at `/_emdash/api/plugins/<slug>/<route-name>`. 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`:

Expand All @@ -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.
</Aside>

## 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 `<pluginId>__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:<pluginId>`.

## 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.
Expand Down
6 changes: 5 additions & 1 deletion docs/src/content/docs/reference/mcp-server.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -41,16 +41,20 @@ 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:<pluginId>` | 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.

### Role Requirements

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 `<pluginId>__<localName>` 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 |
Expand Down
29 changes: 29 additions & 0 deletions packages/admin/src/components/CapabilityConsentDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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 */
Expand All @@ -47,6 +50,7 @@ export function CapabilityConsentDialog({
allowedHosts,
newCapabilities = [],
newlyPublicRoutes = [],
mcpTools = [],
auditVerdict,
isPending = false,
error,
Expand Down Expand Up @@ -130,6 +134,31 @@ export function CapabilityConsentDialog({
</div>
)}

{mcpTools.length > 0 && (
<div className="rounded-md border border-kumo-warning/30 bg-kumo-warning/10 p-3 text-sm">
<div className="font-medium text-kumo-warning">{t`Agent-callable MCP tools`}</div>
<p className="mt-1 text-xs text-kumo-subtle">
{t`These tools remain disabled after installation until you explicitly enable agent access.`}
</p>
<ul className="mt-2 space-y-2">
{mcpTools.map((tool) => (
<li key={tool.name} className="rounded bg-kumo-base p-2 text-xs">
<div className="font-mono">{tool.name}</div>
<p className="mt-1 text-kumo-subtle">{tool.description}</p>
<p className="mt-1 text-kumo-subtle">
{t`Route: ${tool.route} · Permission: ${tool.permission}`}
</p>
{tool.destructive && (
<span className="mt-1 inline-block font-medium text-kumo-danger">
{t`Destructive`}
</span>
)}
</li>
))}
</ul>
</div>
)}

{/* Audit verdict banner */}
{auditVerdict && auditVerdict !== "pass" && (
<div
Expand Down
13 changes: 13 additions & 0 deletions packages/admin/src/components/MarketplacePluginDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,10 @@ import * as React from "react";
import {
fetchMarketplacePlugin,
installMarketplacePlugin,
PluginMcpConsentRequiredError,
uninstallMarketplacePlugin,
describeCapability,
type PluginMcpConsentTool,
} from "../lib/api/marketplace.js";
import { renderMarkdown } from "../lib/markdown.js";
import { isSafeUrl, safeIconUrl } from "../lib/url.js";
Expand All @@ -44,6 +46,7 @@ export function MarketplacePluginDetail({
const { t } = useLingui();
const queryClient = useQueryClient();
const [showConsent, setShowConsent] = React.useState(false);
const [mcpConsentTools, setMcpConsentTools] = React.useState<PluginMcpConsentTool[]>([]);
const [showUninstallConfirm, setShowUninstallConfirm] = React.useState(false);
const [lightboxIndex, setLightboxIndex] = React.useState<number | null>(null);

Expand All @@ -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({
Expand Down Expand Up @@ -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();
}}
/>
Expand Down
Loading
Loading