// `storebound mcp`: a stdio MCP server (tools only) that wraps the CLI, so any agent tool with MCP support (Claude Code, // Codex, Gemini CLI, Cursor, VS Code / Copilot, Windsurf, OpenCode, ...) can call it. // // Dual-era, as the 2026-07-28 spec allows ("Versioning: Backward Compatibility with Initialization-Based Versions"): // - modern 2026-07-28: no handshake. Every request carries `_meta["io.modelcontextprotocol/protocolVersion"]` and // `_meta["io.modelcontextprotocol/clientCapabilities"]`; `server/discover` answers the supported versions, // capabilities and instructions; results carry `resultType: "complete"` and // `_meta["io.modelcontextprotocol/serverInfo"]`; tools/list is cacheable (ttlMs, cacheScope). An unknown version // gets -32022 (UnsupportedProtocolVersionError, data { supported, requested }), a missing required _meta key -32602. // https://modelcontextprotocol.io/specification/2026-07-28/basic/versioning // https://modelcontextprotocol.io/specification/2026-07-28/server/discover // https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/stdio // - legacy 2025-11-25 (and 2025-06-18 / 2025-03-26 / 2024-11-05): initialize, notifications/initialized, ping and // the results without resultType, as before. Requests without the per-request version are served this way. // The server keeps no session state, so both eras can share one process. // // Why no SDK: newline-delimited JSON-RPC with a handful of methods is a few hundred lines and keeps storebound at // zero runtime dependencies (the official SDK pulls in zod, express, ...). // // Every tool runs the CLI as a child process in the project directory (STOREBOUND_PROJECT or the server's cwd) and // returns its output. stdout of this process carries protocol messages only; logs go to stderr. // Tool list and safety rules: mcp-tools.ts (write tools default to dry_run, nothing public or irreversible exposed). import { mkdtempSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import pkg from '../package.json' with { type: 'json' }; import { runCommand, type Running } from './lib/runner'; import { listTools, TOOLS, toArgv, validateInput } from './mcp-tools'; import * as rt from './lib/rt'; /** Current (modern, stateless) revision. */ export const PROTOCOL = '2026-07-28'; export const MODERN = [PROTOCOL]; /** Handshake-based revisions, answered through `initialize`. */ export const LEGACY_PROTOCOL = '2025-11-25'; export const LEGACY = [LEGACY_PROTOCOL, '2025-06-18', '2025-03-26', '2024-11-05']; export const SUPPORTED = [...MODERN, ...LEGACY]; export const META = { protocolVersion: 'io.modelcontextprotocol/protocolVersion', clientCapabilities: 'io.modelcontextprotocol/clientCapabilities', clientInfo: 'io.modelcontextprotocol/clientInfo', serverInfo: 'io.modelcontextprotocol/serverInfo', } as const; export const UNSUPPORTED_PROTOCOL_VERSION = -32022; /** The tool list never changes while the server runs; modern clients may cache it this long. */ export const TOOLS_TTL_MS = 300_000; export const INSTRUCTIONS = [ 'storebound takes mobile apps and games from nothing to Submit for review on Google Play and the App Store (app profiles: game, app, subscription-app).', 'Write tools default to dry_run: show the dry run to the owner and only call again with dry_run false after they agree.', 'Nothing here submits for review, releases, cancels, deletes or sends anything public (review replies, tester invitations, price changes, experiment starts): tell the owner which terminal command or console click does that.', 'Credentials come only from env or .storebound/ in the project; never ask for keys in chat or write them into files.', 'Many apps: workspace_list / workspace_status read the storebound.workspace.json above the project; release_status and release_plan show both stores on one train per version.', 'Run `check` (offline) and `validate` (read-only) before writes. Long uploads (images, screenshots) are better run in a terminal with a log.', ].join(' '); type Id = string | number; interface Msg { jsonrpc: '2.0'; id?: Id; method?: string; params?: Record } const MAX_OUTPUT = 60_000; const serverInfo = () => ({ name: 'storebound', title: 'storebound', version: pkg.version }); /** Era of one request: modern when its _meta names a modern (or unknown) protocol version, else legacy. */ export function eraOf(m: Msg): { era: 'modern' | 'legacy'; version: string | null } { const meta = (m.params?._meta ?? {}) as Record; const v = meta[META.protocolVersion]; if (typeof v !== 'string') return { era: 'legacy', version: null }; return { era: LEGACY.includes(v) ? 'legacy' : 'modern', version: v }; } /** Handles one message; returns the response object (or null for notifications). Exported for tests. */ export async function handle(m: Msg, run: (argv: string[], id: Id) => Promise<{ code: number; out: string }> = runCli): Promise | null> { const err = (code: number, message: string, data?: unknown) => ({ jsonrpc: '2.0', id: m.id ?? null, error: { code, message, ...(data === undefined ? {} : { data }) } }); if (m.id === undefined) { if (m.method === 'notifications/cancelled') cancel(m.params?.requestId as Id); return null; // notifications/initialized and any other notification } if (m.method === 'initialize') { const asked = String(m.params?.protocolVersion ?? ''); return { jsonrpc: '2.0', id: m.id, result: { protocolVersion: LEGACY.includes(asked) ? asked : LEGACY_PROTOCOL, capabilities: { tools: { listChanged: false } }, serverInfo: { ...serverInfo(), description: pkg.description }, instructions: INSTRUCTIONS, } }; } const { era, version } = eraOf(m); if (era === 'modern') { if (!MODERN.includes(version!)) return err(UNSUPPORTED_PROTOCOL_VERSION, 'Unsupported protocol version', { supported: SUPPORTED, requested: version }); const caps = ((m.params?._meta ?? {}) as Record)[META.clientCapabilities]; if (typeof caps !== 'object' || caps === null) return err(-32602, `Invalid params: _meta["${META.clientCapabilities}"] is required`); } else if (m.method === 'server/discover') { return err(-32602, `Invalid params: _meta["${META.protocolVersion}"] is required`); } // Modern results carry resultType and name the server in _meta; legacy results stay as they were. const ok = (result: Record) => ({ jsonrpc: '2.0', id: m.id, result: era === 'modern' ? { resultType: 'complete', ...result, _meta: { [META.serverInfo]: serverInfo() } } : result }); switch (m.method) { case 'server/discover': return ok({ supportedVersions: SUPPORTED, capabilities: { tools: {} }, instructions: INSTRUCTIONS, ttlMs: TOOLS_TTL_MS, cacheScope: 'public' }); case 'ping': return ok({}); case 'tools/list': return ok({ tools: listTools(), ...(era === 'modern' ? { ttlMs: TOOLS_TTL_MS, cacheScope: 'public' } : {}) }); case 'tools/call': { const name = String(m.params?.name ?? ''); const tool = TOOLS.find(t => t.name === name); if (!tool) return err(-32602, `Unknown tool: ${name}`); const input = (m.params?.arguments ?? {}) as Record; const problems = validateInput(tool, input); if (problems.length) return ok({ content: [{ type: 'text', text: `Invalid arguments: ${problems.join('; ')}` }], isError: true }); const { argv: raw, dryRun } = toArgv(tool, input); const argv = textToFile(raw); const r = await run(argv, m.id); let out = clip(r.out, argv.includes('--json')); if (dryRun) out += '\n\n(dry run: nothing was changed. Ask the owner before calling again with dry_run: false.)'; const structured = { exitCode: r.code, dryRun, command: `storebound ${argv.join(' ')}`, output: out }; return ok({ content: [{ type: 'text', text: `$ storebound ${argv.join(' ')}\n${out}\n[exit ${r.code}]` }], structuredContent: structured, isError: r.code !== 0 }); } default: return err(-32601, `Method not found: ${m.method}`); } } /** A multi-line --text=... goes to the CLI as --from-text : on Windows the child starts through a shell and a * multi-line argument kept only its first line (console_verify read every App Privacy type as missing). */ export function textToFile(argv: string[]): string[] { return argv.flatMap(a => { if (!a.startsWith('--text=') || !/[\r\n]/.test(a)) return [a]; const file = join(mkdtempSync(join(tmpdir(), 'storebound-mcp-')), 'page.txt'); writeFileSync(file, a.slice('--text='.length)); return ['--from-text', file]; }); } /** Long output: the whole text goes to a file whose path is named; JSON keeps its start (a plan's start URL and first * steps were cut when the tail was kept), plain text its end (where errors are). */ export function clip(out: string, json: boolean, max = MAX_OUTPUT): string { if (out.length <= max) return out; const file = join(mkdtempSync(join(tmpdir(), 'storebound-mcp-')), json ? 'output.json' : 'output.txt'); writeFileSync(file, out); return json ? `${out.slice(0, max)}\n[... last ${out.length - max} characters cut; the whole output: ${file}]` : `[... first ${out.length - max} characters cut; the whole output: ${file}]\n${out.slice(-max)}`; } const running = new Map(); function cancel(id: Id | undefined): void { if (id !== undefined) running.get(id)?.cancel(); } async function runCli(argv: string[], id: Id): Promise<{ code: number; out: string }> { // STOREBOUND_MCP: `storebound api` never sends a write from an MCP call, whatever the arguments const r = runCommand(argv, { cwd: process.env.STOREBOUND_PROJECT || process.cwd(), env: { STOREBOUND_MCP: '1' } }); running.set(id, r); try { const { code, out } = await r.result; return { code, out }; } finally { running.delete(id); } } export async function serve(): Promise { const send = (o: unknown) => process.stdout.write(JSON.stringify(o) + '\n'); console.error(`[storebound mcp] ${pkg.version} on stdio (MCP ${PROTOCOL}, legacy ${LEGACY.join(', ')}), project ${process.env.STOREBOUND_PROJECT || process.cwd()}`); let buf = ''; const decoder = new TextDecoder(); const reader = rt.stdinStream().getReader(); for (;;) { const { value, done } = await reader.read(); if (done) break; buf += decoder.decode(value, { stream: true }); let nl: number; while ((nl = buf.indexOf('\n')) >= 0) { const line = buf.slice(0, nl).trim(); buf = buf.slice(nl + 1); if (!line) continue; let msg: Msg; try { msg = JSON.parse(line) as Msg; } catch { send({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error' } }); continue; } if (Array.isArray(msg)) { send({ jsonrpc: '2.0', id: null, error: { code: -32600, message: 'Batches are not supported' } }); continue; } // Calls run concurrently; each answers when its child process ends. handle(msg).then(r => { if (r) send(r); }, e => send({ jsonrpc: '2.0', id: msg.id ?? null, error: { code: -32603, message: String((e as Error).message) } })); } } }