From 05082f60eb2f95de2ad39ca1f4ec13b601bdeaa0 Mon Sep 17 00:00:00 2001 From: Steven Le Date: Thu, 25 Sep 2025 14:51:36 -0700 Subject: [PATCH 1/2] feat(root-cms): add mcp server command --- packages/root-cms/cli/cli.ts | 9 +- packages/root-cms/cli/mcp.ts | 225 +++++++++++++++++++++++++++++++++ packages/root-cms/package.json | 1 + pnpm-lock.yaml | 3 + 4 files changed, 237 insertions(+), 1 deletion(-) create mode 100644 packages/root-cms/cli/mcp.ts diff --git a/packages/root-cms/cli/cli.ts b/packages/root-cms/cli/cli.ts index 725ddbff..a6bab9e6 100644 --- a/packages/root-cms/cli/cli.ts +++ b/packages/root-cms/cli/cli.ts @@ -2,6 +2,7 @@ import {Command} from 'commander'; import {bgGreen, black} from 'kleur/colors'; import {generateTypes} from './generate-types.js'; import {initFirebase} from './init-firebase.js'; +import {startMcpServer} from './mcp.js'; class CliRunner { private name: string; @@ -37,8 +38,14 @@ class CliRunner { 'generates root-cms.d.ts from *.schema.ts files in the project' ) .action(generateTypes); + program + .command('mcp') + .description( + 'starts a Model Context Protocol server for the current Root CMS project' + ) + .action(startMcpServer); await program.parseAsync(argv); } } -export {CliRunner, generateTypes, initFirebase}; +export {CliRunner, generateTypes, initFirebase, startMcpServer}; diff --git a/packages/root-cms/cli/mcp.ts b/packages/root-cms/cli/mcp.ts new file mode 100644 index 00000000..56494c45 --- /dev/null +++ b/packages/root-cms/cli/mcp.ts @@ -0,0 +1,225 @@ +import {loadRootConfig} from '@blinkk/root/node'; +import {z} from 'zod'; + +import packageJson from '../package.json' assert {type: 'json'}; +import {DocMode, RootCMSClient, parseDocId} from '../core/client.js'; + +const DOC_MODES = ['draft', 'published'] as const satisfies DocMode[]; + +const getDocInputSchema = z + .object({ + docId: z + .string() + .describe('Fully-qualified doc id in the format "/".') + .optional(), + collectionId: z + .string() + .describe('Collection id (e.g. "Pages").') + .optional(), + slug: z + .string() + .describe('Doc slug (e.g. "home").') + .optional(), + mode: z + .enum(DOC_MODES) + .default('draft') + .describe('Whether to fetch the draft or published version of the doc.'), + }) + .refine( + (value) => { + if (value.docId) { + return true; + } + return Boolean(value.collectionId && value.slug); + }, + { + message: + 'Provide either "docId" or both "collectionId" and "slug" for the doc to fetch.', + path: ['docId'], + } + ); + +const getDocInputJsonSchema = { + type: 'object', + properties: { + docId: { + type: 'string', + description: + 'Fully-qualified doc id in the format "/" (e.g. "Pages/home").', + }, + collectionId: { + type: 'string', + description: 'Collection id (e.g. "Pages").', + }, + slug: { + type: 'string', + description: 'Doc slug (e.g. "home").', + }, + mode: { + type: 'string', + enum: [...DOC_MODES], + description: 'Whether to fetch the draft or published version of the doc.', + default: 'draft', + }, + }, + oneOf: [ + { + required: ['docId'], + }, + { + required: ['collectionId', 'slug'], + }, + ], + additionalProperties: false, +} as const; + +type ToolResponse = { + content: Array<{type: 'text'; text: string}>; + isError?: boolean; +}; + +async function loadMcpSdk() { + const [{Server}, transportModule] = await Promise.all([ + import('@modelcontextprotocol/sdk/server/index.js'), + import('@modelcontextprotocol/sdk/server/node/index.js').catch(async () => + import('@modelcontextprotocol/sdk/server/stdio.js') + ), + ]); + const StdioServerTransport = + (transportModule as any).StdioServerTransport || + (transportModule as any).stdioServerTransport || + (transportModule as any).default; + if (!StdioServerTransport) { + throw new Error('Unable to load MCP stdio transport implementation.'); + } + return {Server, StdioServerTransport}; +} + +function registerTool( + server: any, + definition: { + name: string; + description: string; + inputSchema: unknown; + }, + handler: (payload: unknown) => Promise +) { + if (typeof server.tool === 'function') { + return server.tool(definition, handler); + } + if (typeof server.registerTool === 'function') { + return server.registerTool(definition, handler); + } + if (typeof server.addTool === 'function') { + return server.addTool(definition, handler); + } + throw new Error('Unsupported MCP SDK version: missing tool registration helper.'); +} + +function formatDocForResponse(doc: unknown): ToolResponse { + return { + content: [ + { + type: 'text', + text: JSON.stringify(doc, null, 2), + }, + ], + }; +} + +async function handleGetDocRequest( + cmsClient: RootCMSClient, + rawPayload: unknown +): Promise { + try { + const parsed = getDocInputSchema.parse(rawPayload); + let collectionId = parsed.collectionId; + let slug = parsed.slug; + if (parsed.docId) { + const docInfo = parseDocId(parsed.docId); + collectionId = docInfo.collection; + slug = docInfo.slug; + } + if (!collectionId || !slug) { + throw new Error( + 'A collection id and slug are required to fetch a doc from Root CMS.' + ); + } + const mode: DocMode = parsed.mode || 'draft'; + const doc = await cmsClient.getDoc(collectionId, slug, {mode}); + if (!doc) { + return { + content: [ + { + type: 'text', + text: `Doc not found: ${collectionId}/${slug} (mode: ${mode})`, + }, + ], + isError: true, + }; + } + return formatDocForResponse(doc); + } catch (error) { + const message = + error instanceof Error ? error.message : 'Unknown error fetching doc.'; + return { + content: [ + { + type: 'text', + text: `Error fetching Root CMS doc: ${message}`, + }, + ], + isError: true, + }; + } +} + +export async function startMcpServer() { + const rootDir = process.cwd(); + const rootConfig = await loadRootConfig(rootDir, {command: 'root-cms'}); + const cmsClient = new RootCMSClient(rootConfig); + + const {Server, StdioServerTransport} = await loadMcpSdk(); + const server = new Server({ + name: 'root-cms-mcp', + version: packageJson.version, + description: 'Expose Root CMS project data over the Model Context Protocol.', + }); + + registerTool( + server, + { + name: 'root_cms.get_doc', + description: + 'Fetch a document from the current Root CMS project by collection and slug.', + inputSchema: getDocInputJsonSchema, + }, + async (payload: unknown) => { + const input = + (payload as any)?.input ?? + (payload as any)?.arguments ?? + payload; + return handleGetDocRequest(cmsClient, input); + } + ); + + const transport = new StdioServerTransport(); + await server.connect(transport); + console.log('Root CMS MCP server listening on stdio. Press Ctrl+C to exit.'); + + await new Promise((resolve, reject) => { + const shutdown = () => { + try { + if (typeof transport.close === 'function') { + transport.close(); + } + } catch (err) { + reject(err); + return; + } + resolve(); + }; + process.once('SIGINT', shutdown); + process.once('SIGTERM', shutdown); + }); +} diff --git a/packages/root-cms/package.json b/packages/root-cms/package.json index d58643d0..efea53de 100644 --- a/packages/root-cms/package.json +++ b/packages/root-cms/package.json @@ -69,6 +69,7 @@ "@genkit-ai/vertexai": "1.18.0", "@google-cloud/firestore": "7.11.3", "@hello-pangea/dnd": "18.0.1", + "@modelcontextprotocol/sdk": "1.17.5", "body-parser": "1.20.2", "commander": "11.0.0", "csv-parse": "5.5.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bb40b0c6..cc9a6831 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -361,6 +361,9 @@ importers: '@hello-pangea/dnd': specifier: 18.0.1 version: 18.0.1(@preact/compat@18.3.1)(@preact/compat@18.3.1) + '@modelcontextprotocol/sdk': + specifier: 1.17.5 + version: 1.17.5 body-parser: specifier: 1.20.2 version: 1.20.2 From 99351ce2ca1e22e4382e5ded9464588ee66f02b2 Mon Sep 17 00:00:00 2001 From: Steven Le Date: Thu, 25 Sep 2025 15:48:27 -0700 Subject: [PATCH 2/2] refactor: share Root CMS doc tool between MCP and AI chat --- packages/root-cms/cli/mcp.ts | 107 ++----------- packages/root-cms/core/ai.ts | 7 + packages/root-cms/core/ai/tools/getDocTool.ts | 145 ++++++++++++++++++ 3 files changed, 165 insertions(+), 94 deletions(-) create mode 100644 packages/root-cms/core/ai/tools/getDocTool.ts diff --git a/packages/root-cms/cli/mcp.ts b/packages/root-cms/cli/mcp.ts index 56494c45..d6982351 100644 --- a/packages/root-cms/cli/mcp.ts +++ b/packages/root-cms/cli/mcp.ts @@ -1,77 +1,11 @@ import {loadRootConfig} from '@blinkk/root/node'; -import {z} from 'zod'; - import packageJson from '../package.json' assert {type: 'json'}; -import {DocMode, RootCMSClient, parseDocId} from '../core/client.js'; - -const DOC_MODES = ['draft', 'published'] as const satisfies DocMode[]; - -const getDocInputSchema = z - .object({ - docId: z - .string() - .describe('Fully-qualified doc id in the format "/".') - .optional(), - collectionId: z - .string() - .describe('Collection id (e.g. "Pages").') - .optional(), - slug: z - .string() - .describe('Doc slug (e.g. "home").') - .optional(), - mode: z - .enum(DOC_MODES) - .default('draft') - .describe('Whether to fetch the draft or published version of the doc.'), - }) - .refine( - (value) => { - if (value.docId) { - return true; - } - return Boolean(value.collectionId && value.slug); - }, - { - message: - 'Provide either "docId" or both "collectionId" and "slug" for the doc to fetch.', - path: ['docId'], - } - ); - -const getDocInputJsonSchema = { - type: 'object', - properties: { - docId: { - type: 'string', - description: - 'Fully-qualified doc id in the format "/" (e.g. "Pages/home").', - }, - collectionId: { - type: 'string', - description: 'Collection id (e.g. "Pages").', - }, - slug: { - type: 'string', - description: 'Doc slug (e.g. "home").', - }, - mode: { - type: 'string', - enum: [...DOC_MODES], - description: 'Whether to fetch the draft or published version of the doc.', - default: 'draft', - }, - }, - oneOf: [ - { - required: ['docId'], - }, - { - required: ['collectionId', 'slug'], - }, - ], - additionalProperties: false, -} as const; +import {RootCMSClient} from '../core/client.js'; +import { + fetchRootCmsDoc, + rootCmsGetDocInputJsonSchema, + rootCmsGetDocToolMetadata, +} from '../core/ai/tools/getDocTool.js'; type ToolResponse = { content: Array<{type: 'text'; text: string}>; @@ -132,33 +66,19 @@ async function handleGetDocRequest( rawPayload: unknown ): Promise { try { - const parsed = getDocInputSchema.parse(rawPayload); - let collectionId = parsed.collectionId; - let slug = parsed.slug; - if (parsed.docId) { - const docInfo = parseDocId(parsed.docId); - collectionId = docInfo.collection; - slug = docInfo.slug; - } - if (!collectionId || !slug) { - throw new Error( - 'A collection id and slug are required to fetch a doc from Root CMS.' - ); - } - const mode: DocMode = parsed.mode || 'draft'; - const doc = await cmsClient.getDoc(collectionId, slug, {mode}); - if (!doc) { + const result = await fetchRootCmsDoc(cmsClient, rawPayload); + if (!result.doc) { return { content: [ { type: 'text', - text: `Doc not found: ${collectionId}/${slug} (mode: ${mode})`, + text: `Doc not found: ${result.collectionId}/${result.slug} (mode: ${result.mode})`, }, ], isError: true, }; } - return formatDocForResponse(doc); + return formatDocForResponse(result.doc); } catch (error) { const message = error instanceof Error ? error.message : 'Unknown error fetching doc.'; @@ -189,10 +109,9 @@ export async function startMcpServer() { registerTool( server, { - name: 'root_cms.get_doc', - description: - 'Fetch a document from the current Root CMS project by collection and slug.', - inputSchema: getDocInputJsonSchema, + name: rootCmsGetDocToolMetadata.name, + description: rootCmsGetDocToolMetadata.description, + inputSchema: rootCmsGetDocInputJsonSchema, }, async (payload: unknown) => { const input = diff --git a/packages/root-cms/core/ai.ts b/packages/root-cms/core/ai.ts index 83bb86a3..892b73aa 100644 --- a/packages/root-cms/core/ai.ts +++ b/packages/root-cms/core/ai.ts @@ -12,6 +12,10 @@ import { } from '../shared/ai/prompts.js'; import {RootCMSClient} from './client.js'; import {CMSPluginOptions} from './plugin.js'; +import { + GenkitTool, + createRootCmsGetDocGenkitTool, +} from './ai/tools/getDocTool.js'; // Suppress the "Shutting down all Genkit servers..." message. logger.setLogLevel('warn'); @@ -102,6 +106,7 @@ export class Chat { history: HistoryItem[]; model: string; ai: Genkit; + getDocTool: GenkitTool; constructor( chatClient: ChatClient, @@ -128,6 +133,7 @@ export class Chat { }), ], }); + this.getDocTool = createRootCmsGetDocGenkitTool(this.cmsClient); } /** Builds the messages for the AI request. */ @@ -197,6 +203,7 @@ export class Chat { model: chatRequest.model, messages: chatRequest.messages, prompt: Array.isArray(prompt) ? prompt.flat() : prompt, + tools: [this.getDocTool], }); this.history = res.messages; await this.dbDoc().update({ diff --git a/packages/root-cms/core/ai/tools/getDocTool.ts b/packages/root-cms/core/ai/tools/getDocTool.ts new file mode 100644 index 00000000..97896194 --- /dev/null +++ b/packages/root-cms/core/ai/tools/getDocTool.ts @@ -0,0 +1,145 @@ +import {z} from 'zod'; +import {DocMode, RootCMSClient, parseDocId} from '../../client.js'; + +const DOC_MODES = ['draft', 'published'] as const satisfies DocMode[]; + +export const rootCmsGetDocToolMetadata = { + name: 'root_cms.get_doc', + description: + 'Fetch a document from the current Root CMS project by collection and slug.', +} as const; + +export const rootCmsGetDocInputSchema = z + .object({ + docId: z + .string() + .describe('Fully-qualified doc id in the format "/".') + .optional(), + collectionId: z + .string() + .describe('Collection id (e.g. "Pages").') + .optional(), + slug: z.string().describe('Doc slug (e.g. "home").').optional(), + mode: z + .enum(DOC_MODES) + .default('draft') + .describe('Whether to fetch the draft or published version of the doc.'), + }) + .refine( + (value) => { + if (value.docId) { + return true; + } + return Boolean(value.collectionId && value.slug); + }, + { + message: + 'Provide either "docId" or both "collectionId" and "slug" for the doc to fetch.', + path: ['docId'], + } + ); + +export type RootCmsGetDocInput = z.infer; + +export const rootCmsGetDocInputJsonSchema = { + type: 'object', + properties: { + docId: { + type: 'string', + description: + 'Fully-qualified doc id in the format "/" (e.g. "Pages/home").', + }, + collectionId: { + type: 'string', + description: 'Collection id (e.g. "Pages").', + }, + slug: { + type: 'string', + description: 'Doc slug (e.g. "home").', + }, + mode: { + type: 'string', + enum: [...DOC_MODES], + description: 'Whether to fetch the draft or published version of the doc.', + default: 'draft', + }, + }, + oneOf: [ + { + required: ['docId'], + }, + { + required: ['collectionId', 'slug'], + }, + ], + additionalProperties: false, +} as const; + +export interface RootCmsGetDocContext { + collectionId: string; + slug: string; + mode: DocMode; +} + +export interface RootCmsGetDocResult extends RootCmsGetDocContext { + doc: unknown | null; +} + +export function normalizeRootCmsGetDocInput( + rawInput: unknown +): RootCmsGetDocContext { + const parsed = rootCmsGetDocInputSchema.parse(rawInput); + let collectionId = parsed.collectionId; + let slug = parsed.slug; + if (parsed.docId) { + const docInfo = parseDocId(parsed.docId); + collectionId = docInfo.collection; + slug = docInfo.slug; + } + if (!collectionId || !slug) { + throw new Error( + 'A collection id and slug are required to fetch a doc from Root CMS.' + ); + } + const mode: DocMode = parsed.mode ?? 'draft'; + return {collectionId, slug, mode}; +} + +export async function fetchRootCmsDoc( + cmsClient: RootCMSClient, + rawInput: unknown +): Promise { + const context = normalizeRootCmsGetDocInput(rawInput); + const doc = await cmsClient.getDoc(context.collectionId, context.slug, { + mode: context.mode, + }); + return {...context, doc}; +} + +export type GenkitTool = { + name: string; + description: string; + inputSchema: unknown; + outputSchema: unknown; + handler: (input: unknown) => Promise; +}; + +export function createRootCmsGetDocGenkitTool( + cmsClient: RootCMSClient +): GenkitTool { + return { + name: rootCmsGetDocToolMetadata.name, + description: rootCmsGetDocToolMetadata.description, + inputSchema: rootCmsGetDocInputSchema, + outputSchema: z.any(), + async handler(input: unknown) { + const result = await fetchRootCmsDoc(cmsClient, input); + if (!result.doc) { + throw new Error( + `Doc not found: ${result.collectionId}/${result.slug} (mode: ${result.mode})` + ); + } + return result.doc; + }, + }; +}