Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
7 changes: 7 additions & 0 deletions .changeset/plugin-mcp-tools.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"emdash": patch
"@emdash-cms/plugin-types": patch
"@emdash-cms/registry-cli": patch
---

Adds plugin-defined MCP tool metadata to plugin manifests and exposes enabled plugin tools through the EmDash MCP endpoint.
1 change: 1 addition & 0 deletions docs/astro.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ export default defineConfig({
{ label: "API Routes", slug: "plugins/creating-plugins/api-routes" },
{ label: "Storage", slug: "plugins/creating-plugins/storage" },
{ label: "Settings", slug: "plugins/creating-plugins/settings" },
{ label: "MCP Tools", slug: "plugins/creating-plugins/mcp-tools" },
{ label: "Block Kit", slug: "plugins/creating-plugins/block-kit" },
{
label: "Capabilities & Security",
Expand Down
116 changes: 116 additions & 0 deletions docs/src/content/docs/plugins/creating-plugins/mcp-tools.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
---
title: MCP Tools
description: Expose sandboxed plugin routes as tools on the site's MCP server.
---

import { Aside } from "@astrojs/starlight/components";

Plugin MCP tools let an AI assistant call one of your plugin routes through the site's built-in MCP server. Use them for admin-only actions that are useful from an assistant, such as summarizing plugin data, running an import preview, or querying plugin-owned storage.

## Requirements

Plugin MCP tools are opt-in:

- Add the `mcp:tools` capability.
- Declare the route in `routes`.
- Add an `mcpTools` entry that points at that route.
- Use a lowercase snake_case tool name without double underscores.

MCP tool calls always require the `admin` token scope and an Admin user role. This is true even if the underlying plugin route is public.

## Define a Tool

```ts
import { definePlugin } from "emdash";

export default definePlugin({
routes: {
summarize: {
handler: async ({ input }) => {
const { text } = input as { text: string };
return { summary: text.slice(0, 120) };
},
},
},
mcpTools: {
summarize: {
title: "Summarize Text",
description: "Summarize text using this plugin.",
route: "summarize",
inputSchema: {
type: "object",
properties: {
text: {
type: "string",
description: "Text to summarize.",
minLength: 1,
},
},
required: ["text"],
additionalProperties: false,
},
},
},
});
```

Add the capability in the plugin descriptor or manifest:

```ts
export default {
id: "my-plugin",
version: "1.0.0",
capabilities: ["mcp:tools"],
mcpTools: [
{
name: "summarize",
title: "Summarize Text",
description: "Summarize text using this plugin.",
route: "summarize",
inputSchema: {
type: "object",
properties: {
text: { type: "string", description: "Text to summarize." },
},
required: ["text"],
additionalProperties: false,
},
},
],
};
```

## Input Schemas

Sandboxed plugin manifests cannot serialize Zod schemas, so MCP tools use a constrained JSON Schema object for input validation and client-facing descriptions.

Supported fields:

| Field | Applies to |
| --- | --- |
| `type` | `object`, `string`, `number`, `integer`, `boolean`, `array` |
| `description`, `default` | all schemas |
| `properties`, `required`, `additionalProperties` | object |
| `items`, `minItems`, `maxItems` | array |
| `enum` | string, number, integer, boolean |
| `format` | string: `date-time`, `email`, `uri`, `uuid` |
| `minLength`, `maxLength`, `pattern` | string |
| `minimum`, `maximum` | number, integer |

The root `inputSchema` must be an object. `$ref`, `$defs`, recursive schemas, `oneOf`, `anyOf`, and `allOf` are not supported.

<Aside>
Your route handler should still validate or narrow `input` before using it. The MCP input schema gives clients useful parameter information and rejects malformed MCP calls before dispatch.
</Aside>

## Tool Names

The host generates the MCP tool name from the plugin id and the local tool name:

```txt
@emdash-cms/plugin-forms + submit_form -> emdash_cms__plugin_forms__submit_form
```

Plugin id segments are joined with double underscores. Local tool names cannot contain double underscores so generated names stay unambiguous.

If a plugin id contains consecutive dashes, the host appends a short stable hash to keep the generated name collision-free.
4 changes: 4 additions & 0 deletions docs/src/content/docs/reference/mcp-server.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,10 @@ Responses follow the [JSON-RPC 2.0](https://www.jsonrpc.org/specification) forma

The server exposes 43 tools across eight domains: content, schema, media, search, taxonomies, menus, revisions, and settings. Each tool returns results as JSON text content, or an error message with `isError: true` on failure.

Enabled plugins can also add MCP tools. Plugin tool names are generated from the plugin id and local tool name, using double underscores between plugin id segments and the tool name. For example, `@emdash-cms/plugin-forms` with `submit_form` becomes `emdash_cms__plugin_forms__submit_form`.

Plugin MCP tools always require the `admin` token scope and an Admin user role, even when the underlying plugin route is public. Plugin authors can provide JSON input schemas so MCP clients can validate and describe tool arguments. See [Plugin MCP Tools](/plugins/creating-plugins/mcp-tools/) for authoring details.

### Content Tools

#### `content_list`
Expand Down
10 changes: 10 additions & 0 deletions packages/core/src/astro/integration/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,14 @@ export interface PluginDashboardWidget {
title?: string;
}

export interface PluginMcpToolDescriptor {
name: string;
title?: string;
description: string;
route: string;
inputSchema?: import("../../plugins/types.js").ManifestJsonObjectSchema;
}

/**
* Plugin descriptor - returned by plugin factory functions
*
Expand Down Expand Up @@ -95,6 +103,8 @@ export interface PluginDescriptor<TOptions = Record<string, unknown>> {
adminPages?: PluginAdminPage[];
/** Dashboard widgets */
adminWidgets?: PluginDashboardWidget[];
/** MCP tools exposed through EmDash's MCP endpoint */
mcpTools?: PluginMcpToolDescriptor[];

// === Sandbox-specific fields (for sandboxed plugins) ===

Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/astro/integration/virtual-modules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,7 @@ export function generatePluginsModule(descriptors: PluginDescriptor[]): string {
storage: descriptor.storage,
adminPages: descriptor.adminPages,
adminWidgets: descriptor.adminWidgets,
mcpTools: descriptor.mcpTools,
})})`,
);
} else {
Expand Down Expand Up @@ -533,6 +534,7 @@ export const sandboxedPlugins = [];
storage: ${JSON.stringify(descriptor.storage ?? {})},
adminPages: ${JSON.stringify(descriptor.adminPages ?? [])},
adminWidgets: ${JSON.stringify(descriptor.adminWidgets ?? [])},
mcpTools: ${JSON.stringify(descriptor.mcpTools ?? [])},
adminEntry: ${JSON.stringify(descriptor.adminEntry)},
// Code read from: ${filePath}
code: ${JSON.stringify(code)},
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/astro/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -487,6 +487,7 @@ export const onRequest = defineMiddleware(async (context, next) => {
// Plugin routes
handlePluginApiRoute: runtime.handlePluginApiRoute.bind(runtime),
getPluginRouteMeta: runtime.getPluginRouteMeta.bind(runtime),
getPluginMcpTools: runtime.getPluginMcpTools.bind(runtime),

// Media provider methods
getMediaProvider: runtime.getMediaProvider.bind(runtime),
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/astro/routes/api/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ export const POST: APIRoute = async ({ request, locals }) => {
return apiError("UNAUTHORIZED", "Authentication required", 401);
}

const server = createMcpServer();
const server = createMcpServer(emdash);

try {
const transport = new WebStandardStreamableHTTPServerTransport({
Expand Down
3 changes: 3 additions & 0 deletions packages/core/src/astro/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -353,6 +353,9 @@ export interface EmDashHandlers {
// Plugin route metadata (for auth decisions before dispatch)
getPluginRouteMeta: (pluginId: string, path: string) => { public: boolean } | null;

// Plugin-defined MCP tools
getPluginMcpTools: () => import("../plugins/types.js").PluginMcpToolRegistration[];

// Media provider handlers
getMediaProvider: (providerId: string) => import("../media/types.js").MediaProvider | undefined;
getMediaProviderList: () => Array<{
Expand Down
7 changes: 7 additions & 0 deletions packages/core/src/cli/commands/bundle-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,13 @@ export function extractManifest(plugin: ResolvedPlugin): PluginManifest {
storage: plugin.storage,
hooks,
routes: Object.keys(plugin.routes),
mcpTools: Object.entries(plugin.mcpTools ?? {}).map(([name, tool]) => ({
name,
title: tool.title,
description: tool.description,
route: tool.route,
inputSchema: tool.inputSchema,
})),
admin: {
// Omit entry (it's a module specifier for the host, not relevant in bundles)
settingsSchema: plugin.admin.settingsSchema,
Expand Down
74 changes: 74 additions & 0 deletions packages/core/src/emdash-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import type {
MediaItem,
PluginManifest,
PluginCapability,
ManifestMcpToolEntry,
PluginStorageConfig,
PublicPageContext,
PageMetadataContribution,
Expand Down Expand Up @@ -205,6 +206,8 @@ export interface SandboxedPluginEntry {
adminPages?: Array<{ path: string; label?: string; icon?: string }>;
/** Dashboard widgets */
adminWidgets?: Array<{ id: string; title?: string; size?: string }>;
/** MCP tool declarations */
mcpTools?: ManifestMcpToolEntry[];
/** Admin entry module */
adminEntry?: string;
/**
Expand Down Expand Up @@ -310,7 +313,9 @@ const marketplaceManifestCache = new Map<
{
id: string;
version: string;
capabilities?: PluginCapability[];
admin?: { pages?: PluginAdminPage[]; widgets?: PluginDashboardWidget[] };
mcpTools?: ManifestMcpToolEntry[];
}
>();
/** Route metadata for sandboxed plugins: pluginId -> routeName -> RouteMeta */
Expand Down Expand Up @@ -584,7 +589,9 @@ export class EmDashRuntime {
marketplaceManifestCache.set(pluginId, {
id: bundle.manifest.id,
version: bundle.manifest.version,
capabilities: bundle.manifest.capabilities,
admin: bundle.manifest.admin,
mcpTools: bundle.manifest.mcpTools ?? [],
});

// Cache route metadata from manifest for auth decisions
Expand Down Expand Up @@ -1097,6 +1104,7 @@ export class EmDashRuntime {
storage: entry.storage ?? {},
hooks: [],
routes: [],
mcpTools: entry.mcpTools ?? [],
admin: {},
};

Expand Down Expand Up @@ -1163,7 +1171,9 @@ export class EmDashRuntime {
marketplaceManifestCache.set(plugin.pluginId, {
id: bundle.manifest.id,
version: bundle.manifest.version,
capabilities: bundle.manifest.capabilities,
admin: bundle.manifest.admin,
mcpTools: bundle.manifest.mcpTools ?? [],
});

// Cache route metadata from manifest for auth decisions
Expand Down Expand Up @@ -2149,6 +2159,70 @@ export class EmDashRuntime {
// Plugin Routes
// =========================================================================

getPluginMcpTools(): import("./plugins/types.js").PluginMcpToolRegistration[] {
const tools: import("./plugins/types.js").PluginMcpToolRegistration[] = [];
const seen = new Set<string>();
const addTool = (tool: import("./plugins/types.js").PluginMcpToolRegistration) => {
const key = `${tool.pluginId}\0${tool.name}`;
Comment thread
masonjames marked this conversation as resolved.
if (seen.has(key)) return;
seen.add(key);
tools.push(tool);
};

for (const plugin of this.configuredPlugins) {
if (!this.isPluginEnabled(plugin.id)) continue;
if (!plugin.capabilities.includes("mcp:tools")) continue;

for (const [name, tool] of Object.entries(plugin.mcpTools ?? {})) {
addTool({
pluginId: plugin.id,
name,
title: tool.title,
description: tool.description,
route: tool.route,
input: tool.input,
inputSchema: tool.inputSchema,
});
}
}

for (const entry of this.sandboxedPluginEntries) {
if (!this.isPluginEnabled(entry.id)) continue;
if (!entry.capabilities.includes("mcp:tools")) continue;
if (!this.findSandboxedPlugin(entry.id)) continue;

for (const tool of entry.mcpTools ?? []) {
addTool({
pluginId: entry.id,
name: tool.name,
title: tool.title,
description: tool.description,
route: tool.route,
inputSchema: tool.inputSchema,
});
}
}

for (const [pluginId, meta] of marketplaceManifestCache) {
if (!this.isPluginEnabled(pluginId)) continue;
if (!meta.capabilities?.includes("mcp:tools")) continue;
if (!this.findSandboxedPlugin(pluginId)) continue;

for (const tool of meta.mcpTools ?? []) {
addTool({
pluginId,
name: tool.name,
title: tool.title,
description: tool.description,
route: tool.route,
inputSchema: tool.inputSchema,
});
}
}
Comment thread
masonjames marked this conversation as resolved.

return tools;
}

/**
* Get route metadata for a plugin route without invoking the handler.
* Used by the catch-all route to decide auth before dispatch.
Expand Down
3 changes: 3 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,8 @@ export type {
HookResult,
PluginRoute,
RouteContext,
PluginMcpTool,
PluginMcpToolRegistration,
PluginAdminConfig,
PluginAdminPage,
PluginAdminExports,
Expand Down Expand Up @@ -259,6 +261,7 @@ export type {
SandboxEmailSendCallback,
PluginManifest,
ValidatedPluginManifest,
ManifestMcpToolEntry,
SerializedRequest,
} from "./plugins/index.js";

Expand Down
Loading
Loading