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
109 changes: 109 additions & 0 deletions packages/root-cms/core/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ import {getAuth, DecodedIdToken} from 'firebase-admin/auth';
import {Firestore, getFirestore} from 'firebase-admin/firestore';
import * as jsonwebtoken from 'jsonwebtoken';
import sirv from 'sirv';
import glob from 'tiny-glob';
import {z} from 'zod';
import {SSEEvent, SSESchemaChangedEvent} from '../shared/sse.js';
import {type RootAiModel} from './ai.js';
import {api} from './api.js';
Expand Down Expand Up @@ -723,6 +725,113 @@ export function cmsPlugin(options: CMSPluginOptions): CMSPlugin {
}
});
},

configureMcpServer: async (server, {rootConfig}) => {
const cmsClient = new RootCMSClient(rootConfig);

server.tool(
'cms.listCollections',
'Lists all CMS collections defined in the project',
{},
async () => {
const collectionFileNames = await glob('*.schema.ts', {
cwd: path.join(rootConfig.rootDir, 'collections'),
});
const collectionIds = collectionFileNames.map((filename) =>
filename.slice(0, -'.schema.ts'.length)
);
return {
content: [
{type: 'text', text: JSON.stringify(collectionIds, null, 2)},
],
};
}
);

server.tool(
'cms.listDocs',
'Lists documents in a CMS collection',
{
collectionId: z.string().describe('The collection ID'),
mode: z
.enum(['draft', 'published'])
.default('draft')
.describe('Document mode'),
limit: z
.number()
.optional()
.describe('Max number of documents to return'),
},
async ({collectionId, mode, limit}) => {
const result = await cmsClient.listDocs(collectionId, {
mode,
limit,
});
const docs = result.docs.map((doc: any) => ({
id: doc.id,
slug: doc.slug,
sys: {
modifiedAt: doc.sys?.modifiedAt,
modifiedBy: doc.sys?.modifiedBy,
},
}));
return {
content: [{type: 'text', text: JSON.stringify(docs, null, 2)}],
};
}
);

server.tool(
'cms.getDoc',
'Reads a single document from the CMS',
{
collectionId: z.string().describe('The collection ID'),
slug: z.string().describe('The document slug'),
mode: z
.enum(['draft', 'published'])
.default('draft')
.describe('Document mode'),
},
async ({collectionId, slug, mode}) => {
const doc = await cmsClient.getDoc(collectionId, slug, {mode});
if (!doc) {
return {
content: [
{
type: 'text',
text: `Document not found: ${collectionId}/${slug}`,
},
],
isError: true,
};
}
return {
content: [{type: 'text', text: JSON.stringify(doc, null, 2)}],
};
}
);

server.tool(
'cms.saveDoc',
'Saves draft data to a CMS document',
{
docId: z
.string()
.describe('The document ID (e.g. "Pages/home")'),
data: z
.record(z.string(), z.any())
.describe('The fields data to save'),
},
async ({docId, data}) => {
await cmsClient.saveDraftData(docId, data);
return {
content: [
{type: 'text', text: `Successfully saved draft: ${docId}`},
],
};
}
);
},
};

if (process.env.NODE_ENV === 'development' && options.watch !== false) {
Expand Down
4 changes: 3 additions & 1 deletion packages/root-cms/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@
"@ag-grid-community/core": "32.3.9",
"@ag-grid-community/react": "32.3.9",
"@ag-grid-community/styles": "32.3.9",
"@modelcontextprotocol/sdk": "1.12.0",
"@genkit-ai/ai": "1.26.0",
"@genkit-ai/core": "1.26.0",
"@genkit-ai/google-genai": "1.26.0",
Expand All @@ -90,7 +91,8 @@
"kleur": "4.1.5",
"react-easy-crop": "5.5.6",
"sirv": "2.0.3",
"tiny-glob": "0.2.9"
"tiny-glob": "0.2.9",
"zod": "3.24.4"
},
"//": "NOTE(stevenle): due to compat issues with mantine and preact, mantine is pinned to v4.2.12",
"devDependencies": {
Expand Down
4 changes: 3 additions & 1 deletion packages/root/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@
"@types/micromatch": "4.0.6",
"bundle-require": "4.0.2",
"busboy": "1.6.0",
"@modelcontextprotocol/sdk": "1.12.0",
"commander": "11.0.0",
"compression": "1.7.4",
"cookie-parser": "1.4.6",
Expand All @@ -81,7 +82,8 @@
"source-map-support": "0.5.21",
"tiny-glob": "0.2.9",
"vite": "7.1.4",
"workspace-tools": "0.37.0"
"workspace-tools": "0.37.0",
"zod": "3.25.0"
},
"peerDependencies": {
"firebase-admin": ">=11",
Expand Down
6 changes: 5 additions & 1 deletion packages/root/src/cli/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,11 @@ class CliRunner {
'--host <host>',
'network address the server should listen on, e.g. 127.0.0.1'
)
.action(dev);
.option('--mcp', 'start an MCP server for AI tool integration')
.action((rootPackageDir, options) => {
options.version = this.version;
dev(rootPackageDir, options);
});
program
.command('gae-deploy <appdir>')
.description(
Expand Down
39 changes: 35 additions & 4 deletions packages/root/src/cli/dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ type RenderModule = typeof import('../render/render.js');

export interface DevOptions {
host?: string;
mcp?: boolean;
version?: string;
}

export async function dev(rootProjectDir?: string, options?: DevOptions) {
Expand All @@ -42,9 +44,11 @@ export async function dev(rootProjectDir?: string, options?: DevOptions) {
const defaultPort = parseInt(process.env.PORT || '4007');
const host = options?.host || 'localhost';
const port = await findOpenPort(defaultPort, defaultPort + 10);
const mcpMode = options?.mcp ?? false;

let currentServer: http.Server | null = null;
let currentViteServer: ViteDevServer | null = null;
let currentMcpHandle: {close: () => Promise<void>} | null = null;

async function start() {
const server = await createDevServer({rootDir, port});
Expand All @@ -62,8 +66,17 @@ export async function dev(rootProjectDir?: string, options?: DevOptions) {
}
console.log(`${dim('┃')} mode: development`);
console.log();

currentServer = server.listen(port, host);

if (mcpMode) {
const {startMcpServer} = await import('./mcp.js');
currentMcpHandle = await startMcpServer({
rootConfig,
version: options?.version || '1.0.0',
});
}

// Watch for config changes.
const rootConfigDependencies: string[] = server.get(
'rootConfigDependencies'
Expand All @@ -75,15 +88,23 @@ export async function dev(rootProjectDir?: string, options?: DevOptions) {
viteServer.watcher.add(dependencies);
viteServer.watcher.on('change', async (file) => {
if (dependencies.includes(file)) {
console.log(
`\n${dim('┃')} root.config.ts changed. restarting server...`
);
if (mcpMode) {
console.error('root.config.ts changed. restarting server...');
} else {
console.log(
`\n${dim('┃')} root.config.ts changed. restarting server...`
);
}
await restart();
}
});
}

async function restart() {
async function shutdown() {
if (currentMcpHandle) {
await currentMcpHandle.close();
currentMcpHandle = null;
}
if (currentServer) {
currentServer.close();
currentServer = null;
Expand All @@ -92,9 +113,19 @@ export async function dev(rootProjectDir?: string, options?: DevOptions) {
await currentViteServer.close();
currentViteServer = null;
}
}

async function restart() {
await shutdown();
await start();
}

process.on('SIGINT', async () => {
await shutdown();
// eslint-disable-next-line no-process-exit
process.exit(0);
});

await start();
}

Expand Down
54 changes: 54 additions & 0 deletions packages/root/src/cli/mcp.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import {McpServer} from '@modelcontextprotocol/sdk/server/mcp.js';
import {StdioServerTransport} from '@modelcontextprotocol/sdk/server/stdio.js';
import {RootConfig} from '../core/config.js';

export interface McpServerOptions {
rootConfig: RootConfig;
version: string;
}

/**
* Creates and returns a configured McpServer instance. Does not connect
* the transport — the caller is responsible for that.
*/
export async function createMcpServer(
options: McpServerOptions
): Promise<McpServer> {
const {rootConfig} = options;
const plugins = rootConfig.plugins || [];

const server = new McpServer({
name: 'root-ai',
version: options.version,
});

for (const plugin of plugins) {
if (plugin.configureMcpServer) {
await plugin.configureMcpServer(server, {rootConfig});
}
}

return server;
}

export interface McpServerHandle {
close: () => Promise<void>;
}

/**
* Starts the MCP server with stdio transport. This function should be
* called when `root dev --mcp` is invoked.
*/
export async function startMcpServer(
options: McpServerOptions
): Promise<McpServerHandle> {
const server = await createMcpServer(options);
const transport = new StdioServerTransport();
await server.connect(transport);
console.error('Root.js MCP server running on stdio');
return {
close: async () => {
await server.close();
},
};
}
16 changes: 16 additions & 0 deletions packages/root/src/core/plugin.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type {McpServer} from '@modelcontextprotocol/sdk/server/mcp.js';
import {ViteDevServer, PluginOption as VitePlugin} from 'vite';
import {RootConfig} from './config.js';
import {NextFunction, Request, Response, Server} from './types.js';
Expand All @@ -16,6 +17,15 @@ export interface ConfigureServerOptions {
rootConfig: RootConfig;
}

export interface ConfigureMcpServerOptions {
rootConfig: RootConfig;
}

export type ConfigureMcpServerHook = (
server: McpServer,
options: ConfigureMcpServerOptions
) => MaybePromise<void>;

export interface PluginHooks {
/**
* Hook that runs before the build starts.
Expand Down Expand Up @@ -68,6 +78,12 @@ export interface Plugin {
res: Response,
next: NextFunction
) => void | Promise<void>;
/**
* Configures the MCP (Model Context Protocol) server. Plugins can use
* this hook to register MCP tools that will be available when the dev
* server is started with `--mcp`.
*/
configureMcpServer?: ConfigureMcpServerHook;
}

/**
Expand Down
Loading
Loading