Skip to content
Open
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.
9 changes: 9 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,13 @@ export interface PluginDashboardWidget {
title?: string;
}

export interface PluginMcpToolDescriptor {
name: string;
title?: string;
description: string;
route: string;
}

/**
* Plugin descriptor - returned by plugin factory functions
*
Expand Down Expand Up @@ -95,6 +102,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
6 changes: 6 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,12 @@ 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,
})),
admin: {
// Omit entry (it's a module specifier for the host, not relevant in bundles)
settingsSchema: plugin.admin.settingsSchema,
Expand Down
64 changes: 64 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,60 @@ export class EmDashRuntime {
// Plugin Routes
// =========================================================================

getPluginMcpTools(): import("./plugins/types.js").PluginMcpToolRegistration[] {
const tools: import("./plugins/types.js").PluginMcpToolRegistration[] = [];

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 ?? {})) {
tools.push({
pluginId: plugin.id,
name,
title: tool.title,
description: tool.description,
route: tool.route,
input: tool.input,
});
}
}

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 ?? []) {
tools.push({
pluginId: entry.id,
name: tool.name,
title: tool.title,
description: tool.description,
route: tool.route,
});
}
}

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 ?? []) {
tools.push({
pluginId,
name: tool.name,
title: tool.title,
description: tool.description,
route: tool.route,
});
}
}
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
88 changes: 87 additions & 1 deletion packages/core/src/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ import type { EmDashHandlers } from "../astro/types.js";
import { hasScope } from "../auth/api-tokens.js";

const COLLECTION_SLUG_PATTERN = /^[a-z][a-z0-9_]*$/;
const MCP_TOOL_NAME_PATTERN = /^[a-z][a-z0-9_]*$/;
const LEADING_SLASH_PATTERN = /^\/+/;
const FORWARD_SLASH_PATTERN = "/";
/** http(s) scheme matcher used by `settings_update` URL validation. */
const HTTP_SCHEME_PATTERN = /^https?:\/\//i;

Expand Down Expand Up @@ -232,6 +235,34 @@ function jsonResult(data: unknown): SuccessEnvelope {
return respondData(data);
}

function pluginToolName(pluginId: string, toolName: string): string {
const encodedPluginId = Array.from(new TextEncoder().encode(pluginId), (byte) =>
byte.toString(16).padStart(2, "0"),
).join("");
return `plugin_${encodedPluginId}_${toolName}`;
}
Comment thread
masonjames marked this conversation as resolved.
Outdated

function normalizePluginToolRoute(route: string): string {
return route.replace(LEADING_SLASH_PATTERN, "");
}

function createPluginToolRequest(
pluginId: string,
route: string,
args: Record<string, unknown>,
): Request {
const routePath = normalizePluginToolRoute(route)
.split(FORWARD_SLASH_PATTERN)
.map((segment) => encodeURIComponent(segment))
.join("/");
const encodedPluginId = encodeURIComponent(pluginId);
return new Request(`http://localhost/_emdash/api/plugins/${encodedPluginId}/${routePath}`, {
method: "POST",
headers: { "Content-Type": "application/json", "X-EmDash-Request": "1" },
body: JSON.stringify(args),
});
}

// ---------------------------------------------------------------------------
// Context extraction
//
Expand Down Expand Up @@ -385,7 +416,7 @@ function extractContentId(data: unknown): string | undefined {
// Server factory
// ---------------------------------------------------------------------------

export function createMcpServer(): McpServer {
export function createMcpServer(emdashForToolList?: EmDashHandlers): McpServer {
const server = new McpServer(
{ name: "emdash", version: "0.1.0" },
{ capabilities: { logging: {} } },
Expand Down Expand Up @@ -419,6 +450,61 @@ export function createMcpServer(): McpServer {
)(name, config, wrapped);
}) as typeof server.registerTool;

const registeredToolNames = new Set<string>();
for (const tool of emdashForToolList?.getPluginMcpTools() ?? []) {
if (!MCP_TOOL_NAME_PATTERN.test(tool.name)) continue;

const name = pluginToolName(tool.pluginId, tool.name);
if (registeredToolNames.has(name)) continue;
registeredToolNames.add(name);

server.registerTool(
name,
{
title: tool.title,
description: tool.description,
inputSchema: tool.input ?? z.record(z.string(), z.unknown()),
Comment thread
masonjames marked this conversation as resolved.
Outdated
},
async (args: Record<string, unknown>, extra) => {
requireScope(extra, "admin");
requireRole(extra, Role.ADMIN);

const emdash = getEmDash(extra);
const routePath = normalizePluginToolRoute(tool.route);
const routeMeta = emdash.getPluginRouteMeta(tool.pluginId, `/${routePath}`);
if (!routeMeta) {
return respondError("NOT_FOUND", `Plugin MCP tool route not found: ${tool.route}`);
}

const request = createPluginToolRequest(tool.pluginId, routePath, args);
const result = await emdash.handlePluginApiRoute(
tool.pluginId,
"POST",
`/${routePath}`,
request,
);
if (!result.success) {
const err =
result.error && typeof result.error === "object"
? (result.error as { code?: unknown; message?: unknown; details?: unknown })
: undefined;
const code = typeof err?.code === "string" && err.code ? err.code : "PLUGIN_ERROR";
const message =
typeof err?.message === "string" && err.message
? err.message
: "Plugin MCP tool failed";
const details =
err?.details && typeof err.details === "object"
? (err.details as Record<string, unknown>)
: undefined;
return respondError(code, message, details);
}

return respondData(result.data ?? null);
},
);
}
Comment thread
masonjames marked this conversation as resolved.

// =====================================================================
// Content tools
// =====================================================================
Expand Down
22 changes: 22 additions & 0 deletions packages/core/src/plugins/adapt-sandbox-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import type {
PluginCapability,
PluginStorageConfig,
PluginAdminConfig,
PluginMcpTool,
} from "./types.js";

/**
Expand Down Expand Up @@ -147,6 +148,26 @@ export function adaptSandboxEntry(
}
}

const mcpTools: Record<string, PluginMcpTool> = {};
for (const toolEntry of descriptor.mcpTools ?? []) {
mcpTools[toolEntry.name] = {
title: toolEntry.title,
description: toolEntry.description,
route: toolEntry.route,
};
}
if (definition.mcpTools) {
for (const [toolName, toolEntry] of Object.entries(definition.mcpTools)) {
mcpTools[toolName] = {
title: toolEntry.title,
description: toolEntry.description,
route: toolEntry.route,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- Standard MCP tool input is intentionally loosely typed; callers validate at runtime
input: toolEntry.input as PluginMcpTool["input"],
};
}
}

// Build capabilities from descriptor.
// Validate against the known set (same as defineNativePlugin). Both
// current and deprecated names are accepted; deprecated names are
Expand Down Expand Up @@ -214,6 +235,7 @@ export function adaptSandboxEntry(
storage,
hooks: resolvedHooks,
routes: resolvedRoutes,
mcpTools,
admin,
};
}
Loading
Loading