Skip to content
Closed

Poc mcp #2727

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
72 changes: 72 additions & 0 deletions apps/cli/commands/mcp/index.ts
Original file line number Diff line number Diff line change
@@ -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;
} );
},
} );
28 changes: 28 additions & 0 deletions apps/cli/commands/mcp/tools/auth.ts
Original file line number Diff line number Diff line change
@@ -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 ),
} );
}
} );
}
126 changes: 126 additions & 0 deletions apps/cli/commands/mcp/tools/files.ts
Original file line number Diff line number Diff line change
@@ -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 ) );
}
}
);
}
59 changes: 59 additions & 0 deletions apps/cli/commands/mcp/tools/preview.ts
Original file line number Diff line number Diff line change
@@ -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 ) );
}
}
);
}
Loading