198 lines
12 KiB
TypeScript
198 lines
12 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 { 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, OWNER_STEP, TOOLS, toArgv, validateInput } from './mcp-tools';
|
|
import { bypassOn, logOwnerAction } from './lib/bypass';
|
|
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(' ');
|
|
|
|
/** A replacement tool surface (the public key-less server, mcp-public.ts): its own instructions, tool list and calls. */
|
|
export interface Surface {
|
|
instructions: string;
|
|
list(): Record<string, unknown>[];
|
|
call(name: string, input: unknown): Promise<{ text: string; data?: unknown; isError?: boolean } | null>;
|
|
}
|
|
|
|
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, surface?: Surface): 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: surface?.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: surface?.instructions ?? INSTRUCTIONS, ttlMs: TOOLS_TTL_MS, cacheScope: 'public' });
|
|
case 'ping': return ok({});
|
|
case 'tools/list': return ok({ tools: surface ? surface.list() : listTools(bypassOn()), ...(era === 'modern' ? { ttlMs: TOOLS_TTL_MS, cacheScope: 'public' } : {}) });
|
|
case 'tools/call': {
|
|
const name = String(m.params?.name ?? '');
|
|
if (surface) {
|
|
// public mode: only the surface's own tools exist; nothing below (the CLI tools, owner_step) is reachable
|
|
const r = await surface.call(name, m.params?.arguments ?? {});
|
|
if (!r) return err(-32602, `Unknown tool: ${name}`);
|
|
return ok({ content: [{ type: 'text', text: r.text }], ...(r.data === undefined ? {} : { structuredContent: { result: r.data } }), isError: !!r.isError });
|
|
}
|
|
const tool = TOOLS.find(t => t.name === name) ?? (name === OWNER_STEP.name && bypassOn() ? OWNER_STEP : undefined);
|
|
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: raw, dryRun } = toArgv(tool, input);
|
|
// owner_step runs the real command with --yes; its dry run is the command's own --dry
|
|
const argv = tool === OWNER_STEP && !dryRun ? [...textToFile(raw), '--yes'] : textToFile(raw);
|
|
const r = await run(argv, m.id);
|
|
let out = clip(r.out, argv.includes('--json'));
|
|
if (tool === OWNER_STEP) {
|
|
const file = logOwnerAction(process.env.STOREBOUND_PROJECT || process.cwd(), { command: `storebound ${argv.join(' ')}`, dry: dryRun, exit: r.code, summary: r.out.trim().split('\n').slice(-1)[0] });
|
|
out += `\n(bypass mode: logged to ${file})`;
|
|
}
|
|
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 <file>: 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<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 = 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) } }));
|
|
}
|
|
}
|
|
}
|