Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion packages/root-cms/cli/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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};
144 changes: 144 additions & 0 deletions packages/root-cms/cli/mcp.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import {loadRootConfig} from '@blinkk/root/node';
import packageJson from '../package.json' assert {type: 'json'};
import {RootCMSClient} from '../core/client.js';
import {
fetchRootCmsDoc,
rootCmsGetDocInputJsonSchema,
rootCmsGetDocToolMetadata,
} from '../core/ai/tools/getDocTool.js';

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<ToolResponse>
) {
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<ToolResponse> {
try {
const result = await fetchRootCmsDoc(cmsClient, rawPayload);
if (!result.doc) {
return {
content: [
{
type: 'text',
text: `Doc not found: ${result.collectionId}/${result.slug} (mode: ${result.mode})`,
},
],
isError: true,
};
}
return formatDocForResponse(result.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: rootCmsGetDocToolMetadata.name,
description: rootCmsGetDocToolMetadata.description,
inputSchema: rootCmsGetDocInputJsonSchema,
},
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<void>((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);
});
Comment on lines +125 to +143

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Resolve MCP server when stdio closes

The CLI keeps the process alive by awaiting a promise that only resolves on SIGINT or SIGTERM. When the MCP client simply closes the stdio transport (the common shutdown path for child processes), no signal is delivered, so the handler in startMcpServer never resolves and the server continues running as an orphaned process. That means every disconnect leaks a hanging root-cms mcp process until it is manually killed. Consider also resolving the promise when the stdio transport closes or when server.connect completes, so the command exits automatically when its pipes are torn down.

Useful? React with 👍 / 👎.

}
7 changes: 7 additions & 0 deletions packages/root-cms/core/ai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -102,6 +106,7 @@ export class Chat {
history: HistoryItem[];
model: string;
ai: Genkit;
getDocTool: GenkitTool;

constructor(
chatClient: ChatClient,
Expand All @@ -128,6 +133,7 @@ export class Chat {
}),
],
});
this.getDocTool = createRootCmsGetDocGenkitTool(this.cmsClient);
}

/** Builds the messages for the AI request. */
Expand Down Expand Up @@ -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({
Expand Down
145 changes: 145 additions & 0 deletions packages/root-cms/core/ai/tools/getDocTool.ts
Original file line number Diff line number Diff line change
@@ -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 "<Collection>/<slug>".')
.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<typeof rootCmsGetDocInputSchema>;

export const rootCmsGetDocInputJsonSchema = {
type: 'object',
properties: {
docId: {
type: 'string',
description:
'Fully-qualified doc id in the format "<Collection>/<slug>" (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<RootCmsGetDocResult> {
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<unknown>;
};

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;
},
};
}
1 change: 1 addition & 0 deletions packages/root-cms/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.