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/docs/astro.config.mjs b/docs/astro.config.mjs index 93fb33915..8455e1f05 100644 --- a/docs/astro.config.mjs +++ b/docs/astro.config.mjs @@ -115,6 +115,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 f8963a0b8..eb7da09d5 100644 --- a/docs/src/content/docs/reference/mcp-server.mdx +++ b/docs/src/content/docs/reference/mcp-server.mdx @@ -81,6 +81,10 @@ Responses follow the [JSON-RPC 2.0](https://www.jsonrpc.org/specification) forma The server exposes 45 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 a07b9d2c4..d71dfdbbc 100644 --- a/packages/core/src/astro/integration/runtime.ts +++ b/packages/core/src/astro/integration/runtime.ts @@ -37,6 +37,14 @@ export interface PluginDashboardWidget { title?: string; } +export interface PluginMcpToolDescriptor { + name: string; + title?: string; + description: string; + route: string; + inputSchema?: import("../../plugins/types.js").ManifestJsonObjectSchema; +} + /** * Plugin descriptor - returned by plugin factory functions * @@ -98,6 +106,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 f130c2309..03bf4bd51 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 { @@ -550,6 +551,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 347b04f97..c9d0eb459 100644 --- a/packages/core/src/astro/middleware.ts +++ b/packages/core/src/astro/middleware.ts @@ -498,6 +498,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 50027cac8..2d45c05c0 100644 --- a/packages/core/src/astro/types.ts +++ b/packages/core/src/astro/types.ts @@ -388,6 +388,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..95030a4e9 100644 --- a/packages/core/src/cli/commands/bundle-utils.ts +++ b/packages/core/src/cli/commands/bundle-utils.ts @@ -156,6 +156,13 @@ export function extractManifest(plugin: ResolvedPlugin): PluginManifest { storage: plugin.storage, hooks, routes: Object.keys(plugin.routes), + mcpTools: Object.entries(plugin.mcpTools ?? {}).map(([name, tool]) => ({ + name, + title: tool.title, + description: tool.description, + route: tool.route, + inputSchema: tool.inputSchema, + })), admin: { // Omit entry (it's a module specifier for the host, not relevant in bundles) settingsSchema: plugin.admin.settingsSchema, 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/emdash-runtime.ts b/packages/core/src/emdash-runtime.ts index 71aedaa42..1aafaf1d1 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, @@ -207,6 +208,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; /** @@ -333,7 +336,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 */ @@ -663,7 +668,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 @@ -1510,6 +1517,7 @@ export class EmDashRuntime { storage: entry.storage ?? {}, hooks: [], routes: [], + mcpTools: entry.mcpTools ?? [], admin: {}, }; @@ -1607,7 +1615,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 @@ -2717,6 +2727,70 @@ export class EmDashRuntime { // Plugin Routes // ========================================================================= + 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 ?? {})) { + addTool({ + pluginId: plugin.id, + name, + title: tool.title, + description: tool.description, + route: tool.route, + input: tool.input, + inputSchema: tool.inputSchema, + }); + } + } + + for (const entry of this.sandboxedPluginEntries) { + if (!this.isPluginEnabled(entry.id)) continue; + if (!entry.capabilities.includes("mcp:tools")) continue; + if (!this.findSandboxedPlugin(entry.id)) continue; + + for (const tool of entry.mcpTools ?? []) { + addTool({ + pluginId: entry.id, + name: tool.name, + title: tool.title, + description: tool.description, + route: tool.route, + inputSchema: tool.inputSchema, + }); + } + } + + for (const [pluginId, meta] of marketplaceManifestCache) { + if (!this.isPluginEnabled(pluginId)) continue; + if (!meta.capabilities?.includes("mcp:tools")) continue; + if (!this.findSandboxedPlugin(pluginId)) continue; + + for (const tool of meta.mcpTools ?? []) { + addTool({ + pluginId, + name: tool.name, + title: tool.title, + description: tool.description, + route: tool.route, + inputSchema: tool.inputSchema, + }); + } + } + + 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 d70ad0bd2..f934042ea 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -231,6 +231,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/json-schema.ts b/packages/core/src/mcp/json-schema.ts new file mode 100644 index 000000000..258816d18 --- /dev/null +++ b/packages/core/src/mcp/json-schema.ts @@ -0,0 +1,14 @@ +import { z } from "zod"; + +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 -- 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..1598381c5 --- /dev/null +++ b/packages/core/src/mcp/plugin-tool-name.ts @@ -0,0 +1,29 @@ +const FORWARD_SLASH_PATTERN = "/"; +const PLUGIN_SCOPE_PREFIX_PATTERN = /^@/; +const PLUGIN_ID_DASH_PATTERN = /-/g; +const AMBIGUOUS_PLUGIN_SEGMENT_PATTERN = /__/; +const AMBIGUOUS_JOINED_PLUGIN_ID_PATTERN = /_{3,}/; + +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 ( + AMBIGUOUS_JOINED_PLUGIN_ID_PATTERN.test(readablePluginId) || + 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 2f9bf84ff..de66ae1a4 100644 --- a/packages/core/src/mcp/server.ts +++ b/packages/core/src/mcp/server.ts @@ -18,8 +18,13 @@ 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 = "/"; /** http(s) scheme matcher used by `settings_update` URL validation. */ const HTTP_SCHEME_PATTERN = /^https?:\/\//i; @@ -232,6 +237,47 @@ function jsonResult(data: unknown): SuccessEnvelope { return respondData(data); } +function fallbackPluginToolInputSchema(): z.ZodType> { + return z.record(z.string(), z.unknown()); +} + +function safeJsonSchemaObjectToZod( + schema: Parameters[0], + pluginId: string, + toolName: string, +): z.ZodType> { + try { + return jsonSchemaObjectToZod(schema); + } catch (error) { + console.warn( + `[emdash] Ignoring invalid MCP inputSchema for plugin tool ${pluginId}/${toolName}:`, + error, + ); + return fallbackPluginToolInputSchema(); + } +} + +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 +431,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 +465,66 @@ 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 ?? + (tool.inputSchema + ? safeJsonSchemaObjectToZod(tool.inputSchema, tool.pluginId, tool.name) + : fallbackPluginToolInputSchema()), + }, + async (args: Record, extra) => { + requireScope(extra, "admin"); + requireRole(extra, Role.ADMIN); + + 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}`); + } + + 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/plugin-types.ts b/packages/core/src/plugin-types.ts index 506e83230..3b18ffed2 100644 --- a/packages/core/src/plugin-types.ts +++ b/packages/core/src/plugin-types.ts @@ -38,6 +38,8 @@ * per-hook return contracts so misuse fails at compile time. */ +import type { ManifestJsonObjectSchema } from "@emdash-cms/plugin-types"; + import type { CommentAfterCreateEvent, CommentAfterCreateHandler, @@ -194,6 +196,21 @@ export type RouteEntry = input?: unknown; }; +/** + * MCP tool entry declared by a sandboxed plugin's default export. + * + * Execution is delegated to a plugin route. `input` is the optional + * trusted-mode Zod schema used at runtime; `inputSchema` is the portable + * JSON Schema object emitted to manifests for MCP client introspection. + */ +export interface SandboxedMcpToolEntry { + title?: string; + description: string; + route: string; + input?: unknown; + inputSchema?: ManifestJsonObjectSchema; +} + /** * The shape of a sandboxed plugin's default export. * @@ -208,6 +225,7 @@ export interface SandboxedPlugin { [K in keyof HookHandlers]?: HookEntry; }; routes?: Record; + mcpTools?: Record; } /** diff --git a/packages/core/src/plugins/adapt-sandbox-entry.ts b/packages/core/src/plugins/adapt-sandbox-entry.ts index aa5c8773e..bdffcbc16 100644 --- a/packages/core/src/plugins/adapt-sandbox-entry.ts +++ b/packages/core/src/plugins/adapt-sandbox-entry.ts @@ -22,6 +22,7 @@ import type { PluginCapability, PluginStorageConfig, PluginAdminConfig, + PluginMcpTool, } from "./types.js"; /** @@ -53,6 +54,8 @@ type AnyHookEntry = 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 hook entry is the config form (has a `handler` property). @@ -228,6 +231,28 @@ export function adaptSandboxEntry( } } + const mcpTools: Record = {}; + for (const toolEntry of descriptor.mcpTools ?? []) { + mcpTools[toolEntry.name] = { + title: toolEntry.title, + description: toolEntry.description, + route: toolEntry.route, + inputSchema: toolEntry.inputSchema, + }; + } + if (definition.mcpTools) { + for (const [toolName, toolEntry] of Object.entries(definition.mcpTools)) { + mcpTools[toolName] = { + 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"], + }; + } + } + // Build capabilities from descriptor. // Validate against the known set (same as defineNativePlugin). Both // current and deprecated names are accepted; deprecated names are @@ -266,6 +291,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. @@ -295,6 +340,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 d5bf14ac9..eac3c2608 100644 --- a/packages/core/src/plugins/define-plugin.ts +++ b/packages/core/src/plugins/define-plugin.ts @@ -26,6 +26,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 a native EmDash plugin. @@ -101,6 +103,7 @@ function defineNativePlugin( allowedHosts = [], hooks = {}, routes = {}, + mcpTools = {}, admin = {}, } = definition; @@ -137,6 +140,7 @@ function defineNativePlugin( "media:write", "users:read", "email:send", + "mcp:tools", "hooks.email-transport:register", "hooks.email-events:register", "hooks.page-fragments:register", @@ -181,6 +185,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); @@ -192,6 +215,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 51cb6e31c..d837dbe4a 100644 --- a/packages/core/src/plugins/index.ts +++ b/packages/core/src/plugins/index.ts @@ -168,6 +168,8 @@ export type { // Route types PluginRoute, RouteContext, + PluginMcpTool, + PluginMcpToolRegistration, // Admin types PluginAdminConfig, @@ -184,6 +186,7 @@ export type { PluginDefinition, ResolvedPlugin, PluginManifest, + ManifestMcpToolEntry, } from "./types.js"; // Capability normalization (legacy → canonical alias layer) diff --git a/packages/core/src/plugins/manifest-schema.ts b/packages/core/src/plugins/manifest-schema.ts index 7640fd0c7..5026b54ce 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,102 @@ const manifestRouteEntrySchema = z.object({ public: z.boolean().optional(), }); +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 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 ───────────────────────────────────────────────── /** Index field names must be valid identifiers to prevent SQL injection via JSON path expressions */ @@ -222,11 +319,12 @@ const pluginAdminConfigSchema = z.object({ // ── Main schema ───────────────────────────────────────────────── /** - * Zod schema matching the PluginManifest interface from types.ts. + * Refinement-free manifest object schema. * - * Every JSON.parse of a manifest.json should validate through this. + * Use this for projections such as `.pick()` where Zod cannot operate on + * refined schemas. Full manifest parsing should use `pluginManifestSchema`. */ -export const pluginManifestSchema = z.object({ +export const pluginManifestBaseSchema = z.object({ id: z.string().min(1), version: z.string().min(1), capabilities: z.array(z.enum(PLUGIN_CAPABILITIES)), @@ -249,9 +347,38 @@ export const pluginManifestSchema = z.object({ 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 = pluginManifestBaseSchema.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 b743ed1b8..41183c65c 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 as SharedManifestMcpToolEntry, type ManifestRouteEntry, type PluginCapability, type PluginStorageConfig, @@ -52,6 +53,59 @@ export { 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 // ============================================================================= @@ -1076,6 +1130,35 @@ 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>; + /** Optional JSON Schema object for manifest output and MCP introspection */ + inputSchema?: ManifestJsonObjectSchema; +} + +export interface PluginMcpToolRegistration { + pluginId: string; + name: string; + title?: string; + description: string; + route: string; + input?: z.ZodType>; + inputSchema?: ManifestJsonObjectSchema; +} + // ============================================================================= // Plugin Definition // ============================================================================= @@ -1257,6 +1340,9 @@ export interface PluginDefinition; + /** MCP tools exposed through the host MCP endpoint */ + mcpTools?: Record; + /** Admin UI configuration */ admin?: PluginAdminConfig; } @@ -1272,6 +1358,7 @@ export interface ResolvedPlugin; + mcpTools?: Record; admin: PluginAdminConfig; } @@ -1343,6 +1430,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..270976d1c --- /dev/null +++ b/packages/core/tests/integration/mcp/plugin-tools.test.ts @@ -0,0 +1,416 @@ +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 { + connectMcpHarness, + createTestRuntime, + extractJson, + extractText, + isErrorResult, + type McpHarness, +} from "../../utils/mcp-runtime.js"; +import { setupTestDatabaseWithCollections, teardownTestDatabase } from "../../utils/test-db.js"; + +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, + inputSchema: { + type: "object", + properties: { + text: { + type: "string", + description: "Text to summarize.", + minLength: 1, + }, + }, + required: ["text"], + additionalProperties: false, + }, + }, + }, + }); +} + +function createPluginWithoutMcpCapability(): ResolvedPlugin { + return { + id: "route-only-plugin", + version: "1.0.0", + capabilities: [], + allowedHosts: [], + storage: {}, + hooks: {}, + routes: { + summarize: { + handler: async () => ({ summary: "hidden" }), + }, + }, + mcpTools: { + summarize: { + description: "This tool should not be listed without the mcp:tools capability.", + 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, + }, + }, + }, + }); +} + +function createPluginWithInvalidJsonInputSchema(): ResolvedPlugin { + return definePlugin({ + id: "invalid-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: "definitely-not-valid", + } as unknown as NonNullable[string]["inputSchema"], + }, + }, + }); +} + +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[], + userRole = Role.ADMIN, + ) { + const db = await setupTestDatabaseWithCollections(); + harness = await connectMcpHarness({ + db, + userId: "user_admin", + userRole, + 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("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("falls back when a trusted plugin provides an invalid JSON input schema", async () => { + const connected = await connectWithPlugins([createPluginWithInvalidJsonInputSchema()]); + const name = pluginToolName("invalid-json-schema-plugin", "summarize"); + + const tools = await connected.client.listTools(); + expect(tools.tools.map((tool) => tool.name)).toContain(name); + + const result = await connected.client.callTool({ + name, + arguments: { text: "hello" }, + }); + + expect(result.isError).toBeFalsy(); + expect(extractJson(result)).toEqual({ input: { text: "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("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"]); + + 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]"); + }); + + 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 3f50ca5c5..cb89504ac 100644 --- a/packages/core/tests/unit/cli/bundle-utils.test.ts +++ b/packages/core/tests/unit/cli/bundle-utils.test.ts @@ -85,6 +85,43 @@ 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", + inputSchema: { + type: "object", + properties: { + text: { type: "string" }, + }, + required: ["text"], + }, + }, + }, + }); + + const manifest = extractManifest(plugin); + + expect(manifest.mcpTools).toEqual([ + { + name: "summarize", + title: "Summarize", + description: "Summarize text.", + route: "summarize", + inputSchema: { + type: "object", + properties: { + text: { type: "string" }, + }, + required: ["text"], + }, + }, + ]); + }); + 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/mcp/plugin-tool-name.test.ts b/packages/core/tests/unit/mcp/plugin-tool-name.test.ts new file mode 100644 index 000000000..bf917b3f9 --- /dev/null +++ b/packages/core/tests/unit/mcp/plugin-tool-name.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; + +import { pluginToolName } from "../../../src/mcp/plugin-tool-name.js"; + +const HASHED_TOOL_NAME_PATTERN = /^[a-z0-9_]+__[0-9a-f]{8}__[a-z][a-z0-9_]*$/; + +describe("pluginToolName", () => { + it("uses readable names for unambiguous scoped plugin IDs", () => { + expect(pluginToolName("@emdash-cms/plugin-forms", "submit_form")).toBe( + "emdash_cms__plugin_forms__submit_form", + ); + }); + + it("adds a stable hash when dashes make a segment ambiguous", () => { + const name = pluginToolName("foo--bar", "summarize"); + + expect(name).toMatch(HASHED_TOOL_NAME_PATTERN); + expect(name).not.toBe("foo__bar__summarize"); + expect(pluginToolName("foo--bar", "summarize")).toBe(name); + }); + + it("keeps scoped IDs distinct when dashes sit next to the slash boundary", () => { + const trailingDashScope = pluginToolName("@a-/b", "summarize"); + const leadingDashName = pluginToolName("@a/-b", "summarize"); + + expect(trailingDashScope).toMatch(HASHED_TOOL_NAME_PATTERN); + expect(leadingDashName).toMatch(HASHED_TOOL_NAME_PATTERN); + expect(trailingDashScope).not.toBe(leadingDashName); + }); +}); 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 4e393ea2b..d5c19eb48 100644 --- a/packages/core/tests/unit/plugins/adapt-sandbox-entry.test.ts +++ b/packages/core/tests/unit/plugins/adapt-sandbox-entry.test.ts @@ -83,6 +83,133 @@ describe("adaptSandboxEntry", () => { expect(result.allowedHosts).toEqual(["api.example.com", "*.cdn.com"]); }); + it("carries MCP tools from the standard definition", () => { + const def: SandboxedPlugin = { + routes: { + summarize: { + handler: async () => ({ ok: true }), + }, + }, + mcpTools: { + summarize: { + title: "Summarize", + description: "Summarize text.", + route: "summarize", + inputSchema: { + type: "object", + properties: { + text: { type: "string" }, + }, + required: ["text"], + }, + }, + }, + }; + const descriptor = createDescriptor({ capabilities: ["mcp:tools"] }); + + const result = adaptSandboxEntry(def, descriptor); + + expect(result.mcpTools).toEqual({ + summarize: { + title: "Summarize", + description: "Summarize text.", + route: "summarize", + inputSchema: { + type: "object", + properties: { + text: { type: "string" }, + }, + required: ["text"], + }, + }, + }); + }); + + it("carries MCP tools from the descriptor", () => { + const def: SandboxedPlugin = { + routes: { + summarize: { + handler: async () => ({ ok: true }), + }, + }, + }; + const descriptor = createDescriptor({ + capabilities: ["mcp:tools"], + mcpTools: [ + { + name: "summarize", + title: "Summarize", + description: "Summarize text.", + route: "summarize", + inputSchema: { + type: "object", + properties: { + text: { type: "string" }, + }, + required: ["text"], + }, + }, + ], + }); + + const result = adaptSandboxEntry(def, descriptor); + + expect(result.mcpTools).toEqual({ + summarize: { + 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: SandboxedPlugin = { + 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: SandboxedPlugin = { + 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: SandboxedPlugin = {}; 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 64545b5d4..bd74a6bf9 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", () => { @@ -174,6 +177,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", @@ -501,6 +514,102 @@ describe("definePlugin", () => { }); }); + describe("MCP tools", () => { + it("rejects sandboxed-format MCP tool declarations passed to definePlugin", () => { + const sandboxedDefinition = { + routes: { + summarize: { + handler: async () => ({ ok: true }), + }, + }, + mcpTools: { + summarize: { + description: "Summarize text.", + route: "summarize", + }, + }, + } as unknown as Parameters[0]; + + expect(() => definePlugin(sandboxedDefinition)).toThrow(/requires `id`/); + }); + + 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 5b9c6cda3..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 @@ -105,6 +124,149 @@ describe("pluginManifestSchema — route entries", () => { }); }); +describe("pluginManifestSchema — MCP tool entries", () => { + it("should accept plugin MCP tool metadata", () => { + 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, + }, + }, + ], + }); + + 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", + inputSchema: { + type: "object", + properties: { + text: { + type: "string", + description: "Text to summarize.", + minLength: 1, + }, + }, + required: ["text"], + additionalProperties: false, + }, + }, + ]); + } + }); + + it("should reject MCP tool names outside lowercase snake_case", () => { + const result = pluginManifestSchema.safeParse({ + ...makeManifest({}), + capabilities: ["mcp:tools"], + routes: ["tools/summarize"], + mcpTools: [ + { + name: "Summarize", + description: "Invalid tool name.", + route: "tools/summarize", + }, + ], + }); + + 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", () => { 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; + 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 @@ -65,5 +113,14 @@ export interface ResolvedPlugin { public?: boolean; } >; + mcpTools?: Record< + string, + { + title?: string; + description: string; + route: string; + inputSchema?: ManifestJsonObjectSchema; + } + >; admin: PluginAdminConfig; } diff --git a/packages/plugin-cli/src/bundle/utils.ts b/packages/plugin-cli/src/bundle/utils.ts index c681240af..98c6e4aeb 100644 --- a/packages/plugin-cli/src/bundle/utils.ts +++ b/packages/plugin-cli/src/bundle/utils.ts @@ -152,6 +152,13 @@ export function extractManifest(plugin: ResolvedPlugin): PluginManifest { storage: plugin.storage, hooks, routes: Object.keys(plugin.routes), + mcpTools: Object.entries(plugin.mcpTools ?? {}).map(([name, tool]) => ({ + name, + title: tool.title, + description: tool.description, + route: tool.route, + inputSchema: tool.inputSchema, + })), admin: { // Omit `entry` (it's a module specifier for the host, not relevant in bundles) settingsSchema: plugin.admin.settingsSchema, diff --git a/packages/plugin-cli/src/commands/info.ts b/packages/plugin-cli/src/commands/info.ts index 2b694b598..3e7612591 100644 --- a/packages/plugin-cli/src/commands/info.ts +++ b/packages/plugin-cli/src/commands/info.ts @@ -76,9 +76,9 @@ export const infoCommand = defineCommand({ ); } - const name = profile?.name ?? result.slug; - const description = profile?.description; - const license = profile?.license; + const name = typeof profile?.name === "string" ? profile.name : result.slug; + const description = typeof profile?.description === "string" ? profile.description : undefined; + const license = typeof profile?.license === "string" ? profile.license : undefined; console.log(); console.log(pc.bold(name)); diff --git a/packages/plugin-cli/src/commands/search.ts b/packages/plugin-cli/src/commands/search.ts index e03207b54..ad82c7b54 100644 --- a/packages/plugin-cli/src/commands/search.ts +++ b/packages/plugin-cli/src/commands/search.ts @@ -71,8 +71,11 @@ export const searchCommand = defineCommand({ for (const pkg of result.packages) { // `pkg.profile` is lexicon-validated by DiscoveryClient (or null). const profile = pkg.profile; - console.log(`${pc.bold(profile?.name ?? pkg.slug)} ${pc.dim(`(${pkg.slug})`)}`); - if (profile?.description) console.log(` ${profile.description}`); + const name = typeof profile?.name === "string" ? profile.name : pkg.slug; + const description = + typeof profile?.description === "string" ? profile.description : undefined; + console.log(`${pc.bold(name)} ${pc.dim(`(${pkg.slug})`)}`); + if (description) console.log(` ${description}`); console.log(` ${pc.dim(pkg.uri)}`); console.log(); } diff --git a/packages/plugin-cli/tests/bundle-utils.test.ts b/packages/plugin-cli/tests/bundle-utils.test.ts index 941d4de01..493db95c6 100644 --- a/packages/plugin-cli/tests/bundle-utils.test.ts +++ b/packages/plugin-cli/tests/bundle-utils.test.ts @@ -72,6 +72,43 @@ 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", + inputSchema: { + type: "object", + properties: { + text: { type: "string" }, + }, + required: ["text"], + }, + }, + }, + }), + ); + + expect(manifest.mcpTools).toEqual([ + { + name: "summarize", + title: "Summarize", + description: "Summarize text.", + route: "summarize", + inputSchema: { + type: "object", + properties: { + text: { type: "string" }, + }, + required: ["text"], + }, + }, + ]); + }); + it("strips the runtime entry pointer from admin", () => { const manifest = extractManifest( minimalResolved({ diff --git a/packages/plugin-types/src/index.ts b/packages/plugin-types/src/index.ts index 53ec46559..64eef15d6 100644 --- a/packages/plugin-types/src/index.ts +++ b/packages/plugin-types/src/index.ts @@ -55,6 +55,8 @@ export type PluginCapability = | "users:read" // Email | "email:send" + // MCP + | "mcp:tools" // Hook registration | "hooks.email-transport:register" // exclusive `email:deliver` (transport) | "hooks.email-events:register" // `email:beforeSend` / `email:afterSend` @@ -184,6 +186,70 @@ 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. + * + * The tool itself is exposed through EmDash's native MCP endpoint. Execution + * is delegated to a plugin route so trusted and sandboxed plugins share the + * same implementation path. + */ +export interface ManifestMcpToolEntry { + name: string; + title?: string; + description: string; + route: string; + /** JSON Schema object used for MCP input validation and client introspection. */ + inputSchema?: ManifestJsonObjectSchema; +} + /** * Per-collection storage config in a plugin manifest. * @@ -261,6 +327,8 @@ export interface PluginManifest { hooks: Array; /** Route declarations -- plain name strings or structured objects. */ routes: Array; + /** MCP tools declared by the plugin. */ + mcpTools?: ManifestMcpToolEntry[]; admin: PluginAdminConfig; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dd10806da..b14f78826 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2486,7 +2486,7 @@ packages: wrangler: ^4.83.0 '@astrojs/cloudflare@https://pkg.pr.new/@astrojs/cloudflare@94d342d': - resolution: {tarball: https://pkg.pr.new/@astrojs/cloudflare@94d342d} + resolution: {integrity: sha512-Bt+G512Dr1SqYdsza6HOLP2azfHg0m5UE0s6SBGX77g+ThFV95Nai5boyM8HO3jVpqwVPPh+5ycMptjrtzv7Yg==, tarball: https://pkg.pr.new/@astrojs/cloudflare@94d342d} version: 13.1.10 peerDependencies: astro: ^6.0.0 @@ -4046,7 +4046,7 @@ packages: resolution: {integrity: sha512-yTCCjuQapvRz6S30B8DyqHu1WYsbYRCww6uNsmbQU4GQVf5gJzJSB60qUHj+qBSxReLtRL/mhmhYhrIc9jVFTw==} '@lunariajs/core@https://pkg.pr.new/lunariajs/lunaria/@lunariajs/core@83617cc': - resolution: {tarball: https://pkg.pr.new/lunariajs/lunaria/@lunariajs/core@83617cc} + resolution: {integrity: sha512-k8sHBM7S10HBa39fxsJcOGYMGrbru5UZ9vMS4kmCa9o6dJTUP6rt3zKVEs7uEsHAYasoXyiC6wre2Jiqs3X+zQ==, tarball: https://pkg.pr.new/lunariajs/lunaria/@lunariajs/core@83617cc} version: 0.1.1 engines: {node: '>=18.17.0'} @@ -6651,7 +6651,7 @@ packages: hasBin: true astro@https://pkg.pr.new/astro@94d342d: - resolution: {tarball: https://pkg.pr.new/astro@94d342d} + resolution: {integrity: sha512-1XlhRGRCQP4L5KPZUgSRCKOD28aKiGYQ8TBAxBIJvFV/HUuct3eHvc7sY/krhhCAju81JMlvbWU+1XVzltgZTQ==, tarball: https://pkg.pr.new/astro@94d342d} version: 6.1.7 engines: {node: '>=22.12.0', npm: '>=9.6.5', pnpm: '>=7.1.0'} hasBin: true @@ -7957,6 +7957,10 @@ packages: peerDependencies: kysely: '*' + kysely@0.27.6: + resolution: {integrity: sha512-FIyV/64EkKhJmjgC0g2hygpBv5RNWVPyNCqSAD7eTCv6eFWNIi4PN1UvdSJGicN/o35bnevgis4Y0UDC0qi8jQ==} + engines: {node: '>=14.0.0'} + kysely@0.29.2: resolution: {integrity: sha512-s6WVJyEZrbm6jhBpiKHsGHyePMrVQKJ85wZCFCr9W4QHv6WTjWIrdvTmO9hDEA3bNK0xkrE2DqrHsXMLWuZpQg==} engines: {node: '>=22.0.0'} @@ -17570,6 +17574,8 @@ snapshots: dependencies: kysely: 0.29.2 + kysely@0.27.6: {} + kysely@0.29.2: {} layerr@3.0.0: {}