storebound/cli/mcp.ts
CRT f7fde413e0 Merge api-coverage: storebound api, every App Store Connect and Play endpoint
# Conflicts:
#	.cursor/rules/storebound.mdc
#	.github/copilot-instructions.md
#	AGENTS.md
#	CHANGELOG.md
#	GEMINI.md
#	README.md
#	cli/index.ts
#	cli/serve.ts
#	tools/gen-adapters.ts
2026-10-11 23:40:55 +08:00

152 lines
9.7 KiB
TypeScript

// `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 pkg from '../package.json' with { type: 'json' };
import { runCommand, type Running } from './lib/runner';
import { listTools, TOOLS, toArgv, validateInput } from './mcp-tools';
/** 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<string, unknown> }
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<string, unknown>;
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<Record<string, unknown> | 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<string, unknown>)[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<string, unknown>) => ({ 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<string, unknown>;
const problems = validateInput(tool, input);
if (problems.length) return ok({ content: [{ type: 'text', text: `Invalid arguments: ${problems.join('; ')}` }], isError: true });
const { argv, dryRun } = toArgv(tool, input);
const r = await run(argv, m.id);
let out = r.out.length > MAX_OUTPUT ? `[... first ${r.out.length - MAX_OUTPUT} characters cut ...]\n` + r.out.slice(-MAX_OUTPUT) : r.out;
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}`);
}
}
const running = new Map<Id, Running>();
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<void> {
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 = Bun.stdin.stream().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) } }));
}
}
}