From 38d18eb3a8caa3d8615233bcee1d5d00241dc3dd Mon Sep 17 00:00:00 2001 From: Vikas Singhal Date: Wed, 15 Jul 2026 14:11:22 +0000 Subject: [PATCH] feat: add `mcp enable` + `site create --mcp` to make a site MCP-ready in one command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Provisions InstaMCP on a site with no wp-admin clicks, so an agent can spin up a sandbox and immediately drive it over MCP. - `instawp mcp enable `: install+activate the InstaMCP plugin (if absent), enable Execute PHP + Site Files (read-write), mint a Bearer token tied to an administrator (→ mcp:admin scope), and emit a machine-readable connection blob plus ready-to-paste .mcp.json / `claude mcp add` snippets. Idempotent — the token is cached locally (the plugin stores only its hash) so re-running re-prints the same token; --rotate forces a fresh one. - `instawp site create --mcp [--plugins a,b]`: create → wait → install plugins → same enable path → same blob. Uses InstaMCP's supported Bearer-token transport (/insta-mcp?t=). OAuth 2.1 client provisioning is intentionally NOT done: the plugin ships with INSTA_MCP_OAUTH_FEATURE_ENABLED=false (no OAuth tables/routes to mint against). Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 10 ++ package.json | 2 +- src/commands/mcp.ts | 254 ++++++++++++++++++++++++++++++++++++++++++ src/commands/sites.ts | 40 +++++++ src/index.ts | 2 + src/lib/config.ts | 16 +++ 6 files changed, 323 insertions(+), 1 deletion(-) create mode 100644 src/commands/mcp.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 81f715c..c60f0ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## 0.0.1-beta.33 (2026-07-15) + +### Added — `mcp enable` + `site create --mcp` (make a site MCP-ready in one command) + +Get InstaMCP installed, configured, and connectable on a site with no wp-admin clicks — so an agent can spin up a sandbox and immediately drive it over MCP. + +- **`instawp mcp enable `** — installs + activates the InstaMCP plugin (if absent), enables **Execute PHP** (`insta_mcp_execute_php_enabled`) and **Site Files** read-write (`insta_mcp_site_files_mode=read_write`), mints a Bearer token tied to an administrator (which maps to the `mcp:admin` scope), and prints a machine-readable connection blob (`--json`) plus ready-to-paste `.mcp.json` and `claude mcp add` snippets. **Idempotent**: re-running re-prints the *same* token (cached locally, since the plugin stores only its hash) instead of minting a new one — pass `--rotate` to replace it. +- **`instawp site create --mcp [--plugins elementor,bricks]`** — creates the site, waits for it to be ready, installs any `--plugins`, then runs the same enable path and emits the same blob. +- Uses InstaMCP's supported **Bearer-token** transport (`/insta-mcp?t=`). OAuth 2.1 client provisioning is intentionally not done — the plugin currently ships with `INSTA_MCP_OAUTH_FEATURE_ENABLED=false` (no OAuth tables/routes to mint a client against). + ## 0.0.1-beta.32 (2026-07-10) ### Fixed — `local create` no longer false-fails the network check on valid connections diff --git a/package.json b/package.json index bb26326..e93ac54 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@instawp/cli", - "version": "0.0.1-beta.32", + "version": "0.0.1-beta.33", "description": "InstaWP CLI - Create and manage WordPress sites from the terminal", "type": "module", "bin": { diff --git a/src/commands/mcp.ts b/src/commands/mcp.ts new file mode 100644 index 0000000..f2e9ecd --- /dev/null +++ b/src/commands/mcp.ts @@ -0,0 +1,254 @@ +import { Command } from 'commander'; +import { randomBytes } from 'node:crypto'; +import { requireAuth } from '../lib/api.js'; +import { resolveSite } from '../lib/site-resolver.js'; +import { ensureSshAccess } from '../lib/ssh-keys.js'; +import { execViaSsh } from '../lib/ssh-connection.js'; +import { shellQuote, sliceAfterMarker } from '../lib/remote-command.js'; +import { getMcpToken, setMcpToken } from '../lib/config.js'; +import { success, error, spinner, info, isJsonMode } from '../lib/output.js'; +import type { SshConnection } from '../types.js'; + +// Canonical InstaMCP distribution zip (private; not on wordpress.org). Overridable +// for testing a pre-release build via --plugin-zip or INSTAMCP_PLUGIN_ZIP. +const DEFAULT_PLUGIN_ZIP = + 'https://artifacts-iwp.ams3.digitaloceanspaces.com/insta-mcp/insta-mcp-latest.zip'; +const PLUGIN_SLUG = 'insta-mcp'; +// An administrator's token maps to every scope, including mcp:admin (role→scope +// map lives in the plugin's ScopeRepository / BearerTokenAuth). +const ADMIN_SCOPES = ['mcp:read', 'mcp:write', 'mcp:delete', 'mcp:admin']; +// Label of the token row this CLI owns, so re-running upserts one row (never piles up). +const TOKEN_LABEL = 'InstaWP CLI'; + +/** wordpress.org plugin slugs: lowercase letters, numbers, hyphens. */ +export function isValidPluginSlug(slug: string): boolean { + return /^[a-z0-9-]+$/.test(slug); +} + +export interface McpConnection { + site_id: number; + site_url: string; + mcp_endpoint: string; + mcp_endpoint_with_token: string; + token: string; + scopes: string[]; + capabilities: { execute_php: boolean; site_files: string }; + rotated: boolean; +} + +interface WpResult { + stdout: string; + stderr: string; + exitCode: number; +} + +/** Run a WP-CLI invocation on the site over SSH, MOTD-stripped, and capture output. */ +function runWpCli(conn: SshConnection, args: string[]): WpResult { + const wpRoot = `/home/${conn.username}/web/${conn.domain}/public_html`; + const quoted = args.map(shellQuote).join(' '); + const marker = `__IWP_MCP_${randomBytes(6).toString('hex')}__`; + const res = execViaSsh( + conn, + `printf '%s\\n' '${marker}'; cd ${wpRoot} && wp ${quoted}`, + ); + return { + stdout: sliceAfterMarker(res.stdout, marker).trim(), + stderr: res.stderr.trim(), + exitCode: res.exitCode, + }; +} + +function wpFail(label: string, res: WpResult): never { + error(`${label} failed`, res.stderr || res.stdout || `exit ${res.exitCode}`); + process.exit(1); +} + +/** + * Make a site MCP-ready and return a connection blob. Idempotent: an already-set-up + * site re-prints the SAME cached token instead of minting a new one. + * + * Uses InstaMCP's simple Bearer-token transport (`/insta-mcp?t=`) — the + * supported, working connection shape (see the plugin's docs/CLIENTS.md). OAuth 2.1 + * client provisioning is intentionally NOT done: the plugin ships with + * INSTA_MCP_OAUTH_FEATURE_ENABLED=false, so its OAuth tables/routes don't exist and + * a client cannot be minted from the CLI. See the PR description for the plugin-side + * change that would be required. + */ +export async function enableMcp( + site: { id: number; url?: string; sub_domain?: string; name?: string }, + opts: { pluginZip?: string; rotate?: boolean; sshHost?: string; extraPlugins?: string[] } = {}, +): Promise { + const conn = await ensureSshAccess(site.id, { sshHost: opts.sshHost }); + + // 0. Install any requested extra plugins (wordpress.org slugs), idempotently. + for (const slug of opts.extraPlugins || []) { + if (!isValidPluginSlug(slug)) { + error(`Unsafe plugin slug "${slug}"`, 'Use lowercase letters, numbers, and hyphens only.'); + process.exit(1); + } + const pspin = spinner(`Installing ${slug}...`); + pspin.start(); + const res = runWpCli(conn, ['plugin', 'install', slug, '--activate']); + pspin.stop(); + if (res.exitCode !== 0) wpFail(`Installing plugin ${slug}`, res); + info(`Plugin ${slug} installed + activated`); + } + + // 1. Install + activate the plugin if it isn't already active (idempotent). + const activeCheck = runWpCli(conn, ['plugin', 'is-active', PLUGIN_SLUG]); + if (activeCheck.exitCode !== 0) { + const zip = opts.pluginZip || process.env.INSTAMCP_PLUGIN_ZIP || DEFAULT_PLUGIN_ZIP; + // The site's WP-CLI fetches this itself, so it must be a URL it can reach. + if (!/^https?:\/\//.test(zip)) { + error(`Invalid --plugin-zip "${zip}"`, 'Must be an http(s):// URL the site can fetch.'); + process.exit(1); + } + const inSpin = spinner('Installing InstaMCP plugin...'); + inSpin.start(); + const install = runWpCli(conn, ['plugin', 'install', zip, '--activate', '--force']); + inSpin.stop(); + if (install.exitCode !== 0) wpFail('Plugin install', install); + const recheck = runWpCli(conn, ['plugin', 'is-active', PLUGIN_SLUG]); + if (recheck.exitCode !== 0) wpFail('Plugin activation', recheck); + info('InstaMCP plugin installed + activated'); + } else { + info('InstaMCP plugin already active'); + } + + // 2. Enable the capability flags (wp option update is naturally idempotent). + const php = runWpCli(conn, ['option', 'update', 'insta_mcp_execute_php_enabled', '1']); + if (php.exitCode !== 0) wpFail('Enabling Execute PHP', php); + const files = runWpCli(conn, ['option', 'update', 'insta_mcp_site_files_mode', 'read_write']); + if (files.exitCode !== 0) wpFail('Enabling Site Files (read-write)', files); + + // 3. Mint / reuse a token tied to the first administrator (→ mcp:admin scope). + let token = getMcpToken(site.id); + let rotated = false; + if (!token || opts.rotate) { + token = randomBytes(32).toString('hex'); // 64-char hex, the format the plugin expects + // Upsert a single labelled row: UPDATE the hash if our row exists, else INSERT. + // Token is [0-9a-f] only, so it embeds safely in the eval payload. + const php = [ + 'global $wpdb;', + `$t="${token}";`, + `$label="${TOKEN_LABEL}";`, + '$table=$wpdb->prefix."insta_mcp_user_tokens";', + '$a=get_users(["role"=>"administrator","number"=>1,"orderby"=>"ID","order"=>"ASC"]);', + 'if(empty($a)){echo "ERR:no-admin";return;}', + '$uid=$a[0]->ID;$h=hash("sha256",$t);', + '$id=$wpdb->get_var($wpdb->prepare("SELECT id FROM `$table` WHERE user_id=%d AND label=%s",$uid,$label));', + 'if($id){$wpdb->update($table,["token_hash"=>$h,"last_used_at"=>null],["id"=>$id]);}', + 'else{$wpdb->insert($table,["user_id"=>$uid,"token_hash"=>$h,"label"=>$label,"expires_at"=>null,"created_at"=>current_time("mysql")]);}', + 'echo "OK:".$uid;', + ].join(''); + const mint = runWpCli(conn, ['eval', php]); + if (mint.exitCode !== 0 || !mint.stdout.startsWith('OK:')) { + if (mint.stdout.includes('ERR:no-admin')) { + error('Could not mint token', 'No administrator user found on the site.'); + process.exit(1); + } + wpFail('Minting MCP token', mint); + } + setMcpToken(site.id, token); + rotated = !!opts.rotate; + } + + // 4. Resolve the endpoint URL from WordPress itself (authoritative — handles a + // custom endpoint slug and domain drift). + const endpoint = runWpCli(conn, [ + 'eval', + 'echo home_url("/".get_option("insta_mcp_endpoint_slug","insta-mcp"));', + ]); + const mcpEndpoint = + endpoint.exitCode === 0 && /^https?:\/\//.test(endpoint.stdout) + ? endpoint.stdout + : `${(site.url || `https://${site.sub_domain}`).replace(/\/$/, '')}/insta-mcp`; + const siteUrl = mcpEndpoint.replace(/\/[^/]+$/, ''); + + return { + site_id: site.id, + site_url: site.url || siteUrl, + mcp_endpoint: mcpEndpoint, + mcp_endpoint_with_token: `${mcpEndpoint}?t=${token}`, + token, + scopes: ADMIN_SCOPES, + capabilities: { execute_php: true, site_files: 'read_write' }, + rotated, + }; +} + +/** Render the connection blob for humans, with ready-to-paste client snippets. */ +export function printMcpConnection(c: McpConnection): void { + if (isJsonMode()) { + console.log(JSON.stringify({ success: true, data: c })); + return; + } + + const mcpJson = { + mcpServers: { + wordpress: { type: 'http', url: c.mcp_endpoint_with_token }, + }, + }; + const claudeAdd = + `claude mcp add --transport http wordpress "${c.mcp_endpoint}" ` + + `--header "Authorization: Bearer ${c.token}"`; + + console.log(''); + success('MCP-ready'); + console.log(`\n Site: ${c.site_url}`); + console.log(` Endpoint: ${c.mcp_endpoint}`); + console.log(` Token: ${c.token}`); + console.log(` Scopes: ${c.scopes.join(', ')}`); + console.log(` Execute PHP: on Site Files: ${c.capabilities.site_files}`); + console.log('\n .mcp.json:'); + console.log( + JSON.stringify(mcpJson, null, 2) + .split('\n') + .map((l) => ' ' + l) + .join('\n'), + ); + console.log('\n Claude Code:'); + console.log(' ' + claudeAdd); + console.log(''); +} + +export function registerMcpCommand(program: Command): void { + const mcp = program.command('mcp').description('Make a site MCP-ready (InstaMCP)'); + + mcp + .command('enable ') + .description('Install + configure InstaMCP on a site and print a connection blob') + .option('--plugin-zip ', 'Override the InstaMCP plugin zip URL/path') + .option('--rotate', 'Force-mint a fresh token (invalidates the previously printed one)') + .option('--ssh-host ', 'Override the SSH host (CDN-fronted sites)') + .addHelpText( + 'after', + ` +Idempotent: re-running re-prints the same cached token (pass --rotate to replace it). + +Examples: + $ instawp mcp enable my-site + $ instawp mcp enable my-site --json +`, + ) + .action(async (siteIdentifier: string, opts) => { + requireAuth(); + const spin = spinner('Resolving site...'); + spin.start(); + let site; + try { + site = await resolveSite(siteIdentifier); + spin.succeed(`Site: ${site.name || site.sub_domain} (ID: ${site.id})`); + } catch { + spin.fail('Site resolution failed'); + process.exit(1); + } + + const conn = await enableMcp(site, { + pluginZip: opts.pluginZip, + rotate: opts.rotate, + sshHost: opts.sshHost, + }); + printMcpConnection(conn); + }); +} diff --git a/src/commands/sites.ts b/src/commands/sites.ts index d5428ae..a647efb 100644 --- a/src/commands/sites.ts +++ b/src/commands/sites.ts @@ -5,6 +5,7 @@ import { getApiUrl } from '../lib/config.js'; import { resolveSite } from '../lib/site-resolver.js'; import { waitForHttp } from '../lib/http-ready.js'; import { success, error, table, spinner, info, isJsonMode } from '../lib/output.js'; +import { enableMcp, printMcpConnection, isValidPluginSlug, type McpConnection } from './mcp.js'; export function registerSitesCommand(program: Command): void { const sites = program @@ -94,6 +95,8 @@ export function registerSitesCommand(program: Command): void { .option('--php ', 'PHP version (e.g., 8.2)') .option('--config ', 'Configuration ID') .option('--temporary', 'Create as temporary site (default: permanent)') + .option('--mcp', 'Make the site MCP-ready (install + configure InstaMCP) after creation') + .option('--plugins ', 'Comma-separated wordpress.org plugin slugs to install (with --mcp)') .option('--no-wait', 'Do not wait for site to become active') .action(createSiteAction); @@ -400,6 +403,24 @@ async function createSiteAction(opts: any): Promise { const startTime = Date.now(); const elapsed = () => ((Date.now() - startTime) / 1000).toFixed(1); + // --mcp needs the site provisioned + reachable over SSH, so it forces a wait. + const mcpPlugins: string[] = opts.plugins + ? String(opts.plugins).split(',').map((s: string) => s.trim()).filter(Boolean) + : []; + // Validate slugs up front so a typo fails before we spend time creating a site. + const badSlug = mcpPlugins.find((s) => !isValidPluginSlug(s)); + if (badSlug) { + error(`Unsafe plugin slug "${badSlug}"`, 'Use lowercase letters, numbers, and hyphens only.'); + process.exit(1); + } + if (opts.plugins && !opts.mcp && !json) { + info('--plugins has no effect without --mcp.'); + } + if (opts.mcp && !opts.wait) { + if (!json) info('--mcp requires the site to be ready; ignoring --no-wait.'); + opts.wait = true; + } + // Step indicator helpers for human mode const step = (msg: string) => { if (!json) console.log(chalk.green('\u2713') + ' ' + msg); }; const heading = (msg: string) => { if (!json) console.log('\n' + chalk.dim('#') + ' ' + msg); }; @@ -548,6 +569,20 @@ async function createSiteAction(opts: any): Promise { else if (!json) info('Provisioned, but not answering over HTTP yet (DNS may still be propagating).'); } + // Optionally make the site MCP-ready (install/configure InstaMCP). + let mcpConn: McpConnection | null = null; + if (opts.mcp) { + try { + mcpConn = await enableMcp( + { id: site.id, url: siteUrl, sub_domain: domain, name: opts.name }, + { extraPlugins: mcpPlugins }, + ); + } catch (e: any) { + error('MCP enable failed', e?.message || String(e)); + process.exit(1); + } + } + if (json) { // If credentials not yet in details, try list endpoint let creds = meta; @@ -573,6 +608,7 @@ async function createSiteAction(opts: any): Promise { status: 'Active', http_ready: httpReady, elapsed: elapsed() + 's', + ...(mcpConn ? { mcp: mcpConn } : {}), }, })); } else { @@ -601,6 +637,8 @@ async function createSiteAction(opts: any): Promise { if (magicUrl) { console.log(` ${chalk.dim('Magic Login:')} ${chalk.cyan.underline(magicUrl)}`); } + + if (mcpConn) printMcpConnection(mcpConn); } return; } @@ -630,6 +668,8 @@ export function registerCreateAlias(program: Command): void { .option('--php ', 'PHP version (e.g., 8.2)') .option('--config ', 'Configuration ID') .option('--temporary', 'Create as temporary site (default: permanent)') + .option('--mcp', 'Make the site MCP-ready (install + configure InstaMCP) after creation') + .option('--plugins ', 'Comma-separated wordpress.org plugin slugs to install (with --mcp)') .option('--no-wait', 'Do not wait for site to become active') .action(createSiteAction); } diff --git a/src/index.ts b/src/index.ts index 75adf5c..e65e13b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,6 +12,7 @@ import { registerSyncCommand } from './commands/sync.js'; import { registerSshCommand } from './commands/ssh.js'; import { registerExecCommand, registerWpCommand, registerSqlCommand } from './commands/exec.js'; import { registerPluginCommand } from './commands/plugin.js'; +import { registerMcpCommand } from './commands/mcp.js'; import { registerTeamsCommand } from './commands/teams.js'; import { registerLocalCommand } from './commands/local.js'; import { registerMigrateCommand } from './commands/migrate.js'; @@ -59,6 +60,7 @@ registerSyncCommand(program); registerDbCommand(program); registerCacheCommand(program); registerPluginCommand(program); +registerMcpCommand(program); registerLogsCommand(program); registerOpenCommand(program); diff --git a/src/lib/config.ts b/src/lib/config.ts index 0603746..928d89f 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -15,6 +15,7 @@ const config = new Conf({ local_instances: { type: 'object', default: {} }, team_id: { type: 'number', default: 0 }, update_check: { type: 'object', default: {} }, + mcp_tokens: { type: 'object', default: {} }, }, }); @@ -158,6 +159,21 @@ export function setSshCache(siteId: number, entry: SshConnectionCache): void { config.set('ssh_cache', cache); } +// InstaMCP connection tokens, keyed by site ID. Cached locally so re-running +// `mcp enable` is a true no-op that re-prints the SAME token — the plugin only +// stores the token's SHA256 hash, so the plaintext is recoverable only here. +export function getMcpToken(siteId: number): string | null { + const cache = config.get('mcp_tokens') as Record; + const token = cache?.[String(siteId)]; + return token || null; +} + +export function setMcpToken(siteId: number, token: string): void { + const cache = (config.get('mcp_tokens') as Record) || {}; + cache[String(siteId)] = token; + config.set('mcp_tokens', cache); +} + export function clearSshCache(siteId?: number): void { if (siteId !== undefined) { const cache = (config.get('ssh_cache') as Record) || {};