From c61e4e36d6c7d0a4aea6f8441d4f8ce551cb788e Mon Sep 17 00:00:00 2001 From: Mason James Date: Mon, 11 May 2026 09:42:41 -0400 Subject: [PATCH 1/5] Add plugin-defined MCP tools --- .changeset/plugin-mcp-tools.md | 7 + .../core/src/astro/integration/runtime.ts | 9 + .../src/astro/integration/virtual-modules.ts | 2 + packages/core/src/astro/middleware.ts | 1 + packages/core/src/astro/routes/api/mcp.ts | 2 +- packages/core/src/astro/types.ts | 3 + .../core/src/cli/commands/bundle-utils.ts | 6 + packages/core/src/emdash-runtime.ts | 64 ++++++ packages/core/src/index.ts | 3 + packages/core/src/mcp/server.ts | 88 +++++++- .../core/src/plugins/adapt-sandbox-entry.ts | 22 ++ packages/core/src/plugins/define-plugin.ts | 3 + packages/core/src/plugins/index.ts | 3 + packages/core/src/plugins/manifest-schema.ts | 11 + packages/core/src/plugins/types.ts | 42 ++++ .../integration/mcp/plugin-tools.test.ts | 194 ++++++++++++++++++ .../core/tests/unit/cli/bundle-utils.test.ts | 23 +++ .../core/tests/unit/mcp/authorization.test.ts | 1 + .../unit/plugins/adapt-sandbox-entry.test.ts | 59 ++++++ .../tests/unit/plugins/define-plugin.test.ts | 10 + .../unit/plugins/manifest-schema.test.ts | 44 ++++ packages/core/tests/utils/mcp-runtime.ts | 31 +-- packages/plugin-types/src/index.ts | 18 ++ packages/registry-cli/src/bundle/types.ts | 8 + packages/registry-cli/src/bundle/utils.ts | 6 + .../registry-cli/tests/bundle-utils.test.ts | 23 +++ 26 files changed, 668 insertions(+), 15 deletions(-) create mode 100644 .changeset/plugin-mcp-tools.md create mode 100644 packages/core/tests/integration/mcp/plugin-tools.test.ts diff --git a/.changeset/plugin-mcp-tools.md b/.changeset/plugin-mcp-tools.md new file mode 100644 index 000000000..e3b638cd9 --- /dev/null +++ b/.changeset/plugin-mcp-tools.md @@ -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. diff --git a/packages/core/src/astro/integration/runtime.ts b/packages/core/src/astro/integration/runtime.ts index 6de26de02..856f558c2 100644 --- a/packages/core/src/astro/integration/runtime.ts +++ b/packages/core/src/astro/integration/runtime.ts @@ -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 * @@ -95,6 +102,8 @@ export interface PluginDescriptor> { adminPages?: PluginAdminPage[]; /** Dashboard widgets */ adminWidgets?: PluginDashboardWidget[]; + /** MCP tools exposed through EmDash's MCP endpoint */ + mcpTools?: PluginMcpToolDescriptor[]; // === Sandbox-specific fields (for sandboxed plugins) === diff --git a/packages/core/src/astro/integration/virtual-modules.ts b/packages/core/src/astro/integration/virtual-modules.ts index 96c0ebfad..8fddb7a05 100644 --- a/packages/core/src/astro/integration/virtual-modules.ts +++ b/packages/core/src/astro/integration/virtual-modules.ts @@ -217,6 +217,7 @@ export function generatePluginsModule(descriptors: PluginDescriptor[]): string { storage: descriptor.storage, adminPages: descriptor.adminPages, adminWidgets: descriptor.adminWidgets, + mcpTools: descriptor.mcpTools, })})`, ); } else { @@ -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)}, diff --git a/packages/core/src/astro/middleware.ts b/packages/core/src/astro/middleware.ts index 488bf91d6..a0bd4631a 100644 --- a/packages/core/src/astro/middleware.ts +++ b/packages/core/src/astro/middleware.ts @@ -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), diff --git a/packages/core/src/astro/routes/api/mcp.ts b/packages/core/src/astro/routes/api/mcp.ts index 86e4319b8..d1c4fb61c 100644 --- a/packages/core/src/astro/routes/api/mcp.ts +++ b/packages/core/src/astro/routes/api/mcp.ts @@ -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({ diff --git a/packages/core/src/astro/types.ts b/packages/core/src/astro/types.ts index c14f20626..4b3be88e8 100644 --- a/packages/core/src/astro/types.ts +++ b/packages/core/src/astro/types.ts @@ -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<{ diff --git a/packages/core/src/cli/commands/bundle-utils.ts b/packages/core/src/cli/commands/bundle-utils.ts index 23fd42b11..f18d68898 100644 --- a/packages/core/src/cli/commands/bundle-utils.ts +++ b/packages/core/src/cli/commands/bundle-utils.ts @@ -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, diff --git a/packages/core/src/emdash-runtime.ts b/packages/core/src/emdash-runtime.ts index 9edd2226c..0dfb7f779 100644 --- a/packages/core/src/emdash-runtime.ts +++ b/packages/core/src/emdash-runtime.ts @@ -34,6 +34,7 @@ import type { MediaItem, PluginManifest, PluginCapability, + ManifestMcpToolEntry, PluginStorageConfig, PublicPageContext, PageMetadataContribution, @@ -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; /** @@ -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 */ @@ -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 @@ -1097,6 +1104,7 @@ export class EmDashRuntime { storage: entry.storage ?? {}, hooks: [], routes: [], + mcpTools: entry.mcpTools ?? [], admin: {}, }; @@ -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 @@ -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, + }); + } + } + + return tools; + } + /** * Get route metadata for a plugin route without invoking the handler. * Used by the catch-all route to decide auth before dispatch. diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index dfa8ce9c7..6f2eaf673 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -224,6 +224,8 @@ export type { HookResult, PluginRoute, RouteContext, + PluginMcpTool, + PluginMcpToolRegistration, PluginAdminConfig, PluginAdminPage, PluginAdminExports, @@ -259,6 +261,7 @@ export type { SandboxEmailSendCallback, PluginManifest, ValidatedPluginManifest, + ManifestMcpToolEntry, SerializedRequest, } from "./plugins/index.js"; diff --git a/packages/core/src/mcp/server.ts b/packages/core/src/mcp/server.ts index 2f9bf84ff..2cb4620c4 100644 --- a/packages/core/src/mcp/server.ts +++ b/packages/core/src/mcp/server.ts @@ -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; @@ -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}`; +} + +function normalizePluginToolRoute(route: string): string { + return route.replace(LEADING_SLASH_PATTERN, ""); +} + +function createPluginToolRequest( + pluginId: string, + route: string, + args: Record, +): 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 // @@ -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: {} } }, @@ -419,6 +450,61 @@ export function createMcpServer(): McpServer { )(name, config, wrapped); }) as typeof server.registerTool; + const registeredToolNames = new Set(); + 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()), + }, + async (args: Record, 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) + : undefined; + return respondError(code, message, details); + } + + return respondData(result.data ?? null); + }, + ); + } + // ===================================================================== // Content tools // ===================================================================== diff --git a/packages/core/src/plugins/adapt-sandbox-entry.ts b/packages/core/src/plugins/adapt-sandbox-entry.ts index 8535febd8..f827ec71f 100644 --- a/packages/core/src/plugins/adapt-sandbox-entry.ts +++ b/packages/core/src/plugins/adapt-sandbox-entry.ts @@ -24,6 +24,7 @@ import type { PluginCapability, PluginStorageConfig, PluginAdminConfig, + PluginMcpTool, } from "./types.js"; /** @@ -147,6 +148,26 @@ export function adaptSandboxEntry( } } + const mcpTools: Record = {}; + 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 @@ -214,6 +235,7 @@ export function adaptSandboxEntry( storage, hooks: resolvedHooks, routes: resolvedRoutes, + mcpTools, admin, }; } diff --git a/packages/core/src/plugins/define-plugin.ts b/packages/core/src/plugins/define-plugin.ts index 308647215..676791a9e 100644 --- a/packages/core/src/plugins/define-plugin.ts +++ b/packages/core/src/plugins/define-plugin.ts @@ -123,6 +123,7 @@ function defineNativePlugin( allowedHosts = [], hooks = {}, routes = {}, + mcpTools = {}, admin = {}, } = definition; @@ -159,6 +160,7 @@ function defineNativePlugin( "media:write", "users:read", "email:send", + "mcp:tools", "hooks.email-transport:register", "hooks.email-events:register", "hooks.page-fragments:register", @@ -214,6 +216,7 @@ function defineNativePlugin( storage, hooks: resolvedHooks, routes, + mcpTools, admin, }; } diff --git a/packages/core/src/plugins/index.ts b/packages/core/src/plugins/index.ts index 0b015f9f9..4574a9373 100644 --- a/packages/core/src/plugins/index.ts +++ b/packages/core/src/plugins/index.ts @@ -167,6 +167,8 @@ export type { // Route types PluginRoute, RouteContext, + PluginMcpTool, + PluginMcpToolRegistration, // Admin types PluginAdminConfig, @@ -183,6 +185,7 @@ export type { PluginDefinition, ResolvedPlugin, PluginManifest, + ManifestMcpToolEntry, // Standard plugin format StandardPluginDefinition, diff --git a/packages/core/src/plugins/manifest-schema.ts b/packages/core/src/plugins/manifest-schema.ts index 7640fd0c7..7eb7a35d3 100644 --- a/packages/core/src/plugins/manifest-schema.ts +++ b/packages/core/src/plugins/manifest-schema.ts @@ -25,6 +25,7 @@ export const CURRENT_PLUGIN_CAPABILITIES = [ "media:write", "users:read", "email:send", + "mcp:tools", "hooks.email-transport:register", "hooks.email-events:register", "hooks.page-fragments:register", @@ -129,6 +130,15 @@ const manifestRouteEntrySchema = z.object({ public: z.boolean().optional(), }); +const mcpToolNamePattern = /^[a-z][a-z0-9_]*$/; + +const manifestMcpToolEntrySchema = z.object({ + name: z.string().min(1).regex(mcpToolNamePattern, "MCP tool name must be lowercase snake_case"), + title: z.string().min(1).optional(), + description: z.string().min(1), + route: z.string().min(1).regex(routeNamePattern, "Route name must be a safe path segment"), +}); + // ── Sub-schemas ───────────────────────────────────────────────── /** Index field names must be valid identifiers to prevent SQL injection via JSON path expressions */ @@ -249,6 +259,7 @@ export const pluginManifestSchema = z.object({ manifestRouteEntrySchema, ]), ), + mcpTools: z.array(manifestMcpToolEntrySchema).optional().default([]), admin: pluginAdminConfigSchema, }); diff --git a/packages/core/src/plugins/types.ts b/packages/core/src/plugins/types.ts index 359b7b531..f2b5544d9 100644 --- a/packages/core/src/plugins/types.ts +++ b/packages/core/src/plugins/types.ts @@ -25,6 +25,7 @@ import { type CurrentPluginCapability, type DeprecatedPluginCapability, type ManifestHookEntry, + type ManifestMcpToolEntry, type ManifestRouteEntry, type PluginCapability, type PluginStorageConfig, @@ -46,6 +47,7 @@ export { type CurrentPluginCapability, type DeprecatedPluginCapability, type ManifestHookEntry, + type ManifestMcpToolEntry, type ManifestRouteEntry, type PluginCapability, type PluginStorageConfig, @@ -1076,6 +1078,32 @@ export interface PluginRoute { handler: (ctx: RouteContext) => Promise; } +/** + * Plugin-defined MCP tool. + * + * Execution delegates to a plugin route so the route's existing validation, + * plugin context, and sandbox bridge are reused. + */ +export interface PluginMcpTool { + /** Human-readable title exposed to MCP clients */ + title?: string; + /** Tool description exposed to MCP clients */ + description: string; + /** Route name to invoke when the MCP tool is called */ + route: string; + /** Optional Zod schema for MCP argument validation */ + input?: z.ZodType>; +} + +export interface PluginMcpToolRegistration { + pluginId: string; + name: string; + title?: string; + description: string; + route: string; + input?: z.ZodType>; +} + // ============================================================================= // Plugin Definition // ============================================================================= @@ -1257,6 +1285,9 @@ export interface PluginDefinition; + /** MCP tools exposed through the host MCP endpoint */ + mcpTools?: Record; + /** Admin UI configuration */ admin?: PluginAdminConfig; } @@ -1272,6 +1303,7 @@ export interface ResolvedPlugin; + mcpTools?: Record; admin: PluginAdminConfig; } @@ -1351,6 +1383,13 @@ export interface StandardRouteEntry { public?: boolean; } +export interface StandardMcpToolEntry { + title?: string; + description: string; + route: string; + input?: unknown; +} + /** * Standard plugin definition -- the sandbox entry format. * Used by standard plugins that work in both trusted and sandboxed modes. @@ -1367,6 +1406,7 @@ export interface StandardPluginDefinition { hooks?: Record; // eslint-disable-next-line typescript-eslint/no-explicit-any -- must accept handlers with specific event/route types routes?: Record; + mcpTools?: Record; } /** @@ -1420,6 +1460,8 @@ export interface PluginManifest { hooks: Array; /** Route declarations — either plain name strings or structured objects */ routes: Array; + /** MCP tool declarations */ + mcpTools?: ManifestMcpToolEntry[]; admin: PluginAdminConfig; } diff --git a/packages/core/tests/integration/mcp/plugin-tools.test.ts b/packages/core/tests/integration/mcp/plugin-tools.test.ts new file mode 100644 index 000000000..2b9deb91b --- /dev/null +++ b/packages/core/tests/integration/mcp/plugin-tools.test.ts @@ -0,0 +1,194 @@ +import { Role } from "@emdash-cms/auth"; +import { afterEach, describe, expect, it } from "vitest"; +import { z } from "zod"; + +import { definePlugin } from "../../../src/plugins/define-plugin.js"; +import type { ResolvedPlugin } from "../../../src/plugins/types.js"; +import { + connectMcpHarness, + createTestRuntime, + extractJson, + extractText, + isErrorResult, + type McpHarness, +} from "../../utils/mcp-runtime.js"; +import { setupTestDatabaseWithCollections, teardownTestDatabase } from "../../utils/test-db.js"; + +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}`; +} + +function createPluginWithMcpCapability( + id: string = "test-plugin", + summarize: (text: string) => string = (text) => text.toUpperCase(), +): ResolvedPlugin { + const summarizeInput = z.object({ text: z.string() }); + + return definePlugin({ + id, + version: "1.0.0", + capabilities: ["mcp:tools"], + routes: { + summarize: { + input: summarizeInput, + handler: async ({ input }) => { + const { text } = summarizeInput.parse(input); + return { summary: summarize(text) }; + }, + }, + }, + mcpTools: { + summarize: { + title: "Summarize Text", + description: "Summarize text with the test plugin.", + route: "summarize", + input: summarizeInput, + }, + }, + }); +} + +function createPluginWithoutMcpCapability(): ResolvedPlugin { + return definePlugin({ + id: "route-only-plugin", + version: "1.0.0", + capabilities: [], + routes: { + summarize: { + handler: async () => ({ summary: "hidden" }), + }, + }, + mcpTools: { + summarize: { + description: "This tool should not be listed without the mcp:tools capability.", + route: "summarize", + }, + }, + }); +} + +describe("plugin MCP tools", () => { + let harness: McpHarness | undefined; + let dbCleanup: (() => Promise) | undefined; + + afterEach(async () => { + await harness?.cleanup(); + await dbCleanup?.(); + harness = undefined; + dbCleanup = undefined; + }); + + async function connectWithPlugins(plugins: ResolvedPlugin[], tokenScopes?: string[]) { + const db = await setupTestDatabaseWithCollections(); + harness = await connectMcpHarness({ + db, + userId: "user_admin", + userRole: Role.ADMIN, + tokenScopes, + runtimeOptions: { plugins }, + }); + dbCleanup = () => teardownTestDatabase(db); + return harness; + } + + it("lists enabled plugin tools that declare the mcp:tools capability", async () => { + const connected = await connectWithPlugins([createPluginWithMcpCapability()]); + + const tools = await connected.client.listTools(); + const toolNames = tools.tools.map((tool) => tool.name); + + expect(toolNames).toContain(pluginToolName("test-plugin", "summarize")); + }); + + it("invokes plugin tools through the plugin route dispatcher", async () => { + const connected = await connectWithPlugins([createPluginWithMcpCapability()]); + + const result = await connected.client.callTool({ + name: pluginToolName("test-plugin", "summarize"), + arguments: { text: "hello" }, + }); + + expect(result.isError).toBeFalsy(); + expect(extractJson(result)).toEqual({ summary: "HELLO" }); + }); + + it("does not list plugin tools without the mcp:tools capability", async () => { + const connected = await connectWithPlugins([createPluginWithoutMcpCapability()]); + + const tools = await connected.client.listTools(); + const toolNames = tools.tools.map((tool) => tool.name); + + expect(toolNames).not.toContain(pluginToolName("route-only-plugin", "summarize")); + }); + + it("keeps plugin tool names distinct for colliding readable plugin IDs", async () => { + const scopedName = pluginToolName("@foo/bar", "summarize"); + const dashedName = pluginToolName("foo-bar", "summarize"); + const connected = await connectWithPlugins([ + createPluginWithMcpCapability("@foo/bar", (text) => `scoped:${text}`), + createPluginWithMcpCapability("foo-bar", (text) => `dashed:${text}`), + ]); + + const tools = await connected.client.listTools(); + const toolNames = tools.tools.map((tool) => tool.name); + + expect(scopedName).not.toBe(dashedName); + expect(toolNames).toContain(scopedName); + expect(toolNames).toContain(dashedName); + + const scopedResult = await connected.client.callTool({ + name: scopedName, + arguments: { text: "hello" }, + }); + const dashedResult = await connected.client.callTool({ + name: dashedName, + arguments: { text: "hello" }, + }); + + expect(extractJson(scopedResult)).toEqual({ summary: "scoped:hello" }); + expect(extractJson(dashedResult)).toEqual({ summary: "dashed:hello" }); + }); + + it("does not list sandboxed plugin tools when the plugin is not loaded", async () => { + const db = await setupTestDatabaseWithCollections(); + dbCleanup = () => teardownTestDatabase(db); + const runtime = createTestRuntime(db, { + enabledPluginIds: ["sandbox-plugin"], + sandboxedPluginEntries: [ + { + id: "sandbox-plugin", + version: "1.0.0", + options: {}, + code: "export default {};", + capabilities: ["mcp:tools"], + allowedHosts: [], + storage: {}, + mcpTools: [ + { + name: "summarize", + description: "Summarize text.", + route: "summarize", + }, + ], + }, + ], + }); + + expect(runtime.getPluginMcpTools()).toEqual([]); + }); + + it("requires admin token scope to call plugin tools", async () => { + const connected = await connectWithPlugins([createPluginWithMcpCapability()], ["content:read"]); + + const result = await connected.client.callTool({ + name: pluginToolName("test-plugin", "summarize"), + arguments: { text: "hello" }, + }); + + expect(isErrorResult(result)).toBe(true); + expect(extractText(result)).toContain("[INSUFFICIENT_SCOPE]"); + }); +}); diff --git a/packages/core/tests/unit/cli/bundle-utils.test.ts b/packages/core/tests/unit/cli/bundle-utils.test.ts index 3f50ca5c5..79a8c3d47 100644 --- a/packages/core/tests/unit/cli/bundle-utils.test.ts +++ b/packages/core/tests/unit/cli/bundle-utils.test.ts @@ -85,6 +85,29 @@ describe("extractManifest", () => { expect(manifest.routes).toEqual(["sync", "webhook"]); }); + it("converts MCP tools to serializable manifest metadata", () => { + const plugin = mockPlugin({ + mcpTools: { + summarize: { + title: "Summarize", + description: "Summarize text.", + route: "summarize", + }, + }, + }); + + const manifest = extractManifest(plugin); + + expect(manifest.mcpTools).toEqual([ + { + name: "summarize", + title: "Summarize", + description: "Summarize text.", + route: "summarize", + }, + ]); + }); + it("strips admin.entry (host-only concern, not in bundles)", () => { const plugin = mockPlugin({ admin: { diff --git a/packages/core/tests/unit/mcp/authorization.test.ts b/packages/core/tests/unit/mcp/authorization.test.ts index 179864aba..96e305cf3 100644 --- a/packages/core/tests/unit/mcp/authorization.test.ts +++ b/packages/core/tests/unit/mcp/authorization.test.ts @@ -159,6 +159,7 @@ function createMockHandlers(ownerId: string = AUTHOR_USER_ID): EmDashHandlers { success: true, data: { item: contentItem }, }), + getPluginMcpTools: vi.fn(() => []), } as unknown as EmDashHandlers; } diff --git a/packages/core/tests/unit/plugins/adapt-sandbox-entry.test.ts b/packages/core/tests/unit/plugins/adapt-sandbox-entry.test.ts index deea7a995..4c6c79269 100644 --- a/packages/core/tests/unit/plugins/adapt-sandbox-entry.test.ts +++ b/packages/core/tests/unit/plugins/adapt-sandbox-entry.test.ts @@ -77,6 +77,65 @@ describe("adaptSandboxEntry", () => { expect(result.allowedHosts).toEqual(["api.example.com", "*.cdn.com"]); }); + it("carries MCP tools from the standard definition", () => { + const def: StandardPluginDefinition = { + routes: { + summarize: { + handler: async () => ({ ok: true }), + }, + }, + mcpTools: { + summarize: { + title: "Summarize", + description: "Summarize text.", + route: "summarize", + }, + }, + }; + const descriptor = createDescriptor({ capabilities: ["mcp:tools"] }); + + const result = adaptSandboxEntry(def, descriptor); + + expect(result.mcpTools).toEqual({ + summarize: { + title: "Summarize", + description: "Summarize text.", + route: "summarize", + }, + }); + }); + + it("carries MCP tools from the descriptor", () => { + const def: StandardPluginDefinition = { + routes: { + summarize: { + handler: async () => ({ ok: true }), + }, + }, + }; + const descriptor = createDescriptor({ + capabilities: ["mcp:tools"], + mcpTools: [ + { + name: "summarize", + title: "Summarize", + description: "Summarize text.", + route: "summarize", + }, + ], + }); + + const result = adaptSandboxEntry(def, descriptor); + + expect(result.mcpTools).toEqual({ + summarize: { + title: "Summarize", + description: "Summarize text.", + route: "summarize", + }, + }); + }); + it("carries storage config from descriptor", () => { const def: StandardPluginDefinition = {}; const descriptor = createDescriptor({ diff --git a/packages/core/tests/unit/plugins/define-plugin.test.ts b/packages/core/tests/unit/plugins/define-plugin.test.ts index d50bbf013..010b0ac68 100644 --- a/packages/core/tests/unit/plugins/define-plugin.test.ts +++ b/packages/core/tests/unit/plugins/define-plugin.test.ts @@ -171,6 +171,16 @@ describe("definePlugin", () => { expect(plugin.capabilities).toContain("network:request"); }); + it("accepts the mcp:tools capability", () => { + const plugin = definePlugin({ + id: "test", + version: "1.0.0", + capabilities: ["mcp:tools"], + }); + + expect(plugin.capabilities).toContain("mcp:tools"); + }); + it("accepts media:read and media:write", () => { const plugin = definePlugin({ id: "test", diff --git a/packages/core/tests/unit/plugins/manifest-schema.test.ts b/packages/core/tests/unit/plugins/manifest-schema.test.ts index 5b9c6cda3..763180afd 100644 --- a/packages/core/tests/unit/plugins/manifest-schema.test.ts +++ b/packages/core/tests/unit/plugins/manifest-schema.test.ts @@ -105,6 +105,50 @@ describe("pluginManifestSchema — route entries", () => { }); }); +describe("pluginManifestSchema — MCP tool entries", () => { + it("should accept plugin MCP tool metadata", () => { + const result = pluginManifestSchema.safeParse({ + ...makeManifest({}), + capabilities: ["mcp:tools"], + mcpTools: [ + { + name: "summarize", + title: "Summarize Text", + description: "Summarize content using the plugin.", + route: "tools/summarize", + }, + ], + }); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.mcpTools).toEqual([ + { + name: "summarize", + title: "Summarize Text", + description: "Summarize content using the plugin.", + route: "tools/summarize", + }, + ]); + } + }); + + it("should reject MCP tool names outside lowercase snake_case", () => { + const result = pluginManifestSchema.safeParse({ + ...makeManifest({}), + mcpTools: [ + { + name: "Summarize", + description: "Invalid tool name.", + route: "tools/summarize", + }, + ], + }); + + 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/core/tests/utils/mcp-runtime.ts b/packages/core/tests/utils/mcp-runtime.ts index 423f23cda..2c8f84d82 100644 --- a/packages/core/tests/utils/mcp-runtime.ts +++ b/packages/core/tests/utils/mcp-runtime.ts @@ -20,9 +20,10 @@ import type { Kysely } from "kysely"; import type { EmDashConfig } from "../../src/astro/integration/runtime.js"; import type { EmDashHandlers } from "../../src/astro/types.js"; import type { Database } from "../../src/database/types.js"; -import { EmDashRuntime } from "../../src/emdash-runtime.js"; +import { EmDashRuntime, type SandboxedPluginEntry } from "../../src/emdash-runtime.js"; import { createMcpServer } from "../../src/mcp/server.js"; import { createHookPipeline } from "../../src/plugins/hooks.js"; +import type { SandboxedPlugin } from "../../src/plugins/sandbox/index.js"; import type { ResolvedPlugin } from "../../src/plugins/types.js"; import { invalidateUrlPatternCache } from "../../src/query.js"; @@ -94,6 +95,12 @@ function createAuthenticatedPair(authInfo: { export interface TestRuntimeOptions { /** Optional plugins to participate in the hook pipeline. Default: none. */ plugins?: ResolvedPlugin[]; + /** Optional already-loaded sandboxed plugins. Default: none. */ + sandboxedPlugins?: Map; + /** Optional sandboxed plugin descriptors. Default: none. */ + sandboxedPluginEntries?: SandboxedPluginEntry[]; + /** Optional enabled plugin IDs in addition to configured plugins. */ + enabledPluginIds?: string[]; /** Optional partial config override. Default: empty config. */ config?: Partial; } @@ -109,6 +116,8 @@ export function createTestRuntime( opts: TestRuntimeOptions = {}, ): EmDashRuntime { const plugins = opts.plugins ?? []; + const sandboxedPlugins = opts.sandboxedPlugins ?? new Map(); + const sandboxedPluginEntries = opts.sandboxedPluginEntries ?? []; const config: EmDashConfig = { ...opts.config }; const pipelineFactoryOptions = { db } as const; @@ -129,7 +138,7 @@ export function createTestRuntime( }) as any, createStorage: null, sandboxEnabled: false, - sandboxedPluginEntries: [], + sandboxedPluginEntries, createSandboxRunner: null, }; @@ -137,10 +146,10 @@ export function createTestRuntime( db, storage: null, configuredPlugins: plugins, - sandboxedPlugins: new Map(), - sandboxedPluginEntries: [], + sandboxedPlugins, + sandboxedPluginEntries, hooks, - enabledPlugins: new Set(plugins.map((p) => p.id)), + enabledPlugins: new Set([...plugins.map((p) => p.id), ...(opts.enabledPluginIds ?? [])]), pluginStates: new Map(), config, mediaProviders: new Map(), @@ -206,13 +215,9 @@ export function handlersFromRuntime(runtime: EmDashRuntime): EmDashHandlers { getManifest: runtime.getManifest.bind(runtime), invalidateUrlPatternCache, - // Fields the MCP server doesn't currently call. Stub so the type - // checks; if a tool ever reaches for one, the test will throw a - // clear error rather than silently no-op. - handlePluginApiRoute: () => { - throw new Error("handlePluginApiRoute not implemented in test runtime"); - }, - getPluginRouteMeta: () => null, + handlePluginApiRoute: runtime.handlePluginApiRoute.bind(runtime), + getPluginRouteMeta: runtime.getPluginRouteMeta.bind(runtime), + getPluginMcpTools: runtime.getPluginMcpTools.bind(runtime), getMediaProvider: runtime.getMediaProvider.bind(runtime), getMediaProviderList: runtime.getMediaProviderList.bind(runtime), getSandboxRunner: runtime.getSandboxRunner.bind(runtime), @@ -259,7 +264,7 @@ export async function connectMcpHarness(opts: ConnectMcpOptions): Promise; /** Route declarations -- plain name strings or structured objects. */ routes: Array; + /** MCP tools declared by the plugin. */ + mcpTools?: ManifestMcpToolEntry[]; admin: PluginAdminConfig; } diff --git a/packages/registry-cli/src/bundle/types.ts b/packages/registry-cli/src/bundle/types.ts index 7ff78eaca..8ed0960e7 100644 --- a/packages/registry-cli/src/bundle/types.ts +++ b/packages/registry-cli/src/bundle/types.ts @@ -65,5 +65,13 @@ export interface ResolvedPlugin { public?: boolean; } >; + mcpTools?: Record< + string, + { + title?: string; + description: string; + route: string; + } + >; admin: PluginAdminConfig; } diff --git a/packages/registry-cli/src/bundle/utils.ts b/packages/registry-cli/src/bundle/utils.ts index c681240af..f1c31184b 100644 --- a/packages/registry-cli/src/bundle/utils.ts +++ b/packages/registry-cli/src/bundle/utils.ts @@ -152,6 +152,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, diff --git a/packages/registry-cli/tests/bundle-utils.test.ts b/packages/registry-cli/tests/bundle-utils.test.ts index 941d4de01..679a49c25 100644 --- a/packages/registry-cli/tests/bundle-utils.test.ts +++ b/packages/registry-cli/tests/bundle-utils.test.ts @@ -72,6 +72,29 @@ describe("extractManifest", () => { expect(manifest.routes.toSorted((a, b) => a.localeCompare(b))).toEqual(["admin", "api"]); }); + it("emits MCP tool metadata", () => { + const manifest = extractManifest( + minimalResolved({ + mcpTools: { + summarize: { + title: "Summarize", + description: "Summarize text.", + route: "summarize", + }, + }, + }), + ); + + expect(manifest.mcpTools).toEqual([ + { + name: "summarize", + title: "Summarize", + description: "Summarize text.", + route: "summarize", + }, + ]); + }); + it("strips the runtime entry pointer from admin", () => { const manifest = extractManifest( minimalResolved({ From 29aa5ba07741a084e2ff141d717877f68bc63e5b Mon Sep 17 00:00:00 2001 From: Mason James Date: Tue, 12 May 2026 21:49:55 -0400 Subject: [PATCH 2/5] Address plugin MCP tool review feedback --- docs/astro.config.mjs | 1 + .../plugins/creating-plugins/mcp-tools.mdx | 116 ++++++++++ .../src/content/docs/reference/mcp-server.mdx | 4 + .../core/src/astro/integration/runtime.ts | 1 + .../core/src/cli/commands/bundle-utils.ts | 1 + packages/core/src/emdash-runtime.ts | 16 +- packages/core/src/mcp/json-schema.ts | 95 ++++++++ packages/core/src/mcp/server.ts | 35 ++- .../core/src/plugins/adapt-sandbox-entry.ts | 24 ++ packages/core/src/plugins/define-plugin.ts | 40 ++++ packages/core/src/plugins/manifest-schema.ts | 166 ++++++++++--- packages/core/src/plugins/types.ts | 60 ++++- .../integration/mcp/plugin-tools.test.ts | 219 +++++++++++++++++- .../core/tests/unit/cli/bundle-utils.test.ts | 14 ++ .../unit/plugins/adapt-sandbox-entry.test.ts | 68 ++++++ .../tests/unit/plugins/define-plugin.test.ts | 117 ++++++++++ .../unit/plugins/manifest-schema.test.ts | 99 ++++++++ packages/plugin-types/src/index.ts | 50 ++++ packages/registry-cli/src/bundle/types.ts | 49 ++++ packages/registry-cli/src/bundle/utils.ts | 1 + .../registry-cli/tests/bundle-utils.test.ts | 14 ++ 21 files changed, 1142 insertions(+), 48 deletions(-) create mode 100644 docs/src/content/docs/plugins/creating-plugins/mcp-tools.mdx create mode 100644 packages/core/src/mcp/json-schema.ts diff --git a/docs/astro.config.mjs b/docs/astro.config.mjs index a7f0157d7..190378617 100644 --- a/docs/astro.config.mjs +++ b/docs/astro.config.mjs @@ -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", diff --git a/docs/src/content/docs/plugins/creating-plugins/mcp-tools.mdx b/docs/src/content/docs/plugins/creating-plugins/mcp-tools.mdx new file mode 100644 index 000000000..851b7c7bf --- /dev/null +++ b/docs/src/content/docs/plugins/creating-plugins/mcp-tools.mdx @@ -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. + + + +## 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. diff --git a/docs/src/content/docs/reference/mcp-server.mdx b/docs/src/content/docs/reference/mcp-server.mdx index ca0630b92..ea8b65d9e 100644 --- a/docs/src/content/docs/reference/mcp-server.mdx +++ b/docs/src/content/docs/reference/mcp-server.mdx @@ -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` diff --git a/packages/core/src/astro/integration/runtime.ts b/packages/core/src/astro/integration/runtime.ts index 856f558c2..93559a7ec 100644 --- a/packages/core/src/astro/integration/runtime.ts +++ b/packages/core/src/astro/integration/runtime.ts @@ -39,6 +39,7 @@ export interface PluginMcpToolDescriptor { title?: string; description: string; route: string; + inputSchema?: import("../../plugins/types.js").ManifestJsonObjectSchema; } /** diff --git a/packages/core/src/cli/commands/bundle-utils.ts b/packages/core/src/cli/commands/bundle-utils.ts index f18d68898..95030a4e9 100644 --- a/packages/core/src/cli/commands/bundle-utils.ts +++ b/packages/core/src/cli/commands/bundle-utils.ts @@ -161,6 +161,7 @@ export function extractManifest(plugin: ResolvedPlugin): PluginManifest { 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) diff --git a/packages/core/src/emdash-runtime.ts b/packages/core/src/emdash-runtime.ts index 0dfb7f779..8e977bda6 100644 --- a/packages/core/src/emdash-runtime.ts +++ b/packages/core/src/emdash-runtime.ts @@ -2161,19 +2161,27 @@ export class EmDashRuntime { getPluginMcpTools(): import("./plugins/types.js").PluginMcpToolRegistration[] { const tools: import("./plugins/types.js").PluginMcpToolRegistration[] = []; + const seen = new Set(); + const addTool = (tool: import("./plugins/types.js").PluginMcpToolRegistration) => { + const key = `${tool.pluginId}\0${tool.name}`; + 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 ?? {})) { - tools.push({ + addTool({ pluginId: plugin.id, name, title: tool.title, description: tool.description, route: tool.route, input: tool.input, + inputSchema: tool.inputSchema, }); } } @@ -2184,12 +2192,13 @@ export class EmDashRuntime { if (!this.findSandboxedPlugin(entry.id)) continue; for (const tool of entry.mcpTools ?? []) { - tools.push({ + addTool({ pluginId: entry.id, name: tool.name, title: tool.title, description: tool.description, route: tool.route, + inputSchema: tool.inputSchema, }); } } @@ -2200,12 +2209,13 @@ export class EmDashRuntime { if (!this.findSandboxedPlugin(pluginId)) continue; for (const tool of meta.mcpTools ?? []) { - tools.push({ + addTool({ pluginId, name: tool.name, title: tool.title, description: tool.description, route: tool.route, + inputSchema: tool.inputSchema, }); } } diff --git a/packages/core/src/mcp/json-schema.ts b/packages/core/src/mcp/json-schema.ts new file mode 100644 index 000000000..5249e34a6 --- /dev/null +++ b/packages/core/src/mcp/json-schema.ts @@ -0,0 +1,95 @@ +import { z } from "zod"; + +import type { + ManifestJsonArraySchema, + ManifestJsonBooleanSchema, + ManifestJsonNumberSchema, + ManifestJsonObjectSchema, + ManifestJsonSchema, + ManifestJsonStringSchema, +} from "../plugins/types.js"; + +export function jsonSchemaObjectToZod( + schema: ManifestJsonObjectSchema, +): z.ZodType> { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- object roots are converted through objectSchemaToZod() + return schemaToZod(schema) as z.ZodType>; +} + +function schemaToZod(schema: ManifestJsonSchema): z.ZodTypeAny { + switch (schema.type) { + case "string": + return stringSchemaToZod(schema); + case "number": + case "integer": + return numberSchemaToZod(schema); + case "boolean": + return booleanSchemaToZod(schema); + case "array": + return arraySchemaToZod(schema); + case "object": + return objectSchemaToZod(schema); + } +} + +function applyCommon(schema: z.ZodTypeAny, source: ManifestJsonSchema): z.ZodTypeAny { + let next = source.description ? schema.describe(source.description) : schema; + if ("default" in source) { + next = next.default(source.default); + } + return next; +} + +function applyEnum( + schema: z.ZodTypeAny, + values: TValue[] | undefined, +): z.ZodTypeAny { + if (!values) return schema; + return schema.refine((value) => values.some((allowed) => Object.is(allowed, value)), { + message: `Expected one of: ${values.join(", ")}`, + }); +} + +function stringSchemaToZod(schema: ManifestJsonStringSchema): z.ZodTypeAny { + let next = z.string(); + if (schema.minLength !== undefined) next = next.min(schema.minLength); + if (schema.maxLength !== undefined) next = next.max(schema.maxLength); + if (schema.pattern) next = next.regex(RegExp(schema.pattern)); + if (schema.format === "date-time") next = next.datetime(); + if (schema.format === "email") next = next.email(); + if (schema.format === "uri") next = next.url(); + if (schema.format === "uuid") next = next.uuid(); + return applyCommon(applyEnum(next, schema.enum), schema); +} + +function numberSchemaToZod(schema: ManifestJsonNumberSchema): z.ZodTypeAny { + let next = schema.type === "integer" ? z.number().int() : z.number(); + if (schema.minimum !== undefined) next = next.min(schema.minimum); + if (schema.maximum !== undefined) next = next.max(schema.maximum); + return applyCommon(applyEnum(next, schema.enum), schema); +} + +function booleanSchemaToZod(schema: ManifestJsonBooleanSchema): z.ZodTypeAny { + return applyCommon(applyEnum(z.boolean(), schema.enum), schema); +} + +function arraySchemaToZod(schema: ManifestJsonArraySchema): z.ZodTypeAny { + let next = z.array(schemaToZod(schema.items)); + if (schema.minItems !== undefined) next = next.min(schema.minItems); + if (schema.maxItems !== undefined) next = next.max(schema.maxItems); + return applyCommon(next, schema); +} + +function objectSchemaToZod(schema: ManifestJsonObjectSchema): z.ZodTypeAny { + const required = new Set(schema.required ?? []); + const shape: Record = {}; + for (const [key, propertySchema] of Object.entries(schema.properties ?? {})) { + const zodSchema = schemaToZod(propertySchema); + shape[key] = required.has(key) ? zodSchema : zodSchema.optional(); + } + + const objectSchema = z.object(shape); + const next = + schema.additionalProperties === false ? objectSchema.strict() : objectSchema.passthrough(); + return applyCommon(next, schema); +} diff --git a/packages/core/src/mcp/server.ts b/packages/core/src/mcp/server.ts index 2cb4620c4..f2ae9bd8a 100644 --- a/packages/core/src/mcp/server.ts +++ b/packages/core/src/mcp/server.ts @@ -18,11 +18,15 @@ import { contentBylineInputSchema, contentSeoInput } from "#api/schemas.js"; import type { EmDashHandlers } from "../astro/types.js"; import { hasScope } from "../auth/api-tokens.js"; +import { jsonSchemaObjectToZod } from "./json-schema.js"; const COLLECTION_SLUG_PATTERN = /^[a-z][a-z0-9_]*$/; -const MCP_TOOL_NAME_PATTERN = /^[a-z][a-z0-9_]*$/; +const MCP_TOOL_NAME_PATTERN = /^(?!.*__)[a-z][a-z0-9_]*$/; const LEADING_SLASH_PATTERN = /^\/+/; const FORWARD_SLASH_PATTERN = "/"; +const PLUGIN_SCOPE_PREFIX_PATTERN = /^@/; +const PLUGIN_ID_DASH_PATTERN = /-/g; +const AMBIGUOUS_PLUGIN_SEGMENT_PATTERN = /__/; /** http(s) scheme matcher used by `settings_update` URL validation. */ const HTTP_SCHEME_PATTERN = /^https?:\/\//i; @@ -236,10 +240,24 @@ function jsonResult(data: unknown): SuccessEnvelope { } 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}`; + const readableSegments = pluginId + .replace(PLUGIN_SCOPE_PREFIX_PATTERN, "") + .split(FORWARD_SLASH_PATTERN) + .map((segment) => segment.replace(PLUGIN_ID_DASH_PATTERN, "_")); + const readablePluginId = readableSegments.join("__"); + if (readableSegments.some((segment) => AMBIGUOUS_PLUGIN_SEGMENT_PATTERN.test(segment))) { + return `${readablePluginId}__${stableToolNameHash(pluginId)}__${toolName}`; + } + return `${readablePluginId}__${toolName}`; +} + +function stableToolNameHash(value: string): string { + let hash = 0x811c9dc5; + for (let index = 0; index < value.length; index++) { + hash ^= value.charCodeAt(index); + hash = Math.imul(hash, 0x01000193); + } + return (hash >>> 0).toString(16).padStart(8, "0"); } function normalizePluginToolRoute(route: string): string { @@ -463,7 +481,11 @@ export function createMcpServer(emdashForToolList?: EmDashHandlers): McpServer { { title: tool.title, description: tool.description, - inputSchema: tool.input ?? z.record(z.string(), z.unknown()), + inputSchema: + tool.input ?? + (tool.inputSchema + ? jsonSchemaObjectToZod(tool.inputSchema) + : z.record(z.string(), z.unknown())), }, async (args: Record, extra) => { requireScope(extra, "admin"); @@ -471,6 +493,7 @@ export function createMcpServer(emdashForToolList?: EmDashHandlers): McpServer { const emdash = getEmDash(extra); const routePath = normalizePluginToolRoute(tool.route); + // Plugin MCP tools are always admin-only, even when the underlying route is public. const routeMeta = emdash.getPluginRouteMeta(tool.pluginId, `/${routePath}`); if (!routeMeta) { return respondError("NOT_FOUND", `Plugin MCP tool route not found: ${tool.route}`); diff --git a/packages/core/src/plugins/adapt-sandbox-entry.ts b/packages/core/src/plugins/adapt-sandbox-entry.ts index f827ec71f..bd32f8a41 100644 --- a/packages/core/src/plugins/adapt-sandbox-entry.ts +++ b/packages/core/src/plugins/adapt-sandbox-entry.ts @@ -33,6 +33,8 @@ import type { const DEFAULT_PRIORITY = 100; const DEFAULT_TIMEOUT = 5000; const DEFAULT_ERROR_POLICY = "abort" as const; +const MCP_TOOL_NAME_PATTERN = /^(?!.*__)[a-z][a-z0-9_]*$/; +const LEADING_SLASH_PATTERN = /^\/+/; /** * Check if a standard hook entry is a config object (has a `handler` property) @@ -154,6 +156,7 @@ export function adaptSandboxEntry( title: toolEntry.title, description: toolEntry.description, route: toolEntry.route, + inputSchema: toolEntry.inputSchema, }; } if (definition.mcpTools) { @@ -162,6 +165,7 @@ export function adaptSandboxEntry( title: toolEntry.title, description: toolEntry.description, route: toolEntry.route, + inputSchema: toolEntry.inputSchema, // 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"], }; @@ -206,6 +210,26 @@ export function adaptSandboxEntry( capabilities.push("network:request"); } + if (Object.keys(mcpTools).length > 0 && !capabilities.includes("mcp:tools")) { + throw new Error( + `Plugin "${pluginId}" declares MCP tools but is missing the "mcp:tools" capability.`, + ); + } + for (const [toolName, tool] of Object.entries(mcpTools)) { + if (!MCP_TOOL_NAME_PATTERN.test(toolName)) { + throw new Error( + `Invalid MCP tool name "${toolName}" in plugin "${pluginId}". Must be lowercase snake_case and must not contain double underscores.`, + ); + } + + const routeName = tool.route.replace(LEADING_SLASH_PATTERN, ""); + if (!(routeName in resolvedRoutes)) { + throw new Error( + `Invalid MCP tool route "${tool.route}" in plugin "${pluginId}". MCP tool routes must be declared in routes.`, + ); + } + } + // Build storage config from descriptor. // StorageCollectionDeclaration uses optional indexes, but PluginStorageConfig // requires them. Ensure every collection has an indexes array. diff --git a/packages/core/src/plugins/define-plugin.ts b/packages/core/src/plugins/define-plugin.ts index 676791a9e..d08636483 100644 --- a/packages/core/src/plugins/define-plugin.ts +++ b/packages/core/src/plugins/define-plugin.ts @@ -30,6 +30,8 @@ import type { const SIMPLE_ID = /^[a-z0-9-]+$/; const SCOPED_ID = /^@[a-z0-9-]+\/[a-z0-9-]+$/; const SEMVER_PATTERN = /^\d+\.\d+\.\d+/; +const MCP_TOOL_NAME_PATTERN = /^(?!.*__)[a-z][a-z0-9_]*$/; +const LEADING_SLASH_PATTERN = /^\/+/; /** * Define an EmDash plugin. @@ -102,6 +104,7 @@ export function definePlugin( "For native format, provide `id` and `version`.", ); } + validateStandardMcpTools(definition); // Identity function -- return as-is for type inference. // The adapter (adaptSandboxEntry) will convert this to a ResolvedPlugin at build time. return definition; @@ -110,6 +113,24 @@ export function definePlugin( return defineNativePlugin(definition); } +function validateStandardMcpTools(definition: StandardPluginDefinition): void { + const routes = definition.routes ?? {}; + for (const [toolName, tool] of Object.entries(definition.mcpTools ?? {})) { + if (!MCP_TOOL_NAME_PATTERN.test(toolName)) { + throw new Error( + `Invalid MCP tool name "${toolName}". Must be lowercase snake_case and must not contain double underscores.`, + ); + } + + const routeName = tool.route.replace(LEADING_SLASH_PATTERN, ""); + if (!(routeName in routes)) { + throw new Error( + `Invalid MCP tool route "${tool.route}". MCP tool routes must be declared in routes.`, + ); + } + } +} + /** * Internal: define a native-format plugin with full validation and normalization. */ @@ -205,6 +226,25 @@ function defineNativePlugin( normalizedCapabilities.push("network:request"); } + const mcpToolEntries = Object.entries(mcpTools); + if (mcpToolEntries.length > 0 && !normalizedCapabilities.includes("mcp:tools")) { + throw new Error(`Plugin "${id}" declares MCP tools but is missing the "mcp:tools" capability.`); + } + for (const [toolName, tool] of mcpToolEntries) { + if (!MCP_TOOL_NAME_PATTERN.test(toolName)) { + throw new Error( + `Invalid MCP tool name "${toolName}" in plugin "${id}". Must be lowercase snake_case and must not contain double underscores.`, + ); + } + + const routeName = tool.route.replace(LEADING_SLASH_PATTERN, ""); + if (!(routeName in routes)) { + throw new Error( + `Invalid MCP tool route "${tool.route}" in plugin "${id}". MCP tool routes must be declared in routes.`, + ); + } + } + // Normalize hooks const resolvedHooks = resolveHooks(hooks, id); diff --git a/packages/core/src/plugins/manifest-schema.ts b/packages/core/src/plugins/manifest-schema.ts index 7eb7a35d3..99e3ef8b1 100644 --- a/packages/core/src/plugins/manifest-schema.ts +++ b/packages/core/src/plugins/manifest-schema.ts @@ -130,13 +130,100 @@ const manifestRouteEntrySchema = z.object({ public: z.boolean().optional(), }); -const mcpToolNamePattern = /^[a-z][a-z0-9_]*$/; +const mcpToolNamePattern = /^(?!.*__)[a-z][a-z0-9_]*$/; + +const jsonSchemaPattern = z.string().refine( + (pattern) => { + try { + RegExp(pattern); + return true; + } catch { + return false; + } + }, + { message: "Pattern must be a valid regular expression" }, +); + +const manifestJsonSchemaBase = z.object({ + title: z.string().min(1).optional(), + description: z.string().min(1).optional(), + default: z.unknown().optional(), +}); + +const manifestJsonSchema: z.ZodType = z.lazy(() => + z.discriminatedUnion("type", [ + manifestJsonSchemaBase + .extend({ + type: z.literal("string"), + enum: z.array(z.string()).min(1).optional(), + format: z.enum(["date-time", "email", "uri", "uuid"]).optional(), + minLength: z.number().int().nonnegative().optional(), + maxLength: z.number().int().nonnegative().optional(), + pattern: jsonSchemaPattern.optional(), + }) + .strict(), + manifestJsonSchemaBase + .extend({ + type: z.literal("number"), + enum: z.array(z.number()).min(1).optional(), + minimum: z.number().optional(), + maximum: z.number().optional(), + }) + .strict(), + manifestJsonSchemaBase + .extend({ + type: z.literal("integer"), + enum: z.array(z.number().int()).min(1).optional(), + minimum: z.number().optional(), + maximum: z.number().optional(), + }) + .strict(), + manifestJsonSchemaBase + .extend({ + type: z.literal("boolean"), + enum: z.array(z.boolean()).min(1).optional(), + }) + .strict(), + manifestJsonSchemaBase + .extend({ + type: z.literal("array"), + items: manifestJsonSchema, + minItems: z.number().int().nonnegative().optional(), + maxItems: z.number().int().nonnegative().optional(), + }) + .strict(), + manifestJsonSchemaBase + .extend({ + type: z.literal("object"), + properties: z.record(z.string(), manifestJsonSchema).optional(), + required: z.array(z.string()).optional(), + additionalProperties: z.boolean().optional(), + }) + .strict(), + ]), +); + +const manifestJsonObjectSchema = manifestJsonSchemaBase + .extend({ + type: z.literal("object"), + properties: z.record(z.string(), manifestJsonSchema).optional(), + required: z.array(z.string()).optional(), + additionalProperties: z.boolean().optional(), + }) + .strict(); const manifestMcpToolEntrySchema = z.object({ - name: z.string().min(1).regex(mcpToolNamePattern, "MCP tool name must be lowercase snake_case"), + name: z + .string() + .min(1) + .regex( + mcpToolNamePattern, + "MCP tool name must be lowercase snake_case and must not contain double underscores", + ), title: z.string().min(1).optional(), description: z.string().min(1), route: z.string().min(1).regex(routeNamePattern, "Route name must be a safe path segment"), + inputSchema: manifestJsonObjectSchema.optional(), }); // ── Sub-schemas ───────────────────────────────────────────────── @@ -236,32 +323,55 @@ const pluginAdminConfigSchema = z.object({ * * Every JSON.parse of a manifest.json should validate through this. */ -export const pluginManifestSchema = z.object({ - id: z.string().min(1), - version: z.string().min(1), - capabilities: z.array(z.enum(PLUGIN_CAPABILITIES)), - allowedHosts: z.array(z.string()), - storage: z.record(z.string(), storageCollectionSchema), - /** - * Hook declarations — accepts both plain name strings (legacy) and - * structured objects with exclusive/priority/timeout metadata. - * Plain strings are normalized to `{ name }` objects after parsing. - */ - hooks: z.array(z.union([z.enum(HOOK_NAMES), manifestHookEntrySchema])), - /** - * Route declarations — accepts both plain name strings and - * structured objects with public metadata. - * Plain strings are normalized to `{ name }` objects after parsing. - */ - routes: z.array( - z.union([ - z.string().min(1).regex(routeNamePattern, "Route name must be a safe path segment"), - manifestRouteEntrySchema, - ]), - ), - mcpTools: z.array(manifestMcpToolEntrySchema).optional().default([]), - admin: pluginAdminConfigSchema, -}); +export const pluginManifestSchema = z + .object({ + id: z.string().min(1), + version: z.string().min(1), + capabilities: z.array(z.enum(PLUGIN_CAPABILITIES)), + allowedHosts: z.array(z.string()), + storage: z.record(z.string(), storageCollectionSchema), + /** + * Hook declarations — accepts both plain name strings (legacy) and + * structured objects with exclusive/priority/timeout metadata. + * Plain strings are normalized to `{ name }` objects after parsing. + */ + hooks: z.array(z.union([z.enum(HOOK_NAMES), manifestHookEntrySchema])), + /** + * Route declarations — accepts both plain name strings and + * structured objects with public metadata. + * Plain strings are normalized to `{ name }` objects after parsing. + */ + routes: z.array( + z.union([ + z.string().min(1).regex(routeNamePattern, "Route name must be a safe path segment"), + manifestRouteEntrySchema, + ]), + ), + mcpTools: z.array(manifestMcpToolEntrySchema).optional().default([]), + admin: pluginAdminConfigSchema, + }) + .superRefine((manifest, ctx) => { + if (manifest.mcpTools.length === 0) return; + + if (!manifest.capabilities.includes("mcp:tools")) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["capabilities"], + message: 'Manifest with MCP tools must include the "mcp:tools" capability', + }); + } + + const routeNames = new Set(manifest.routes.map((route) => normalizeManifestRoute(route).name)); + for (const [index, tool] of manifest.mcpTools.entries()) { + if (!routeNames.has(tool.route)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["mcpTools", index, "route"], + message: "MCP tool route must be declared in routes", + }); + } + } + }); export type ValidatedPluginManifest = z.infer; diff --git a/packages/core/src/plugins/types.ts b/packages/core/src/plugins/types.ts index f2b5544d9..0264afe68 100644 --- a/packages/core/src/plugins/types.ts +++ b/packages/core/src/plugins/types.ts @@ -25,7 +25,7 @@ import { type CurrentPluginCapability, type DeprecatedPluginCapability, type ManifestHookEntry, - type ManifestMcpToolEntry, + type ManifestMcpToolEntry as SharedManifestMcpToolEntry, type ManifestRouteEntry, type PluginCapability, type PluginStorageConfig, @@ -47,13 +47,65 @@ export { type CurrentPluginCapability, type DeprecatedPluginCapability, type ManifestHookEntry, - type ManifestMcpToolEntry, type ManifestRouteEntry, type PluginCapability, type PluginStorageConfig, type StorageCollectionConfig, }; +export interface ManifestJsonSchemaBase { + title?: string; + description?: string; + default?: unknown; +} + +export interface ManifestJsonStringSchema extends ManifestJsonSchemaBase { + type: "string"; + enum?: string[]; + format?: "date-time" | "email" | "uri" | "uuid"; + minLength?: number; + maxLength?: number; + pattern?: string; +} + +export interface ManifestJsonNumberSchema extends ManifestJsonSchemaBase { + type: "number" | "integer"; + enum?: number[]; + minimum?: number; + maximum?: number; +} + +export interface ManifestJsonBooleanSchema extends ManifestJsonSchemaBase { + type: "boolean"; + enum?: boolean[]; +} + +export interface ManifestJsonArraySchema extends ManifestJsonSchemaBase { + type: "array"; + items: ManifestJsonSchema; + minItems?: number; + maxItems?: number; +} + +export interface ManifestJsonObjectSchema extends ManifestJsonSchemaBase { + type: "object"; + properties?: Record; + required?: string[]; + additionalProperties?: boolean; +} + +export type ManifestJsonSchema = + | ManifestJsonStringSchema + | ManifestJsonNumberSchema + | ManifestJsonBooleanSchema + | ManifestJsonArraySchema + | ManifestJsonObjectSchema; + +export interface ManifestMcpToolEntry extends SharedManifestMcpToolEntry { + /** JSON Schema object used for MCP input validation and client introspection. */ + inputSchema?: ManifestJsonObjectSchema; +} + // ============================================================================= // Storage Types // ============================================================================= @@ -1093,6 +1145,8 @@ export interface PluginMcpTool { route: string; /** Optional Zod schema for MCP argument validation */ input?: z.ZodType>; + /** Optional JSON Schema object for manifest output and MCP introspection */ + inputSchema?: ManifestJsonObjectSchema; } export interface PluginMcpToolRegistration { @@ -1102,6 +1156,7 @@ export interface PluginMcpToolRegistration { description: string; route: string; input?: z.ZodType>; + inputSchema?: ManifestJsonObjectSchema; } // ============================================================================= @@ -1388,6 +1443,7 @@ export interface StandardMcpToolEntry { description: string; route: string; input?: unknown; + inputSchema?: ManifestJsonObjectSchema; } /** diff --git a/packages/core/tests/integration/mcp/plugin-tools.test.ts b/packages/core/tests/integration/mcp/plugin-tools.test.ts index 2b9deb91b..03b005ba6 100644 --- a/packages/core/tests/integration/mcp/plugin-tools.test.ts +++ b/packages/core/tests/integration/mcp/plugin-tools.test.ts @@ -15,10 +15,24 @@ import { import { setupTestDatabaseWithCollections, teardownTestDatabase } from "../../utils/test-db.js"; 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}`; + const readableSegments = pluginId + .replace(/^@/, "") + .split("/") + .map((segment) => segment.replace(/-/g, "_")); + const readablePluginId = readableSegments.join("__"); + if (readableSegments.some((segment) => /__/.test(segment))) { + return `${readablePluginId}__${stableToolNameHash(pluginId)}__${toolName}`; + } + return `${readablePluginId}__${toolName}`; +} + +function stableToolNameHash(value: string): string { + let hash = 0x811c9dc5; + for (let index = 0; index < value.length; index++) { + hash ^= value.charCodeAt(index); + hash = Math.imul(hash, 0x01000193); + } + return (hash >>> 0).toString(16).padStart(8, "0"); } function createPluginWithMcpCapability( @@ -46,16 +60,31 @@ function createPluginWithMcpCapability( description: "Summarize text with the test plugin.", route: "summarize", input: summarizeInput, + inputSchema: { + type: "object", + properties: { + text: { + type: "string", + description: "Text to summarize.", + minLength: 1, + }, + }, + required: ["text"], + additionalProperties: false, + }, }, }, }); } function createPluginWithoutMcpCapability(): ResolvedPlugin { - return definePlugin({ + return { id: "route-only-plugin", version: "1.0.0", capabilities: [], + allowedHosts: [], + storage: {}, + hooks: {}, routes: { summarize: { handler: async () => ({ summary: "hidden" }), @@ -67,6 +96,41 @@ function createPluginWithoutMcpCapability(): ResolvedPlugin { route: "summarize", }, }, + admin: {}, + }; +} + +function createPluginWithJsonInputSchema(): ResolvedPlugin { + return definePlugin({ + id: "json-schema-plugin", + version: "1.0.0", + capabilities: ["mcp:tools"], + routes: { + summarize: { + handler: async ({ input }) => { + return { input }; + }, + }, + }, + mcpTools: { + summarize: { + title: "Summarize Text", + description: "Summarize text with the test plugin.", + route: "summarize", + inputSchema: { + type: "object", + properties: { + text: { + type: "string", + description: "Text to summarize.", + minLength: 1, + }, + }, + required: ["text"], + additionalProperties: false, + }, + }, + }, }); } @@ -81,12 +145,16 @@ describe("plugin MCP tools", () => { dbCleanup = undefined; }); - async function connectWithPlugins(plugins: ResolvedPlugin[], tokenScopes?: string[]) { + async function connectWithPlugins( + plugins: ResolvedPlugin[], + tokenScopes?: string[], + userRole = Role.ADMIN, + ) { const db = await setupTestDatabaseWithCollections(); harness = await connectMcpHarness({ db, userId: "user_admin", - userRole: Role.ADMIN, + userRole, tokenScopes, runtimeOptions: { plugins }, }); @@ -126,10 +194,10 @@ describe("plugin MCP tools", () => { it("keeps plugin tool names distinct for colliding readable plugin IDs", async () => { const scopedName = pluginToolName("@foo/bar", "summarize"); - const dashedName = pluginToolName("foo-bar", "summarize"); + const dashedName = pluginToolName("foo--bar", "summarize"); const connected = await connectWithPlugins([ createPluginWithMcpCapability("@foo/bar", (text) => `scoped:${text}`), - createPluginWithMcpCapability("foo-bar", (text) => `dashed:${text}`), + createPluginWithMcpCapability("foo--bar", (text) => `dashed:${text}`), ]); const tools = await connected.client.listTools(); @@ -152,6 +220,52 @@ describe("plugin MCP tools", () => { expect(extractJson(dashedResult)).toEqual({ summary: "dashed:hello" }); }); + it("uses readable double-underscore names for scoped plugin IDs", async () => { + const connected = await connectWithPlugins([ + createPluginWithMcpCapability("@emdash-cms/plugin-forms"), + ]); + + const tools = await connected.client.listTools(); + const toolNames = tools.tools.map((tool) => tool.name); + + expect(toolNames).toContain("emdash_cms__plugin_forms__summarize"); + }); + + it("exposes JSON input schemas for plugin tools", async () => { + const connected = await connectWithPlugins([createPluginWithJsonInputSchema()]); + + const tools = await connected.client.listTools(); + const tool = tools.tools.find( + (entry) => entry.name === pluginToolName("json-schema-plugin", "summarize"), + ); + + expect(tool?.inputSchema).toMatchObject({ + type: "object", + properties: { + text: { + type: "string", + description: "Text to summarize.", + minLength: 1, + }, + }, + required: ["text"], + additionalProperties: false, + }); + }); + + it("validates plugin tool calls with JSON input schemas", async () => { + const connected = await connectWithPlugins([createPluginWithJsonInputSchema()]); + + const result = await connected.client.callTool({ + name: pluginToolName("json-schema-plugin", "summarize"), + arguments: {}, + }); + + expect(isErrorResult(result)).toBe(true); + expect(extractText(result)).toContain("Input validation error"); + expect(extractText(result)).toContain("text"); + }); + it("does not list sandboxed plugin tools when the plugin is not loaded", async () => { const db = await setupTestDatabaseWithCollections(); dbCleanup = () => teardownTestDatabase(db); @@ -180,6 +294,77 @@ describe("plugin MCP tools", () => { expect(runtime.getPluginMcpTools()).toEqual([]); }); + it("exposes JSON input schemas for sandboxed plugin tools", async () => { + const db = await setupTestDatabaseWithCollections(); + dbCleanup = () => teardownTestDatabase(db); + const connected = await connectMcpHarness({ + db, + userId: "user_admin", + userRole: Role.ADMIN, + runtimeOptions: { + enabledPluginIds: ["sandbox-plugin"], + sandboxedPlugins: new Map([ + [ + "sandbox-plugin:1.0.0", + { + id: "sandbox-plugin:1.0.0", + invokeHook: async () => undefined, + invokeRoute: async () => ({ ok: true }), + terminate: async () => undefined, + }, + ], + ]), + sandboxedPluginEntries: [ + { + id: "sandbox-plugin", + version: "1.0.0", + options: {}, + code: "export default {};", + capabilities: ["mcp:tools"], + allowedHosts: [], + storage: {}, + mcpTools: [ + { + name: "summarize", + description: "Summarize text.", + route: "summarize", + inputSchema: { + type: "object", + properties: { + text: { + type: "string", + description: "Text to summarize.", + }, + }, + required: ["text"], + additionalProperties: false, + }, + }, + ], + }, + ], + }, + }); + harness = connected; + + const tools = await connected.client.listTools(); + const tool = tools.tools.find( + (entry) => entry.name === pluginToolName("sandbox-plugin", "summarize"), + ); + + expect(tool?.inputSchema).toMatchObject({ + type: "object", + properties: { + text: { + type: "string", + description: "Text to summarize.", + }, + }, + required: ["text"], + additionalProperties: false, + }); + }); + it("requires admin token scope to call plugin tools", async () => { const connected = await connectWithPlugins([createPluginWithMcpCapability()], ["content:read"]); @@ -191,4 +376,20 @@ describe("plugin MCP tools", () => { expect(isErrorResult(result)).toBe(true); expect(extractText(result)).toContain("[INSUFFICIENT_SCOPE]"); }); + + it("requires ADMIN role to call plugin tools even with admin token scope", async () => { + const connected = await connectWithPlugins( + [createPluginWithMcpCapability()], + ["admin"], + Role.EDITOR, + ); + + const result = await connected.client.callTool({ + name: pluginToolName("test-plugin", "summarize"), + arguments: { text: "hello" }, + }); + + expect(isErrorResult(result)).toBe(true); + expect(extractText(result)).toContain("[INSUFFICIENT_PERMISSIONS]"); + }); }); diff --git a/packages/core/tests/unit/cli/bundle-utils.test.ts b/packages/core/tests/unit/cli/bundle-utils.test.ts index 79a8c3d47..cb89504ac 100644 --- a/packages/core/tests/unit/cli/bundle-utils.test.ts +++ b/packages/core/tests/unit/cli/bundle-utils.test.ts @@ -92,6 +92,13 @@ describe("extractManifest", () => { title: "Summarize", description: "Summarize text.", route: "summarize", + inputSchema: { + type: "object", + properties: { + text: { type: "string" }, + }, + required: ["text"], + }, }, }, }); @@ -104,6 +111,13 @@ describe("extractManifest", () => { title: "Summarize", description: "Summarize text.", route: "summarize", + inputSchema: { + type: "object", + properties: { + text: { type: "string" }, + }, + required: ["text"], + }, }, ]); }); diff --git a/packages/core/tests/unit/plugins/adapt-sandbox-entry.test.ts b/packages/core/tests/unit/plugins/adapt-sandbox-entry.test.ts index 4c6c79269..cbc10c657 100644 --- a/packages/core/tests/unit/plugins/adapt-sandbox-entry.test.ts +++ b/packages/core/tests/unit/plugins/adapt-sandbox-entry.test.ts @@ -89,6 +89,13 @@ describe("adaptSandboxEntry", () => { title: "Summarize", description: "Summarize text.", route: "summarize", + inputSchema: { + type: "object", + properties: { + text: { type: "string" }, + }, + required: ["text"], + }, }, }, }; @@ -101,6 +108,13 @@ describe("adaptSandboxEntry", () => { title: "Summarize", description: "Summarize text.", route: "summarize", + inputSchema: { + type: "object", + properties: { + text: { type: "string" }, + }, + required: ["text"], + }, }, }); }); @@ -121,6 +135,13 @@ describe("adaptSandboxEntry", () => { title: "Summarize", description: "Summarize text.", route: "summarize", + inputSchema: { + type: "object", + properties: { + text: { type: "string" }, + }, + required: ["text"], + }, }, ], }); @@ -132,10 +153,57 @@ describe("adaptSandboxEntry", () => { title: "Summarize", description: "Summarize text.", route: "summarize", + inputSchema: { + type: "object", + properties: { + text: { type: "string" }, + }, + required: ["text"], + }, }, }); }); + it("rejects MCP tools without the mcp:tools capability", () => { + const def: StandardPluginDefinition = { + routes: { + summarize: { + handler: async () => ({ ok: true }), + }, + }, + mcpTools: { + summarize: { + description: "Summarize text.", + route: "summarize", + }, + }, + }; + const descriptor = createDescriptor(); + + expect(() => adaptSandboxEntry(def, descriptor)).toThrow( + /missing the "mcp:tools" capability/, + ); + }); + + it("rejects MCP tools that reference undeclared routes", () => { + const def: StandardPluginDefinition = { + routes: { + other: { + handler: async () => ({ ok: true }), + }, + }, + mcpTools: { + summarize: { + description: "Summarize text.", + route: "summarize", + }, + }, + }; + const descriptor = createDescriptor({ capabilities: ["mcp:tools"] }); + + expect(() => adaptSandboxEntry(def, descriptor)).toThrow(/MCP tool routes must be declared/); + }); + it("carries storage config from descriptor", () => { const def: StandardPluginDefinition = {}; const descriptor = createDescriptor({ diff --git a/packages/core/tests/unit/plugins/define-plugin.test.ts b/packages/core/tests/unit/plugins/define-plugin.test.ts index 010b0ac68..a235530d0 100644 --- a/packages/core/tests/unit/plugins/define-plugin.test.ts +++ b/packages/core/tests/unit/plugins/define-plugin.test.ts @@ -17,6 +17,9 @@ import { definePlugin } from "../../../src/plugins/define-plugin.js"; const INVALID_PLUGIN_ID_PATTERN = /Invalid plugin id/; const INVALID_PLUGIN_VERSION_PATTERN = /Invalid plugin version/; const INVALID_CAPABILITY_PATTERN = /Invalid capability/; +const INVALID_MCP_TOOL_NAME_PATTERN = /Invalid MCP tool name/; +const INVALID_MCP_TOOL_ROUTE_PATTERN = /Invalid MCP tool route/; +const MISSING_MCP_CAPABILITY_PATTERN = /missing the "mcp:tools" capability/; describe("definePlugin", () => { describe("ID validation", () => { @@ -508,6 +511,120 @@ describe("definePlugin", () => { }); }); + describe("MCP tools", () => { + it("validates standard-format MCP tool names", () => { + expect(() => + definePlugin({ + routes: { + summarize: { + handler: async () => ({ ok: true }), + }, + }, + mcpTools: { + bad__name: { + description: "Summarize text.", + route: "summarize", + }, + }, + }), + ).toThrow(INVALID_MCP_TOOL_NAME_PATTERN); + }); + + it("validates standard-format MCP tool routes", () => { + expect(() => + definePlugin({ + routes: { + other: { + handler: async () => ({ ok: true }), + }, + }, + mcpTools: { + summarize: { + description: "Summarize text.", + route: "summarize", + }, + }, + }), + ).toThrow(INVALID_MCP_TOOL_ROUTE_PATTERN); + }); + + it("preserves MCP tool definitions", () => { + const handler = vi.fn(); + const plugin = definePlugin({ + id: "test", + version: "1.0.0", + capabilities: ["mcp:tools"], + routes: { + summarize: { handler }, + }, + mcpTools: { + summarize: { + description: "Summarize text.", + route: "summarize", + }, + }, + }); + + expect(plugin.mcpTools?.summarize?.route).toBe("summarize"); + }); + + it("rejects MCP tools without the mcp:tools capability", () => { + expect(() => + definePlugin({ + id: "test", + version: "1.0.0", + routes: { + summarize: { handler: vi.fn() }, + }, + mcpTools: { + summarize: { + description: "Summarize text.", + route: "summarize", + }, + }, + }), + ).toThrow(MISSING_MCP_CAPABILITY_PATTERN); + }); + + it("rejects invalid MCP tool names", () => { + expect(() => + definePlugin({ + id: "test", + version: "1.0.0", + capabilities: ["mcp:tools"], + routes: { + summarize: { handler: vi.fn() }, + }, + mcpTools: { + bad__name: { + description: "Summarize text.", + route: "summarize", + }, + }, + }), + ).toThrow(INVALID_MCP_TOOL_NAME_PATTERN); + }); + + it("rejects MCP tools that reference undeclared routes", () => { + expect(() => + definePlugin({ + id: "test", + version: "1.0.0", + capabilities: ["mcp:tools"], + routes: { + other: { handler: vi.fn() }, + }, + mcpTools: { + summarize: { + description: "Summarize text.", + route: "summarize", + }, + }, + }), + ).toThrow(INVALID_MCP_TOOL_ROUTE_PATTERN); + }); + }); + describe("admin passthrough", () => { it("preserves admin config", () => { const plugin = definePlugin({ diff --git a/packages/core/tests/unit/plugins/manifest-schema.test.ts b/packages/core/tests/unit/plugins/manifest-schema.test.ts index 763180afd..ea2e91e40 100644 --- a/packages/core/tests/unit/plugins/manifest-schema.test.ts +++ b/packages/core/tests/unit/plugins/manifest-schema.test.ts @@ -110,12 +110,25 @@ describe("pluginManifestSchema — MCP tool entries", () => { const result = pluginManifestSchema.safeParse({ ...makeManifest({}), capabilities: ["mcp:tools"], + routes: ["tools/summarize"], mcpTools: [ { name: "summarize", title: "Summarize Text", description: "Summarize content using the plugin.", route: "tools/summarize", + inputSchema: { + type: "object", + properties: { + text: { + type: "string", + description: "Text to summarize.", + minLength: 1, + }, + }, + required: ["text"], + additionalProperties: false, + }, }, ], }); @@ -128,6 +141,18 @@ describe("pluginManifestSchema — MCP tool entries", () => { title: "Summarize Text", description: "Summarize content using the plugin.", route: "tools/summarize", + inputSchema: { + type: "object", + properties: { + text: { + type: "string", + description: "Text to summarize.", + minLength: 1, + }, + }, + required: ["text"], + additionalProperties: false, + }, }, ]); } @@ -136,6 +161,8 @@ describe("pluginManifestSchema — MCP tool entries", () => { it("should reject MCP tool names outside lowercase snake_case", () => { const result = pluginManifestSchema.safeParse({ ...makeManifest({}), + capabilities: ["mcp:tools"], + routes: ["tools/summarize"], mcpTools: [ { name: "Summarize", @@ -147,6 +174,78 @@ describe("pluginManifestSchema — MCP tool entries", () => { expect(result.success).toBe(false); }); + + it("should reject MCP tool names with double underscores", () => { + const result = pluginManifestSchema.safeParse({ + ...makeManifest({}), + capabilities: ["mcp:tools"], + routes: ["tools/summarize"], + mcpTools: [ + { + name: "bad__name", + description: "Invalid tool name.", + route: "tools/summarize", + }, + ], + }); + + expect(result.success).toBe(false); + }); + + it("should reject MCP tools without the mcp:tools capability", () => { + const result = pluginManifestSchema.safeParse({ + ...makeManifest({}), + routes: ["tools/summarize"], + mcpTools: [ + { + name: "summarize", + description: "Summarize text.", + route: "tools/summarize", + }, + ], + }); + + expect(result.success).toBe(false); + }); + + it("should reject MCP tools that reference undeclared routes", () => { + const result = pluginManifestSchema.safeParse({ + ...makeManifest({}), + capabilities: ["mcp:tools"], + routes: ["tools/other"], + mcpTools: [ + { + name: "summarize", + description: "Summarize text.", + route: "tools/summarize", + }, + ], + }); + + expect(result.success).toBe(false); + }); + + it("should reject unsupported JSON Schema keywords in MCP input schemas", () => { + const result = pluginManifestSchema.safeParse({ + ...makeManifest({}), + capabilities: ["mcp:tools"], + routes: ["tools/summarize"], + mcpTools: [ + { + name: "summarize", + description: "Summarize text.", + route: "tools/summarize", + inputSchema: { + type: "object", + properties: {}, + $ref: "#/$defs/input", + }, + }, + ], + }); + + expect(result.success).toBe(false); + }); }); describe("normalizeManifestRoute", () => { diff --git a/packages/plugin-types/src/index.ts b/packages/plugin-types/src/index.ts index 310568826..bdfb68adc 100644 --- a/packages/plugin-types/src/index.ts +++ b/packages/plugin-types/src/index.ts @@ -186,6 +186,54 @@ export interface ManifestRouteEntry { public?: boolean; } +export interface ManifestJsonSchemaBase { + title?: string; + description?: string; + default?: unknown; +} + +export interface ManifestJsonStringSchema extends ManifestJsonSchemaBase { + type: "string"; + enum?: string[]; + format?: "date-time" | "email" | "uri" | "uuid"; + minLength?: number; + maxLength?: number; + pattern?: string; +} + +export interface ManifestJsonNumberSchema extends ManifestJsonSchemaBase { + type: "number" | "integer"; + enum?: number[]; + minimum?: number; + maximum?: number; +} + +export interface ManifestJsonBooleanSchema extends ManifestJsonSchemaBase { + type: "boolean"; + enum?: boolean[]; +} + +export interface ManifestJsonArraySchema extends ManifestJsonSchemaBase { + type: "array"; + items: ManifestJsonSchema; + minItems?: number; + maxItems?: number; +} + +export interface ManifestJsonObjectSchema extends ManifestJsonSchemaBase { + type: "object"; + properties?: Record; + required?: string[]; + additionalProperties?: boolean; +} + +export type ManifestJsonSchema = + | ManifestJsonStringSchema + | ManifestJsonNumberSchema + | ManifestJsonBooleanSchema + | ManifestJsonArraySchema + | ManifestJsonObjectSchema; + /** * MCP tool entry in a plugin manifest. * @@ -198,6 +246,8 @@ export interface ManifestMcpToolEntry { title?: string; description: string; route: string; + /** JSON Schema object used for MCP input validation and client introspection. */ + inputSchema?: ManifestJsonObjectSchema; } /** diff --git a/packages/registry-cli/src/bundle/types.ts b/packages/registry-cli/src/bundle/types.ts index 8ed0960e7..6fe46e791 100644 --- a/packages/registry-cli/src/bundle/types.ts +++ b/packages/registry-cli/src/bundle/types.ts @@ -33,6 +33,54 @@ import type { PluginStorageConfig, } from "@emdash-cms/plugin-types"; +export interface ManifestJsonSchemaBase { + title?: string; + description?: string; + default?: unknown; +} + +export interface ManifestJsonStringSchema extends ManifestJsonSchemaBase { + type: "string"; + enum?: string[]; + format?: "date-time" | "email" | "uri" | "uuid"; + minLength?: number; + maxLength?: number; + pattern?: string; +} + +export interface ManifestJsonNumberSchema extends ManifestJsonSchemaBase { + type: "number" | "integer"; + enum?: number[]; + minimum?: number; + maximum?: number; +} + +export interface ManifestJsonBooleanSchema extends ManifestJsonSchemaBase { + type: "boolean"; + enum?: boolean[]; +} + +export interface ManifestJsonArraySchema extends ManifestJsonSchemaBase { + type: "array"; + items: ManifestJsonSchema; + minItems?: number; + maxItems?: number; +} + +export interface ManifestJsonObjectSchema extends ManifestJsonSchemaBase { + type: "object"; + properties?: Record; + required?: string[]; + additionalProperties?: boolean; +} + +export type ManifestJsonSchema = + | ManifestJsonStringSchema + | ManifestJsonNumberSchema + | ManifestJsonBooleanSchema + | ManifestJsonArraySchema + | ManifestJsonObjectSchema; + /** * 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 @@ -71,6 +119,7 @@ export interface ResolvedPlugin { title?: string; description: string; route: string; + inputSchema?: ManifestJsonObjectSchema; } >; admin: PluginAdminConfig; diff --git a/packages/registry-cli/src/bundle/utils.ts b/packages/registry-cli/src/bundle/utils.ts index f1c31184b..98c6e4aeb 100644 --- a/packages/registry-cli/src/bundle/utils.ts +++ b/packages/registry-cli/src/bundle/utils.ts @@ -157,6 +157,7 @@ export function extractManifest(plugin: ResolvedPlugin): PluginManifest { 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) diff --git a/packages/registry-cli/tests/bundle-utils.test.ts b/packages/registry-cli/tests/bundle-utils.test.ts index 679a49c25..493db95c6 100644 --- a/packages/registry-cli/tests/bundle-utils.test.ts +++ b/packages/registry-cli/tests/bundle-utils.test.ts @@ -80,6 +80,13 @@ describe("extractManifest", () => { title: "Summarize", description: "Summarize text.", route: "summarize", + inputSchema: { + type: "object", + properties: { + text: { type: "string" }, + }, + required: ["text"], + }, }, }, }), @@ -91,6 +98,13 @@ describe("extractManifest", () => { title: "Summarize", description: "Summarize text.", route: "summarize", + inputSchema: { + type: "object", + properties: { + text: { type: "string" }, + }, + required: ["text"], + }, }, ]); }); From 557aa1e86e1f7520ff728721e530d2e694e0dc12 Mon Sep 17 00:00:00 2001 From: Mason James Date: Tue, 12 May 2026 21:56:21 -0400 Subject: [PATCH 3/5] Fix manifest schema projection validation --- packages/core/src/cli/commands/publish.ts | 4 +- packages/core/src/plugins/manifest-schema.ts | 94 ++++++++++--------- .../unit/plugins/manifest-schema.test.ts | 19 ++++ 3 files changed, 71 insertions(+), 46 deletions(-) diff --git a/packages/core/src/cli/commands/publish.ts b/packages/core/src/cli/commands/publish.ts index 6d54e6dc1..ac8531b2d 100644 --- a/packages/core/src/cli/commands/publish.ts +++ b/packages/core/src/cli/commands/publish.ts @@ -20,7 +20,7 @@ import consola from "consola"; import { createGzipDecoder, unpackTar } from "modern-tar"; import pc from "picocolors"; -import { pluginManifestSchema } from "../../plugins/manifest-schema.js"; +import { pluginManifestBaseSchema } from "../../plugins/manifest-schema.js"; import { CAPABILITY_RENAMES, isDeprecatedCapability } from "../../plugins/types.js"; import { getMarketplaceCredential, @@ -201,7 +201,7 @@ async function pollGitHubDeviceFlow( // ── Tarball reading ───────────────────────────────────────────── -const manifestSummarySchema = pluginManifestSchema.pick({ +const manifestSummarySchema = pluginManifestBaseSchema.pick({ id: true, version: true, capabilities: true, diff --git a/packages/core/src/plugins/manifest-schema.ts b/packages/core/src/plugins/manifest-schema.ts index 99e3ef8b1..5026b54ce 100644 --- a/packages/core/src/plugins/manifest-schema.ts +++ b/packages/core/src/plugins/manifest-schema.ts @@ -318,60 +318,66 @@ const pluginAdminConfigSchema = z.object({ // ── Main schema ───────────────────────────────────────────────── +/** + * Refinement-free manifest object schema. + * + * Use this for projections such as `.pick()` where Zod cannot operate on + * refined schemas. Full manifest parsing should use `pluginManifestSchema`. + */ +export const pluginManifestBaseSchema = z.object({ + id: z.string().min(1), + version: z.string().min(1), + capabilities: z.array(z.enum(PLUGIN_CAPABILITIES)), + allowedHosts: z.array(z.string()), + storage: z.record(z.string(), storageCollectionSchema), + /** + * Hook declarations — accepts both plain name strings (legacy) and + * structured objects with exclusive/priority/timeout metadata. + * Plain strings are normalized to `{ name }` objects after parsing. + */ + hooks: z.array(z.union([z.enum(HOOK_NAMES), manifestHookEntrySchema])), + /** + * Route declarations — accepts both plain name strings and + * structured objects with public metadata. + * Plain strings are normalized to `{ name }` objects after parsing. + */ + routes: z.array( + z.union([ + z.string().min(1).regex(routeNamePattern, "Route name must be a safe path segment"), + manifestRouteEntrySchema, + ]), + ), + mcpTools: z.array(manifestMcpToolEntrySchema).optional().default([]), + admin: pluginAdminConfigSchema, +}); + /** * Zod schema matching the PluginManifest interface from types.ts. * * Every JSON.parse of a manifest.json should validate through this. */ -export const pluginManifestSchema = z - .object({ - id: z.string().min(1), - version: z.string().min(1), - capabilities: z.array(z.enum(PLUGIN_CAPABILITIES)), - allowedHosts: z.array(z.string()), - storage: z.record(z.string(), storageCollectionSchema), - /** - * Hook declarations — accepts both plain name strings (legacy) and - * structured objects with exclusive/priority/timeout metadata. - * Plain strings are normalized to `{ name }` objects after parsing. - */ - hooks: z.array(z.union([z.enum(HOOK_NAMES), manifestHookEntrySchema])), - /** - * Route declarations — accepts both plain name strings and - * structured objects with public metadata. - * Plain strings are normalized to `{ name }` objects after parsing. - */ - routes: z.array( - z.union([ - z.string().min(1).regex(routeNamePattern, "Route name must be a safe path segment"), - manifestRouteEntrySchema, - ]), - ), - mcpTools: z.array(manifestMcpToolEntrySchema).optional().default([]), - admin: pluginAdminConfigSchema, - }) - .superRefine((manifest, ctx) => { - if (manifest.mcpTools.length === 0) return; +export const pluginManifestSchema = pluginManifestBaseSchema.superRefine((manifest, ctx) => { + if (manifest.mcpTools.length === 0) return; - if (!manifest.capabilities.includes("mcp:tools")) { + if (!manifest.capabilities.includes("mcp:tools")) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["capabilities"], + message: 'Manifest with MCP tools must include the "mcp:tools" capability', + }); + } + + const routeNames = new Set(manifest.routes.map((route) => normalizeManifestRoute(route).name)); + for (const [index, tool] of manifest.mcpTools.entries()) { + if (!routeNames.has(tool.route)) { ctx.addIssue({ code: z.ZodIssueCode.custom, - path: ["capabilities"], - message: 'Manifest with MCP tools must include the "mcp:tools" capability', + path: ["mcpTools", index, "route"], + message: "MCP tool route must be declared in routes", }); } - - const routeNames = new Set(manifest.routes.map((route) => normalizeManifestRoute(route).name)); - for (const [index, tool] of manifest.mcpTools.entries()) { - if (!routeNames.has(tool.route)) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ["mcpTools", index, "route"], - message: "MCP tool route must be declared in routes", - }); - } - } - }); + } +}); export type ValidatedPluginManifest = z.infer; diff --git a/packages/core/tests/unit/plugins/manifest-schema.test.ts b/packages/core/tests/unit/plugins/manifest-schema.test.ts index ea2e91e40..cbbabbb34 100644 --- a/packages/core/tests/unit/plugins/manifest-schema.test.ts +++ b/packages/core/tests/unit/plugins/manifest-schema.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect } from "vitest"; import { + pluginManifestBaseSchema, pluginManifestSchema, normalizeManifestRoute, } from "../../../src/plugins/manifest-schema.js"; @@ -20,6 +21,24 @@ function makeManifest(storage: Record { + it("should keep a refinement-free base schema for manifest projections", () => { + const summarySchema = pluginManifestBaseSchema.pick({ + id: true, + version: true, + capabilities: true, + allowedHosts: true, + }); + + expect( + summarySchema.safeParse({ + id: "test-plugin", + version: "1.0.0", + capabilities: [], + allowedHosts: [], + }).success, + ).toBe(true); + }); + it("should accept plain string routes", () => { const result = pluginManifestSchema.safeParse(makeManifest({})); // Baseline with empty routes is valid From ad3bce246c659cfd12eeec4feeede63277ee58bd Mon Sep 17 00:00:00 2001 From: Mason James Date: Wed, 13 May 2026 07:56:45 -0400 Subject: [PATCH 4/5] Address MCP JSON schema review follow-up --- packages/core/src/mcp/json-schema.ts | 95 ++----------------- packages/core/src/mcp/plugin-tool-name.ts | 25 +++++ packages/core/src/mcp/server.ts | 25 +---- .../integration/mcp/plugin-tools.test.ts | 22 +---- 4 files changed, 34 insertions(+), 133 deletions(-) create mode 100644 packages/core/src/mcp/plugin-tool-name.ts diff --git a/packages/core/src/mcp/json-schema.ts b/packages/core/src/mcp/json-schema.ts index 5249e34a6..258816d18 100644 --- a/packages/core/src/mcp/json-schema.ts +++ b/packages/core/src/mcp/json-schema.ts @@ -1,95 +1,14 @@ import { z } from "zod"; -import type { - ManifestJsonArraySchema, - ManifestJsonBooleanSchema, - ManifestJsonNumberSchema, - ManifestJsonObjectSchema, - ManifestJsonSchema, - ManifestJsonStringSchema, -} from "../plugins/types.js"; +import type { ManifestJsonObjectSchema } from "../plugins/types.js"; + +type ZodJsonObjectSchema = z.core.JSONSchema.ObjectSchema; export function jsonSchemaObjectToZod( schema: ManifestJsonObjectSchema, ): z.ZodType> { - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- object roots are converted through objectSchemaToZod() - return schemaToZod(schema) as z.ZodType>; -} - -function schemaToZod(schema: ManifestJsonSchema): z.ZodTypeAny { - switch (schema.type) { - case "string": - return stringSchemaToZod(schema); - case "number": - case "integer": - return numberSchemaToZod(schema); - case "boolean": - return booleanSchemaToZod(schema); - case "array": - return arraySchemaToZod(schema); - case "object": - return objectSchemaToZod(schema); - } -} - -function applyCommon(schema: z.ZodTypeAny, source: ManifestJsonSchema): z.ZodTypeAny { - let next = source.description ? schema.describe(source.description) : schema; - if ("default" in source) { - next = next.default(source.default); - } - return next; -} - -function applyEnum( - schema: z.ZodTypeAny, - values: TValue[] | undefined, -): z.ZodTypeAny { - if (!values) return schema; - return schema.refine((value) => values.some((allowed) => Object.is(allowed, value)), { - message: `Expected one of: ${values.join(", ")}`, - }); -} - -function stringSchemaToZod(schema: ManifestJsonStringSchema): z.ZodTypeAny { - let next = z.string(); - if (schema.minLength !== undefined) next = next.min(schema.minLength); - if (schema.maxLength !== undefined) next = next.max(schema.maxLength); - if (schema.pattern) next = next.regex(RegExp(schema.pattern)); - if (schema.format === "date-time") next = next.datetime(); - if (schema.format === "email") next = next.email(); - if (schema.format === "uri") next = next.url(); - if (schema.format === "uuid") next = next.uuid(); - return applyCommon(applyEnum(next, schema.enum), schema); -} - -function numberSchemaToZod(schema: ManifestJsonNumberSchema): z.ZodTypeAny { - let next = schema.type === "integer" ? z.number().int() : z.number(); - if (schema.minimum !== undefined) next = next.min(schema.minimum); - if (schema.maximum !== undefined) next = next.max(schema.maximum); - return applyCommon(applyEnum(next, schema.enum), schema); -} - -function booleanSchemaToZod(schema: ManifestJsonBooleanSchema): z.ZodTypeAny { - return applyCommon(applyEnum(z.boolean(), schema.enum), schema); -} - -function arraySchemaToZod(schema: ManifestJsonArraySchema): z.ZodTypeAny { - let next = z.array(schemaToZod(schema.items)); - if (schema.minItems !== undefined) next = next.min(schema.minItems); - if (schema.maxItems !== undefined) next = next.max(schema.maxItems); - return applyCommon(next, schema); -} - -function objectSchemaToZod(schema: ManifestJsonObjectSchema): z.ZodTypeAny { - const required = new Set(schema.required ?? []); - const shape: Record = {}; - for (const [key, propertySchema] of Object.entries(schema.properties ?? {})) { - const zodSchema = schemaToZod(propertySchema); - shape[key] = required.has(key) ? zodSchema : zodSchema.optional(); - } - - const objectSchema = z.object(shape); - const next = - schema.additionalProperties === false ? objectSchema.strict() : objectSchema.passthrough(); - return applyCommon(next, schema); + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- manifest validation constrains input schemas to object roots. + return z.fromJSONSchema(schema as unknown as ZodJsonObjectSchema) as z.ZodType< + Record + >; } diff --git a/packages/core/src/mcp/plugin-tool-name.ts b/packages/core/src/mcp/plugin-tool-name.ts new file mode 100644 index 000000000..214fd7b8d --- /dev/null +++ b/packages/core/src/mcp/plugin-tool-name.ts @@ -0,0 +1,25 @@ +const FORWARD_SLASH_PATTERN = "/"; +const PLUGIN_SCOPE_PREFIX_PATTERN = /^@/; +const PLUGIN_ID_DASH_PATTERN = /-/g; +const AMBIGUOUS_PLUGIN_SEGMENT_PATTERN = /__/; + +export function pluginToolName(pluginId: string, toolName: string): string { + const readableSegments = pluginId + .replace(PLUGIN_SCOPE_PREFIX_PATTERN, "") + .split(FORWARD_SLASH_PATTERN) + .map((segment) => segment.replace(PLUGIN_ID_DASH_PATTERN, "_")); + const readablePluginId = readableSegments.join("__"); + if (readableSegments.some((segment) => AMBIGUOUS_PLUGIN_SEGMENT_PATTERN.test(segment))) { + return `${readablePluginId}__${stableToolNameHash(pluginId)}__${toolName}`; + } + return `${readablePluginId}__${toolName}`; +} + +function stableToolNameHash(value: string): string { + let hash = 0x811c9dc5; + for (let index = 0; index < value.length; index++) { + hash ^= value.charCodeAt(index); + hash = Math.imul(hash, 0x01000193); + } + return (hash >>> 0).toString(16).padStart(8, "0"); +} diff --git a/packages/core/src/mcp/server.ts b/packages/core/src/mcp/server.ts index f2ae9bd8a..d686edcf5 100644 --- a/packages/core/src/mcp/server.ts +++ b/packages/core/src/mcp/server.ts @@ -19,14 +19,12 @@ import { contentBylineInputSchema, contentSeoInput } from "#api/schemas.js"; import type { EmDashHandlers } from "../astro/types.js"; import { hasScope } from "../auth/api-tokens.js"; import { jsonSchemaObjectToZod } from "./json-schema.js"; +import { pluginToolName } from "./plugin-tool-name.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 = "/"; -const PLUGIN_SCOPE_PREFIX_PATTERN = /^@/; -const PLUGIN_ID_DASH_PATTERN = /-/g; -const AMBIGUOUS_PLUGIN_SEGMENT_PATTERN = /__/; /** http(s) scheme matcher used by `settings_update` URL validation. */ const HTTP_SCHEME_PATTERN = /^https?:\/\//i; @@ -239,27 +237,6 @@ function jsonResult(data: unknown): SuccessEnvelope { return respondData(data); } -function pluginToolName(pluginId: string, toolName: string): string { - const readableSegments = pluginId - .replace(PLUGIN_SCOPE_PREFIX_PATTERN, "") - .split(FORWARD_SLASH_PATTERN) - .map((segment) => segment.replace(PLUGIN_ID_DASH_PATTERN, "_")); - const readablePluginId = readableSegments.join("__"); - if (readableSegments.some((segment) => AMBIGUOUS_PLUGIN_SEGMENT_PATTERN.test(segment))) { - return `${readablePluginId}__${stableToolNameHash(pluginId)}__${toolName}`; - } - return `${readablePluginId}__${toolName}`; -} - -function stableToolNameHash(value: string): string { - let hash = 0x811c9dc5; - for (let index = 0; index < value.length; index++) { - hash ^= value.charCodeAt(index); - hash = Math.imul(hash, 0x01000193); - } - return (hash >>> 0).toString(16).padStart(8, "0"); -} - function normalizePluginToolRoute(route: string): string { return route.replace(LEADING_SLASH_PATTERN, ""); } diff --git a/packages/core/tests/integration/mcp/plugin-tools.test.ts b/packages/core/tests/integration/mcp/plugin-tools.test.ts index 03b005ba6..41ccfd260 100644 --- a/packages/core/tests/integration/mcp/plugin-tools.test.ts +++ b/packages/core/tests/integration/mcp/plugin-tools.test.ts @@ -2,6 +2,7 @@ import { Role } from "@emdash-cms/auth"; import { afterEach, describe, expect, it } from "vitest"; import { z } from "zod"; +import { pluginToolName } from "../../../src/mcp/plugin-tool-name.js"; import { definePlugin } from "../../../src/plugins/define-plugin.js"; import type { ResolvedPlugin } from "../../../src/plugins/types.js"; import { @@ -14,27 +15,6 @@ import { } from "../../utils/mcp-runtime.js"; import { setupTestDatabaseWithCollections, teardownTestDatabase } from "../../utils/test-db.js"; -function pluginToolName(pluginId: string, toolName: string): string { - const readableSegments = pluginId - .replace(/^@/, "") - .split("/") - .map((segment) => segment.replace(/-/g, "_")); - const readablePluginId = readableSegments.join("__"); - if (readableSegments.some((segment) => /__/.test(segment))) { - return `${readablePluginId}__${stableToolNameHash(pluginId)}__${toolName}`; - } - return `${readablePluginId}__${toolName}`; -} - -function stableToolNameHash(value: string): string { - let hash = 0x811c9dc5; - for (let index = 0; index < value.length; index++) { - hash ^= value.charCodeAt(index); - hash = Math.imul(hash, 0x01000193); - } - return (hash >>> 0).toString(16).padStart(8, "0"); -} - function createPluginWithMcpCapability( id: string = "test-plugin", summarize: (text: string) => string = (text) => text.toUpperCase(), From 60f1316f4a8cbe1a7f497a54d6d9731fe1fb1fbc Mon Sep 17 00:00:00 2001 From: Mason James Date: Fri, 5 Jun 2026 12:09:05 -0400 Subject: [PATCH 5/5] fix changeset package name after main sync --- .changeset/plugin-mcp-tools.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/plugin-mcp-tools.md b/.changeset/plugin-mcp-tools.md index e3b638cd9..0b73cf0a4 100644 --- a/.changeset/plugin-mcp-tools.md +++ b/.changeset/plugin-mcp-tools.md @@ -1,7 +1,7 @@ --- "emdash": patch "@emdash-cms/plugin-types": patch -"@emdash-cms/registry-cli": patch +"@emdash-cms/plugin-cli": patch --- Adds plugin-defined MCP tool metadata to plugin manifests and exposes enabled plugin tools through the EmDash MCP endpoint.