diff --git a/apps/cli/commands/mcp/index.ts b/apps/cli/commands/mcp/index.ts new file mode 100644 index 0000000000..9d4b30e631 --- /dev/null +++ b/apps/cli/commands/mcp/index.ts @@ -0,0 +1,72 @@ +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import { __ } from '@wordpress/i18n'; +import { StudioArgv } from 'cli/types'; +import { registerAuthTools } from './tools/auth'; +import { registerFsTools } from './tools/files'; +import { registerPreviewTools } from './tools/preview'; +import { registerSiteTools } from './tools/sites'; +import { registerWpCliTool } from './tools/wp-cli'; + +function printInstallInstructions() { + console.log( + [ + __( 'Studio MCP Server' ), + '', + __( + 'Connects Claude Desktop, Claude Code, or any MCP-compatible AI app to your local WordPress sites.' + ), + '', + __( 'Available tools: site_list, site_start, site_stop, site_create, site_delete,' ), + __( ' site_status, site_set, fs_list_dir, fs_read_file, fs_write_file, fs_delete,' ), + __( ' wp, preview_list, preview_create, auth_status' ), + '', + __( 'Setup:' ), + '', + __( ' Claude Code:' ), + ' claude mcp add studio -- studio mcp', + '', + __( ' Claude Desktop (add to claude_desktop_config.json):' ), + ' {', + ' "mcpServers": {', + ' "studio": { "command": "studio", "args": ["mcp"] }', + ' }', + ' }', + '', + __( 'This command is intended to be run by an MCP client, not directly in a terminal.' ), + ].join( '\n' ) + ); +} + +export const registerCommand = ( yargs: StudioArgv ) => + yargs.command( { + command: 'mcp', + describe: __( 'Use Studio sites and tools from Claude or other AI apps' ), + builder: ( y ) => y.version( false ), + handler: async () => { + if ( process.stdin.isTTY ) { + printInstallInstructions(); + return; + } + + const server = new McpServer( { + name: 'studio', + version: __STUDIO_CLI_VERSION__, + } ); + + registerSiteTools( server ); + registerFsTools( server ); + registerWpCliTool( server ); + registerPreviewTools( server ); + registerAuthTools( server ); + + const transport = new StdioServerTransport(); + await server.connect( transport ); + + // server.connect() returns immediately; keep the process alive until + // the client closes the connection. + await new Promise< void >( ( resolve ) => { + transport.onclose = resolve; + } ); + }, + } ); diff --git a/apps/cli/commands/mcp/tools/auth.ts b/apps/cli/commands/mcp/tools/auth.ts new file mode 100644 index 0000000000..d4594b9aa3 --- /dev/null +++ b/apps/cli/commands/mcp/tools/auth.ts @@ -0,0 +1,28 @@ +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { __ } from '@wordpress/i18n'; +import { getUserInfo } from 'cli/lib/api'; +import { getAuthToken } from 'cli/lib/appdata'; + +function ok( data: unknown ) { + return { content: [ { type: 'text' as const, text: JSON.stringify( data, null, 2 ) } ] }; +} + +export function registerAuthTools( server: McpServer ) { + server.tool( 'auth_status', __( 'Check WordPress.com authentication status' ), {}, async () => { + try { + const token = await getAuthToken(); + const userData = await getUserInfo( token.accessToken ); + return ok( { + authenticated: true, + username: userData.username, + email: token.email, + displayName: token.displayName, + } ); + } catch ( error ) { + return ok( { + authenticated: false, + message: error instanceof Error ? error.message : String( error ), + } ); + } + } ); +} diff --git a/apps/cli/commands/mcp/tools/files.ts b/apps/cli/commands/mcp/tools/files.ts new file mode 100644 index 0000000000..9f3c611aba --- /dev/null +++ b/apps/cli/commands/mcp/tools/files.ts @@ -0,0 +1,126 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { __ } from '@wordpress/i18n'; +import { z } from 'zod'; +import { readAppdata } from 'cli/lib/appdata'; + +function ok( data: unknown ) { + return { content: [ { type: 'text' as const, text: JSON.stringify( data, null, 2 ) } ] }; +} + +function err( message: string ) { + return { + content: [ { type: 'text' as const, text: message } ], + isError: true as const, + }; +} + +function resolveInsideRoot( root: string, userPath: string ): string { + const resolvedRoot = path.resolve( root ); + const resolved = path.resolve( root, userPath ); + if ( resolved !== resolvedRoot && ! resolved.startsWith( resolvedRoot + path.sep ) ) { + throw new Error( __( 'Path traversal not allowed' ) ); + } + return resolved; +} + +async function getSiteRoot( sitePath: string ): Promise< string > { + const appdata = await readAppdata(); + const site = appdata.sites.find( ( s ) => s.path === sitePath ); + if ( ! site ) { + throw new Error( __( 'Site not found at the specified path' ) ); + } + return site.path; +} + +export function registerFsTools( server: McpServer ) { + server.tool( + 'fs_list_dir', + __( 'List files and directories inside a WordPress site' ), + { + sitePath: z.string().describe( __( 'Absolute path to the site directory' ) ), + dirPath: z + .string() + .optional() + .describe( __( 'Relative path within the site (default: site root)' ) ), + }, + async ( { sitePath, dirPath } ) => { + try { + const root = await getSiteRoot( sitePath ); + const target = resolveInsideRoot( root, dirPath ?? '.' ); + const entries = fs.readdirSync( target, { withFileTypes: true } ); + const result = entries.map( ( e ) => ( { + name: e.name, + type: e.isDirectory() ? 'directory' : 'file', + size: e.isFile() ? fs.statSync( path.join( target, e.name ) ).size : undefined, + } ) ); + return ok( result ); + } catch ( error ) { + return err( error instanceof Error ? error.message : String( error ) ); + } + } + ); + + server.tool( + 'fs_read_file', + __( 'Read a file inside a WordPress site' ), + { + sitePath: z.string().describe( __( 'Absolute path to the site directory' ) ), + filePath: z.string().describe( __( 'Relative path to the file within the site' ) ), + }, + async ( { sitePath, filePath } ) => { + try { + const root = await getSiteRoot( sitePath ); + const target = resolveInsideRoot( root, filePath ); + const content = fs.readFileSync( target, 'utf-8' ); + return ok( { content } ); + } catch ( error ) { + return err( error instanceof Error ? error.message : String( error ) ); + } + } + ); + + server.tool( + 'fs_write_file', + __( 'Write content to a file inside a WordPress site' ), + { + sitePath: z.string().describe( __( 'Absolute path to the site directory' ) ), + filePath: z.string().describe( __( 'Relative path to the file within the site' ) ), + content: z.string().describe( __( 'Content to write to the file' ) ), + }, + async ( { sitePath, filePath, content } ) => { + try { + const root = await getSiteRoot( sitePath ); + const target = resolveInsideRoot( root, filePath ); + fs.mkdirSync( path.dirname( target ), { recursive: true } ); + fs.writeFileSync( target, content, 'utf-8' ); + return ok( { success: true, path: target } ); + } catch ( error ) { + return err( error instanceof Error ? error.message : String( error ) ); + } + } + ); + + server.tool( + 'fs_delete', + __( 'Delete a file or directory inside a WordPress site' ), + { + sitePath: z.string().describe( __( 'Absolute path to the site directory' ) ), + targetPath: z.string().describe( __( 'Relative path to the file or directory to delete' ) ), + }, + async ( { sitePath, targetPath } ) => { + try { + const root = await getSiteRoot( sitePath ); + const target = resolveInsideRoot( root, targetPath ); + if ( target === path.resolve( root ) ) { + return err( __( 'Cannot delete the site root directory' ) ); + } + fs.rmSync( target, { recursive: true, force: true } ); + return ok( { success: true } ); + } catch ( error ) { + return err( error instanceof Error ? error.message : String( error ) ); + } + } + ); +} diff --git a/apps/cli/commands/mcp/tools/preview.ts b/apps/cli/commands/mcp/tools/preview.ts new file mode 100644 index 0000000000..d094864dc9 --- /dev/null +++ b/apps/cli/commands/mcp/tools/preview.ts @@ -0,0 +1,59 @@ +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { __ } from '@wordpress/i18n'; +import { z } from 'zod'; +import { runCommand as runPreviewCreateCommand } from 'cli/commands/preview/create'; +import { getAuthToken } from 'cli/lib/appdata'; +import { + formatDurationUntilExpiry, + getSnapshotsFromAppdata, + isSnapshotExpired, +} from 'cli/lib/snapshots'; + +function ok( data: unknown ) { + return { content: [ { type: 'text' as const, text: JSON.stringify( data, null, 2 ) } ] }; +} + +function err( message: string ) { + return { + content: [ { type: 'text' as const, text: message } ], + isError: true as const, + }; +} + +export function registerPreviewTools( server: McpServer ) { + server.tool( + 'preview_list', + __( 'List preview sites for a WordPress site' ), + { sitePath: z.string().describe( __( 'Absolute path to the site directory' ) ) }, + async ( { sitePath } ) => { + try { + const token = await getAuthToken(); + const snapshots = await getSnapshotsFromAppdata( token.id, sitePath ); + const result = snapshots.map( ( s ) => ( { + url: `https://${ s.url }`, + name: s.name, + date: new Date( s.date ).toISOString(), + expiresIn: formatDurationUntilExpiry( s.date ), + expired: isSnapshotExpired( s ), + } ) ); + return ok( result ); + } catch ( error ) { + return err( error instanceof Error ? error.message : String( error ) ); + } + } + ); + + server.tool( + 'preview_create', + __( 'Create a preview site from a WordPress site' ), + { sitePath: z.string().describe( __( 'Absolute path to the site directory' ) ) }, + async ( { sitePath } ) => { + try { + await runPreviewCreateCommand( sitePath ); + return ok( { success: true, message: __( 'Preview site created successfully' ) } ); + } catch ( error ) { + return err( error instanceof Error ? error.message : String( error ) ); + } + } + ); +} diff --git a/apps/cli/commands/mcp/tools/sites.ts b/apps/cli/commands/mcp/tools/sites.ts new file mode 100644 index 0000000000..12faee8da7 --- /dev/null +++ b/apps/cli/commands/mcp/tools/sites.ts @@ -0,0 +1,224 @@ +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { DEFAULT_PHP_VERSION, DEFAULT_WORDPRESS_VERSION } from '@studio/common/constants'; +import { getWordPressVersion } from '@studio/common/lib/get-wordpress-version'; +import { decodePassword } from '@studio/common/lib/passwords'; +import { __ } from '@wordpress/i18n'; +import { z } from 'zod'; +import { runCommand as runCreateCommand } from 'cli/commands/site/create'; +import { runCommand as runDeleteCommand } from 'cli/commands/site/delete'; +import { runCommand as runSetCommand, SetCommandOptions } from 'cli/commands/site/set'; +import { runCommand as runStartCommand } from 'cli/commands/site/start'; +import { runCommand as runStopCommand, Mode as StopMode } from 'cli/commands/site/stop'; +import { getSiteUrl, readAppdata } from 'cli/lib/appdata'; +import { connect, disconnect } from 'cli/lib/pm2-manager'; +import { isSiteRunning } from 'cli/lib/site-utils'; +import { isServerRunning } from 'cli/lib/wordpress-server-manager'; + +function ok( data: unknown ) { + return { content: [ { type: 'text' as const, text: JSON.stringify( data, null, 2 ) } ] }; +} + +function err( message: string ) { + return { + content: [ { type: 'text' as const, text: message } ], + isError: true as const, + }; +} + +export function registerSiteTools( server: McpServer ) { + server.tool( 'site_list', __( 'List all WordPress sites in Studio' ), {}, async () => { + try { + const appdata = await readAppdata(); + await connect(); + try { + const sites = await Promise.all( + appdata.sites.map( async ( site ) => { + const running = await isSiteRunning( site ); + return { + id: site.id, + name: site.name, + path: site.path, + url: getSiteUrl( site ), + status: running ? 'online' : 'offline', + phpVersion: site.phpVersion, + wpVersion: getWordPressVersion( site.path ), + }; + } ) + ); + return ok( sites ); + } finally { + await disconnect(); + } + } catch ( error ) { + return err( error instanceof Error ? error.message : String( error ) ); + } + } ); + + server.tool( + 'site_status', + __( 'Get detailed status of a WordPress site' ), + { path: z.string().describe( __( 'Absolute path to the site directory' ) ) }, + async ( { path: sitePath } ) => { + try { + await connect(); + try { + const appdata = await readAppdata(); + const site = appdata.sites.find( ( s ) => s.path === sitePath ); + if ( ! site ) { + return err( __( 'Site not found at the specified path' ) ); + } + const processInfo = await isServerRunning( site.id ); + const online = !! processInfo; + const siteUrl = getSiteUrl( site ); + return ok( { + id: site.id, + name: site.name, + path: site.path, + url: siteUrl, + autoLoginUrl: online + ? `${ siteUrl }/studio-auto-login?redirect_to=%2Fwp-admin%2F` + : undefined, + status: online ? 'online' : 'offline', + phpVersion: site.phpVersion, + wpVersion: getWordPressVersion( site.path ), + adminUsername: site.adminUsername ?? 'admin', + adminPassword: site.adminPassword ? decodePassword( site.adminPassword ) : undefined, + adminEmail: site.adminEmail, + customDomain: site.customDomain, + enableHttps: site.enableHttps, + xdebug: site.enableXdebug ?? false, + } ); + } finally { + await disconnect(); + } + } catch ( error ) { + return err( error instanceof Error ? error.message : String( error ) ); + } + } + ); + + server.tool( + 'site_start', + __( 'Start a WordPress site' ), + { path: z.string().describe( __( 'Absolute path to the site directory' ) ) }, + async ( { path: sitePath } ) => { + try { + await runStartCommand( sitePath, true, true ); + return ok( { success: true, message: __( 'Site started successfully' ) } ); + } catch ( error ) { + return err( error instanceof Error ? error.message : String( error ) ); + } + } + ); + + server.tool( + 'site_stop', + __( 'Stop a WordPress site' ), + { path: z.string().describe( __( 'Absolute path to the site directory' ) ) }, + async ( { path: sitePath } ) => { + try { + await runStopCommand( StopMode.STOP_SINGLE_SITE, sitePath, false ); + return ok( { success: true, message: __( 'Site stopped successfully' ) } ); + } catch ( error ) { + return err( error instanceof Error ? error.message : String( error ) ); + } + } + ); + + server.tool( + 'site_create', + __( 'Create a new WordPress site' ), + { + name: z.string().describe( __( 'Site name' ) ), + path: z.string().describe( __( 'Absolute path where the site directory will be created' ) ), + php: z + .string() + .optional() + .describe( __( `PHP version (e.g. "8.2", default: "${ DEFAULT_PHP_VERSION }")` ) ), + wp: z + .string() + .optional() + .describe( + __( + `WordPress version (e.g. "6.7", "latest", default: "${ DEFAULT_WORDPRESS_VERSION }")` + ) + ), + start: z + .boolean() + .optional() + .describe( __( 'Start the site after creation (default: true)' ) ), + }, + async ( args ) => { + try { + await runCreateCommand( args.path, { + name: args.name, + wpVersion: args.wp ?? DEFAULT_WORDPRESS_VERSION, + phpVersion: ( args.php ?? DEFAULT_PHP_VERSION ) as never, + enableHttps: false, + noStart: ! ( args.start ?? true ), + skipBrowser: true, + skipLogDetails: true, + } ); + return ok( { success: true, message: __( 'Site created successfully' ) } ); + } catch ( error ) { + return err( error instanceof Error ? error.message : String( error ) ); + } + } + ); + + server.tool( + 'site_delete', + __( 'Delete a WordPress site' ), + { + path: z.string().describe( __( 'Absolute path to the site directory' ) ), + deleteFiles: z + .boolean() + .optional() + .describe( __( 'Also move site files to trash (default: false)' ) ), + }, + async ( { path: sitePath, deleteFiles } ) => { + try { + await runDeleteCommand( sitePath, deleteFiles ?? false ); + return ok( { success: true, message: __( 'Site deleted successfully' ) } ); + } catch ( error ) { + return err( error instanceof Error ? error.message : String( error ) ); + } + } + ); + + server.tool( + 'site_set', + __( 'Update settings for a WordPress site' ), + { + path: z.string().describe( __( 'Absolute path to the site directory' ) ), + name: z.string().optional().describe( __( 'New site name' ) ), + php: z.string().optional().describe( __( 'PHP version' ) ), + wp: z.string().optional().describe( __( 'WordPress version' ) ), + domain: z.string().optional().describe( __( 'Custom domain' ) ), + https: z.boolean().optional().describe( __( 'Enable HTTPS' ) ), + xdebug: z.boolean().optional().describe( __( 'Enable Xdebug' ) ), + adminUsername: z.string().optional().describe( __( 'Admin username' ) ), + adminPassword: z.string().optional().describe( __( 'Admin password' ) ), + adminEmail: z.string().optional().describe( __( 'Admin email' ) ), + }, + async ( { path: sitePath, ...options } ) => { + try { + const setOptions: SetCommandOptions = { + name: options.name, + php: options.php, + wp: options.wp, + domain: options.domain, + https: options.https, + xdebug: options.xdebug, + adminUsername: options.adminUsername, + adminPassword: options.adminPassword, + adminEmail: options.adminEmail, + }; + await runSetCommand( sitePath, setOptions ); + return ok( { success: true, message: __( 'Site updated successfully' ) } ); + } catch ( error ) { + return err( error instanceof Error ? error.message : String( error ) ); + } + } + ); +} diff --git a/apps/cli/commands/mcp/tools/wp-cli.ts b/apps/cli/commands/mcp/tools/wp-cli.ts new file mode 100644 index 0000000000..7b139017bc --- /dev/null +++ b/apps/cli/commands/mcp/tools/wp-cli.ts @@ -0,0 +1,49 @@ +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { __ } from '@wordpress/i18n'; +import { z } from 'zod'; +import { readAppdata } from 'cli/lib/appdata'; +import { connect, disconnect } from 'cli/lib/pm2-manager'; +import { sendWpCliCommand } from 'cli/lib/wordpress-server-manager'; + +function ok( data: unknown ) { + return { content: [ { type: 'text' as const, text: JSON.stringify( data, null, 2 ) } ] }; +} + +function err( message: string ) { + return { + content: [ { type: 'text' as const, text: message } ], + isError: true as const, + }; +} + +export function registerWpCliTool( server: McpServer ) { + server.tool( + 'wp', + __( 'Run a WP-CLI command on a running WordPress site' ), + { + sitePath: z.string().describe( __( 'Absolute path to the site directory' ) ), + args: z + .array( z.string() ) + .describe( __( 'WP-CLI arguments (e.g. ["plugin", "list", "--format=json"])' ) ), + }, + async ( { sitePath, args } ) => { + try { + const appdata = await readAppdata(); + const site = appdata.sites.find( ( s ) => s.path === sitePath ); + if ( ! site ) { + return err( __( 'Site not found at the specified path' ) ); + } + + await connect(); + try { + const result = await sendWpCliCommand( site.id, args ); + return ok( result ); + } finally { + await disconnect(); + } + } catch ( error ) { + return err( error instanceof Error ? error.message : String( error ) ); + } + } + ); +} diff --git a/apps/cli/index.ts b/apps/cli/index.ts index e0f084a341..efbc0859e9 100644 --- a/apps/cli/index.ts +++ b/apps/cli/index.ts @@ -12,6 +12,7 @@ import { commandHandler as eventsCommandHandler } from 'cli/commands/_events'; import { registerCommand as registerAuthLoginCommand } from 'cli/commands/auth/login'; import { registerCommand as registerAuthLogoutCommand } from 'cli/commands/auth/logout'; import { registerCommand as registerAuthStatusCommand } from 'cli/commands/auth/status'; +import { registerCommand as registerMcpCommand } from 'cli/commands/mcp/index'; import { registerCommand as registerCreateCommand } from 'cli/commands/preview/create'; import { registerCommand as registerDeleteCommand } from 'cli/commands/preview/delete'; import { registerCommand as registerListCommand } from 'cli/commands/preview/list'; @@ -124,9 +125,11 @@ async function main() { command: '_events', describe: false, // Hidden command handler: eventsCommandHandler, - } ) - .demandCommand( 1, __( 'You must provide a valid command' ) ) - .strict(); + } ); + + registerMcpCommand( studioArgv ); + + studioArgv.demandCommand( 1, __( 'You must provide a valid command' ) ).strict(); await studioArgv.argv; } diff --git a/apps/cli/mcp-bundle/.gitignore b/apps/cli/mcp-bundle/.gitignore new file mode 100644 index 0000000000..d557af4769 --- /dev/null +++ b/apps/cli/mcp-bundle/.gitignore @@ -0,0 +1 @@ +*.mcpb diff --git a/apps/cli/mcp-bundle/.mcpbignore b/apps/cli/mcp-bundle/.mcpbignore new file mode 100644 index 0000000000..d557af4769 --- /dev/null +++ b/apps/cli/mcp-bundle/.mcpbignore @@ -0,0 +1 @@ +*.mcpb diff --git a/apps/cli/mcp-bundle/icon.png b/apps/cli/mcp-bundle/icon.png new file mode 100644 index 0000000000..c859e73b72 Binary files /dev/null and b/apps/cli/mcp-bundle/icon.png differ diff --git a/apps/cli/mcp-bundle/manifest.json b/apps/cli/mcp-bundle/manifest.json new file mode 100644 index 0000000000..b3c14db8a2 --- /dev/null +++ b/apps/cli/mcp-bundle/manifest.json @@ -0,0 +1,61 @@ +{ + "manifest_version": "0.3", + "name": "wordpress-studio", + "display_name": "WordPress Studio", + "version": "1.7.5", + "description": "Manage local WordPress sites via WordPress Studio", + "long_description": "Control your local WordPress sites directly from Claude. List, start, stop, create, and delete sites. Read and write site files. Run WP-CLI commands. Create and manage preview sites on WordPress.com. Requires WordPress Studio to be installed.", + "author": { + "name": "Automattic Inc.", + "url": "https://developer.wordpress.com/studio" + }, + "repository": { + "type": "git", + "url": "https://github.com/Automattic/studio" + }, + "homepage": "https://developer.wordpress.com/studio", + "documentation": "https://github.com/Automattic/studio", + "support": "https://github.com/Automattic/studio/issues", + "icon": "icon.png", + "server": { + "type": "node", + "entry_point": "server/index.js", + "mcp_config": { + "command": "node", + "args": ["${__dirname}/server/index.js"], + "platform_overrides": { + "win32": { + "command": "node", + "args": ["${__dirname}/server/index.js"] + } + } + } + }, + "tools": [ + { "name": "site_list", "description": "List all WordPress sites in Studio" }, + { "name": "site_status", "description": "Get detailed status of a WordPress site" }, + { "name": "site_start", "description": "Start a WordPress site" }, + { "name": "site_stop", "description": "Stop a WordPress site" }, + { "name": "site_create", "description": "Create a new WordPress site" }, + { "name": "site_delete", "description": "Delete a WordPress site" }, + { "name": "site_set", "description": "Update settings for a WordPress site" }, + { "name": "fs_list_dir", "description": "List files inside a WordPress site" }, + { "name": "fs_read_file", "description": "Read a file inside a WordPress site" }, + { "name": "fs_write_file", "description": "Write content to a file inside a WordPress site" }, + { "name": "fs_delete", "description": "Delete a file or directory inside a WordPress site" }, + { "name": "wp", "description": "Run a WP-CLI command on a running WordPress site" }, + { "name": "preview_list", "description": "List preview sites for a WordPress site" }, + { "name": "preview_create", "description": "Create a preview site from a WordPress site" }, + { "name": "auth_status", "description": "Check WordPress.com authentication status" } + ], + "keywords": ["wordpress", "studio", "local", "development", "wp-cli", "preview"], + "license": "GPL-2.0-or-later", + "compatibility": { + "claude_desktop": ">=0.10.0", + "platforms": ["darwin", "win32"], + "runtimes": { + "node": ">=18.0.0" + } + }, + "privacy_policies": [] +} diff --git a/apps/cli/mcp-bundle/server/index.js b/apps/cli/mcp-bundle/server/index.js new file mode 100644 index 0000000000..e2f9aafd40 --- /dev/null +++ b/apps/cli/mcp-bundle/server/index.js @@ -0,0 +1,64 @@ +#!/usr/bin/env node +'use strict'; + +/** + * WordPress Studio MCP Bundle launcher + * + * Locates the Studio app's bundled CLI and re-executes it as an MCP server + * using the Node.js runtime that Claude Desktop provides (this script's runtime). + */ + +const { spawnSync } = require( 'child_process' ); +const fs = require( 'fs' ); +const path = require( 'path' ); + +function findStudioCli() { + const candidates = []; + + // Dev build: check for a built CLI next to this bundle's directory. + // Resolved relative to this file: mcp-bundle/server/index.js → apps/cli/dist/cli/main.js + candidates.push( path.resolve( __dirname, '..', '..', 'dist', 'cli', 'main.js' ) ); + + if ( process.platform === 'darwin' ) { + candidates.push( + '/Applications/Studio.app/Contents/Resources/cli/main.js', + path.join( + process.env.HOME || '', + 'Applications/Studio.app/Contents/Resources/cli/main.js' + ) + ); + } else if ( process.platform === 'win32' ) { + const programFiles = process.env[ 'ProgramFiles' ] || 'C:\\Program Files'; + const localAppData = process.env[ 'LOCALAPPDATA' ] || ''; + candidates.push( + path.join( programFiles, 'Studio', 'resources', 'cli', 'main.js' ), + path.join( localAppData, 'Programs', 'Studio', 'resources', 'cli', 'main.js' ) + ); + } + + for ( const candidate of candidates ) { + if ( fs.existsSync( candidate ) ) { + return candidate; + } + } + + return null; +} + +const cliPath = findStudioCli(); +if ( ! cliPath ) { + process.stderr.write( + 'WordPress Studio is not installed.\n' + + 'Download and install it from: https://developer.wordpress.com/studio\n' + ); + process.exit( 1 ); +} + +// Use the same Node.js runtime that is executing this script (provided by Claude Desktop). +// Start the MCP server, inheriting stdin/stdout/stderr for JSON-RPC transport. +const result = spawnSync( process.execPath, [ cliPath, 'mcp' ], { + stdio: 'inherit', + env: process.env, +} ); + +process.exit( result.status ?? 1 ); diff --git a/apps/cli/package.json b/apps/cli/package.json index 471ba53241..64929daad8 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -7,6 +7,7 @@ "license": "GPL-2.0-or-later", "main": "index.js", "dependencies": { + "@modelcontextprotocol/sdk": "^1.0.0", "@php-wasm/universal": "3.1.4", "@studio/common": "file:../../tools/common", "@vscode/sudo-prompt": "^9.3.2", @@ -26,7 +27,8 @@ "build": "vite build --config ./vite.config.ts", "install:bundle": "npm install --omit=dev --no-package-lock --no-progress --install-links --no-workspaces && patch-package && node ../../scripts/remove-fs-ext-other-platform-binaries.mjs", "package": "npm run install:bundle && npm run build", - "watch": "vite build --config ./vite.config.ts --watch" + "watch": "vite build --config ./vite.config.ts --watch", + "mcp-bundle": "mcpb pack mcp-bundle dist/wordpress-studio.mcpb" }, "devDependencies": { "@types/archiver": "^6.0.4", diff --git a/docs/design-docs/mcp.md b/docs/design-docs/mcp.md new file mode 100644 index 0000000000..dd9b51570a --- /dev/null +++ b/docs/design-docs/mcp.md @@ -0,0 +1,209 @@ +# MCP Integration Design + +## Overview + +Add a `studio mcp` subcommand to the Studio CLI that implements an MCP (Model Context Protocol) server over stdin/stdout. This lets Claude Desktop (or any MCP client) manage local WordPress sites directly. + +There are two ways to connect Claude Desktop to the Studio MCP server: + +**Option A — Manual config** (edit `claude_desktop_config.json` directly): +```json +{ + "mcpServers": { + "studio": { + "command": "studio", + "args": ["mcp"] + } + } +} +``` + +**Option B — `.mcpb` drag-and-drop bundle** (see [MCP Bundle](#mcp-bundle) section below). + +## Why build it into the CLI (not a separate package) + +A [prototype exists](https://github.com/nightnei/wordpress-developer-mcp-server) as a standalone npm package. The core overengineering there is that it's a separate tool that shells out to `studio` as a child process. Building MCP into the CLI instead means: + +- **No separate install** — users who have Studio already have the MCP server +- **No Node.js download** — runs with the same Node that ships with Studio +- **No subprocess overhead** — direct function calls instead of spawning `studio site list` +- **No drift** — MCP tools call the same `runCommand()` functions the CLI already uses + +## Architecture + +The `studio mcp` command starts a JSON-RPC 2.0 server over stdin/stdout using `@modelcontextprotocol/sdk`. It reuses existing CLI command handler functions directly — no subprocess spawning. + +``` +apps/cli/commands/mcp/ + index.ts ← registers `studio mcp` yargs command, starts StdioServerTransport + tools/ + sites.ts ← wraps existing site runCommand() functions + files.ts ← fs read/write/list with path containment + wp-cli.ts ← passes through to existing wp command + preview.ts ← wraps existing preview commands + auth.ts ← wraps existing auth status command +``` + +Total new code: ~300–400 lines, mostly thin wrappers around already-existing functions. + +## Dependency + +Add `@modelcontextprotocol/sdk` to `apps/cli/package.json`. This single package handles all JSON-RPC framing, the MCP handshake (`initialize` / `initialized`), `tools/list` dispatch, and `tools/call` routing. + +## Tools + +15 tools across 5 categories: + +### Sites (7) +| Tool | Description | +|------|-------------| +| `site_list` | List all local sites | +| `site_status` | Get site details (URL, credentials, PHP/WP versions) | +| `site_start` | Start a site | +| `site_stop` | Stop a site | +| `site_create` | Create a new site (supports WP version, PHP version) | +| `site_delete` | Delete a site | +| `site_set` | Modify site settings (domain, HTTPS, PHP/WP version, Xdebug) | + +### File System (4) +| Tool | Description | +|------|-------------| +| `fs_list_dir` | List files in a directory | +| `fs_read_file` | Read a text file | +| `fs_write_file` | Create or overwrite a file | +| `fs_delete` | Delete a file or directory | + +All file operations use path containment — paths must resolve inside the site root to prevent directory traversal. + +### WP-CLI (1) +| Tool | Description | +|------|-------------| +| `wp` | Run arbitrary WP-CLI commands against a running site | + +### Preview Sites (2) +| Tool | Description | +|------|-------------| +| `preview_list` | List previews for a site | +| `preview_create` | Create a shareable preview URL | + +### Auth (1) +| Tool | Description | +|------|-------------| +| `auth_status` | Check WordPress.com login status | + +## MCP Bundle + +The `.mcpb` (MCP Bundle) format lets users install the Studio MCP server into Claude Desktop by dragging a single file — no terminal required. + +### Structure + +``` +apps/cli/mcp-bundle/ + manifest.json ← bundle metadata, tool declarations, entry point + server/index.js ← launcher: finds the Studio CLI and re-execs it + icon.png ← Studio app icon (1024×1024 PNG) + .gitignore ← excludes *.mcpb build artifacts + .mcpbignore ← excludes *.mcpb from mcpb pack input +``` + +The generated `.mcpb` file is a zip archive and is **not committed** — it lives in `apps/cli/dist/` alongside the built CLI. + +### How the launcher works + +`server/index.js` is a small Node script (no dependencies) that Claude Desktop executes using its own bundled Node.js. It searches for the Studio CLI in this order: + +1. `../../dist/cli/main.js` relative to the bundle — picks up a **dev build** at `apps/cli/dist/cli/main.js` when the bundle is installed from this repo +2. `/Applications/Studio.app/Contents/Resources/cli/main.js` (macOS system install) +3. `~/Applications/Studio.app/...` (macOS user install) +4. `%ProgramFiles%\Studio\resources\cli\main.js` (Windows) +5. `%LOCALAPPDATA%\Programs\Studio\resources\cli\main.js` (Windows user install) + +Once found, it re-execs `node [cli-path] mcp`, inheriting stdin/stdout/stderr for the JSON-RPC transport. + +### Building + +Requires [`@anthropic-ai/mcpb`](https://github.com/modelcontextprotocol/mcpb) CLI: + +```bash +npm install -g @anthropic-ai/mcpb +``` + +Then from `apps/cli/`: + +```bash +npm run mcp-bundle +# outputs: apps/cli/dist/wordpress-studio.mcpb +``` + +Or directly: + +```bash +mcpb pack apps/cli/mcp-bundle apps/cli/dist/wordpress-studio.mcpb +``` + +### Installing + +Drag `dist/wordpress-studio.mcpb` onto Claude Desktop (or use **File → Developer → Install Extension**). Claude Desktop unpacks the bundle and runs `server/index.js` on every session start. + +### Debugging + +MCP server logs are at `~/Library/Logs/Claude/mcp-server-WordPress Studio.log`. The most useful signals: + +- **"Server transport closed unexpectedly"** — the process exited early; look for errors in the log above that line +- **"Using built-in Node.js"** — Claude Desktop found and started the bundle correctly +- **Studio not found** — the launcher writes to stderr before exiting; check the log + +## What the prototype has that we can drop + +| Prototype piece | Why drop it | +|---|---| +| Separate npm package + install.sh | Built into Studio CLI instead | +| Own Node.js download (~50MB) | Uses Studio's bundled Node | +| esbuild build pipeline | Uses Studio's existing Vite build | +| `studio://appdata` MCP resource | Nice-to-have; not essential for v1 | +| `studio_inspect_site` prompt | Claude handles site inspection naturally | +| `formatCliFailure()` error wrapper | Raw errors are sufficient; Claude interprets them | +| WP-CLI command string tokenizer | Prototype needed this because it received a free-form string; direct function calls accept an array | + +## Minimal implementation sketch + +```typescript +// apps/cli/commands/mcp/index.ts +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import { registerSiteTools } from './tools/sites'; +import { registerFsTools } from './tools/files'; +import { registerWpCliTool } from './tools/wp-cli'; +import { registerPreviewTools } from './tools/preview'; +import { registerAuthTools } from './tools/auth'; + +export function registerCommand(yargs) { + yargs.command('mcp', 'Start MCP server (JSON-RPC over stdio)', {}, async () => { + const server = new McpServer({ name: 'studio', version: '1.0.0' }); + registerSiteTools(server); + registerFsTools(server); + registerWpCliTool(server); + registerPreviewTools(server); + registerAuthTools(server); + await server.connect(new StdioServerTransport()); + }); +} +``` + +Each tool file wraps the corresponding `runCommand()` function already used by the CLI command. For example: + +```typescript +// apps/cli/commands/mcp/tools/sites.ts +import { runCommand as runSiteList } from '../site/list'; + +server.tool('studio_site_list', {}, async () => { + const sites = await runSiteList(); + return [{ type: 'text', text: JSON.stringify(sites) }]; +}); +``` + +## References + +- Prototype: https://github.com/nightnei/wordpress-developer-mcp-server +- MCP SDK: https://github.com/modelcontextprotocol/typescript-sdk +- MCP spec: https://modelcontextprotocol.io diff --git a/eslint.config.mjs b/eslint.config.mjs index 65df187f4c..92c72f5a00 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -14,6 +14,7 @@ export default defineConfig( '**/node_modules/', '**/__mocks__', 'apps/cli/dist/', + 'apps/cli/mcp-bundle/', 'dist/', 'out/', 'vendor/', diff --git a/package-lock.json b/package-lock.json index 9e148ce1bf..22a9015826 100644 --- a/package-lock.json +++ b/package-lock.json @@ -51,6 +51,7 @@ "name": "studio-cli", "license": "GPL-2.0-or-later", "dependencies": { + "@modelcontextprotocol/sdk": "^1.0.0", "@php-wasm/universal": "3.1.4", "@studio/common": "file:../../tools/common", "@vscode/sudo-prompt": "^9.3.2", @@ -5098,6 +5099,18 @@ "dev": true, "license": "MIT" }, + "node_modules/@hono/node-server": { + "version": "1.19.11", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.11.tgz", + "integrity": "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "license": "Apache-2.0", @@ -5709,6 +5722,400 @@ "node": ">= 12.13.0" } }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.27.1", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.27.1.tgz", + "integrity": "sha512-sr6GbP+4edBwFndLbM60gf07z0FQ79gaExpnsjMGePXqFcSSb7t6iscpjk9DhFhwd+mTEQrzNafGP8/iGGFYaA==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/body-parser": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/content-disposition": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", + "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/type-is": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "license": "MIT", + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/@napi-rs/wasm-runtime": { "version": "0.2.12", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", @@ -13510,6 +13917,23 @@ "version": "1.0.3", "license": "MIT" }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/cosmiconfig": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz", @@ -15880,6 +16304,27 @@ "bare-events": "^2.7.0" } }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz", + "integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/execa": { "version": "1.0.0", "devOptional": true, @@ -16029,6 +16474,24 @@ "url": "https://opencollective.com/express" } }, + "node_modules/express-rate-limit": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.0.tgz", + "integrity": "sha512-KJzBawY6fB9FiZGdE/0aftepZ91YlaGIrV8vgblRM3J8X+dHx/aiowJWwkx6LIGyuqGiANsjSwwrbb8mifOJ4Q==", + "license": "MIT", + "dependencies": { + "ip-address": "10.1.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, "node_modules/express/node_modules/debug": { "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", @@ -16226,7 +16689,6 @@ }, "node_modules/fast-uri": { "version": "3.1.0", - "dev": true, "funding": [ { "type": "github", @@ -17423,6 +17885,15 @@ "react-is": "^16.7.0" } }, + "node_modules/hono": { + "version": "4.12.5", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.5.tgz", + "integrity": "sha512-3qq+FUBtlTHhtYxbxheZgY8NIFnkkC/MR8u5TTsr7YZ3wixryQ3cCwn3iZbg8p8B88iDBBAYSfZDS75t8MN7Vg==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, "node_modules/hosted-git-info": { "version": "2.8.9", "dev": true, @@ -18522,6 +18993,15 @@ "jiti": "bin/jiti.js" } }, + "node_modules/jose": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.0.tgz", + "integrity": "sha512-xsfE1TcSCbUdo6U07tR0mvhg0flGxU8tPLbF03mirl2ukGQENhUg4ubGYQnhVH0b5stLlPM+WOqDkEl1R1y5sQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-base64": { "version": "3.7.8", "license": "BSD-3-Clause" @@ -18653,6 +19133,12 @@ "version": "0.4.1", "license": "MIT" }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, "node_modules/json-stable-stringify": { "version": "1.1.1", "dev": true, @@ -20810,7 +21296,6 @@ }, "node_modules/object-assign": { "version": "4.1.1", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -21705,6 +22190,15 @@ "node": ">= 6" } }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, "node_modules/playwright": { "version": "1.58.2", "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", @@ -23483,6 +23977,32 @@ "fsevents": "~2.3.2" } }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/router/node_modules/path-to-regexp": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", + "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/rrweb-cssom": { "version": "0.7.1", "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.7.1.tgz", @@ -27603,6 +28123,15 @@ "url": "https://github.com/sponsors/colinhacks" } }, + "node_modules/zod-to-json-schema": { + "version": "3.25.1", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz", + "integrity": "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25 || ^4" + } + }, "node_modules/zwitch": { "version": "2.0.4", "license": "MIT",