MCP 2026-07-28: stateless revision with server/discover and per-request _meta, still 2025-11-25 compatible

Dual-era server: requests that carry io.modelcontextprotocol/protocolVersion are served statelessly (server/discover
with supportedVersions, capabilities, instructions, ttlMs / cacheScope; resultType complete and serverInfo in every
result's _meta; cacheable tools/list; -32022 UnsupportedProtocolVersion with data { supported, requested }; -32602 when
clientCapabilities is missing). initialize / ping / unannotated requests keep the 2025-11-25 (and older) behaviour.
Tests for both handshakes, including one stdio process serving a modern probe and a legacy client.
This commit is contained in:
CRTkafa 2026-10-10 23:23:58 +08:00
parent 952a3a6e37
commit 20db1345e4
3 changed files with 167 additions and 21 deletions

View file

@ -1,8 +1,21 @@
// `storebound mcp`: a stdio MCP server (spec revision 2025-11-25, tools only) that wraps the CLI, so any agent tool // `storebound mcp`: a stdio MCP server (tools only) that wraps the CLI, so any agent tool with MCP support (Claude Code,
// with MCP support (Claude Code, Codex, Gemini CLI, Cursor, VS Code / Copilot, Windsurf, OpenCode, ...) can call it. // Codex, Gemini CLI, Cursor, VS Code / Copilot, Windsurf, OpenCode, ...) can call it.
// //
// Why no SDK: the server needs initialize, ping, tools/list and tools/call over newline-delimited JSON-RPC. That is // Dual-era, as the 2026-07-28 spec allows ("Versioning: Backward Compatibility with Initialization-Based Versions"):
// ~150 lines, and keeps storebound at zero runtime dependencies (the official SDK pulls in zod, express, ...). // - 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 // 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. // returns its output. stdout of this process carries protocol messages only; logs go to stderr.
@ -11,13 +24,28 @@ import { join } from 'node:path';
import pkg from '../package.json' with { type: 'json' }; import pkg from '../package.json' with { type: 'json' };
import { listTools, TOOLS, toArgv, validateInput } from './mcp-tools'; import { listTools, TOOLS, toArgv, validateInput } from './mcp-tools';
export const PROTOCOL = '2025-11-25'; /** Current (modern, stateless) revision. */
export const SUPPORTED = [PROTOCOL, '2025-06-18', '2025-03-26', '2024-11-05']; 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 = [ export const INSTRUCTIONS = [
'storebound ships mobile games to Google Play and the App Store.', 'storebound ships mobile games to Google Play and the App Store.',
'Write tools default to dry_run: show the dry run to the owner and only call again with dry_run false after they agree.', '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 or deletes: tell the owner which terminal command or console click does that.', '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.', 'Credentials come only from env or .storebound/ in the project; never ask for keys in chat or write them into files.',
'Run `check` (offline) and `validate` (read-only) before writes. Long uploads (images, screenshots) are better run in a terminal with a log.', 'Run `check` (offline) and `validate` (read-only) before writes. Long uploads (images, screenshots) are better run in a terminal with a log.',
].join(' '); ].join(' ');
@ -27,27 +55,50 @@ interface Msg { jsonrpc: '2.0'; id?: Id; method?: string; params?: Record<string
const MAX_OUTPUT = 60_000; const MAX_OUTPUT = 60_000;
const CLI = join(import.meta.dir, 'index.ts'); const CLI = join(import.meta.dir, 'index.ts');
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. */ /** 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> { export async function handle(m: Msg, run: (argv: string[], id: Id) => Promise<{ code: number; out: string }> = runCli): Promise<Record<string, unknown> | null> {
const ok = (result: unknown) => ({ jsonrpc: '2.0', id: m.id, result }); const err = (code: number, message: string, data?: unknown) => ({ jsonrpc: '2.0', id: m.id ?? null, error: { code, message, ...(data === undefined ? {} : { data }) } });
const err = (code: number, message: string) => ({ jsonrpc: '2.0', id: m.id ?? null, error: { code, message } });
if (m.id === undefined) { if (m.id === undefined) {
if (m.method === 'notifications/cancelled') cancel(m.params?.requestId as Id); if (m.method === 'notifications/cancelled') cancel(m.params?.requestId as Id);
return null; // notifications/initialized and any other notification return null; // notifications/initialized and any other notification
} }
switch (m.method) { if (m.method === 'initialize') {
case 'initialize': {
const asked = String(m.params?.protocolVersion ?? ''); const asked = String(m.params?.protocolVersion ?? '');
return ok({ return { jsonrpc: '2.0', id: m.id, result: {
protocolVersion: SUPPORTED.includes(asked) ? asked : PROTOCOL, protocolVersion: LEGACY.includes(asked) ? asked : LEGACY_PROTOCOL,
capabilities: { tools: { listChanged: false } }, capabilities: { tools: { listChanged: false } },
serverInfo: { name: 'storebound', title: 'storebound', version: pkg.version, description: pkg.description }, serverInfo: { ...serverInfo(), description: pkg.description },
instructions: INSTRUCTIONS, 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 'ping': return ok({});
case 'tools/list': return ok({ tools: listTools() }); case 'tools/list': return ok({ tools: listTools(), ...(era === 'modern' ? { ttlMs: TOOLS_TTL_MS, cacheScope: 'public' } : {}) });
case 'tools/call': { case 'tools/call': {
const name = String(m.params?.name ?? ''); const name = String(m.params?.name ?? '');
const tool = TOOLS.find(t => t.name === name); const tool = TOOLS.find(t => t.name === name);
@ -81,7 +132,7 @@ async function runCli(argv: string[], id: Id): Promise<{ code: number; out: stri
export async function serve(): Promise<void> { export async function serve(): Promise<void> {
const send = (o: unknown) => process.stdout.write(JSON.stringify(o) + '\n'); const send = (o: unknown) => process.stdout.write(JSON.stringify(o) + '\n');
console.error(`[storebound mcp] ${pkg.version} on stdio, project ${process.env.STOREBOUND_PROJECT || process.cwd()}`); console.error(`[storebound mcp] ${pkg.version} on stdio (MCP ${PROTOCOL}, legacy ${LEGACY.join(', ')}), project ${process.env.STOREBOUND_PROJECT || process.cwd()}`);
let buf = ''; let buf = '';
const decoder = new TextDecoder(); const decoder = new TextDecoder();
const reader = Bun.stdin.stream().getReader(); const reader = Bun.stdin.stream().getReader();

95
tests/mcp.test.ts Normal file
View file

@ -0,0 +1,95 @@
import { describe, expect, test } from 'bun:test';
import { join } from 'node:path';
import { eraOf, handle, LEGACY, LEGACY_PROTOCOL, META, PROTOCOL, SUPPORTED, TOOLS_TTL_MS, UNSUPPORTED_PROTOCOL_VERSION } from '../cli/mcp';
import { TOOLS } from '../cli/mcp-tools';
const modernMeta = (v = PROTOCOL, caps: unknown = {}) => ({ [META.protocolVersion]: v, [META.clientCapabilities]: caps, [META.clientInfo]: { name: 'test', version: '1' } });
type R = Record<string, unknown>;
const res = (r: Record<string, unknown> | null) => r!.result as R;
const errOf = (r: Record<string, unknown> | null) => r!.error as { code: number; message: string; data?: R };
describe('mcp 2026-07-28 (modern, stateless)', () => {
test('server/discover: versions, capabilities, instructions, serverInfo in _meta, cacheable', async () => {
const r = res(await handle({ jsonrpc: '2.0', id: 'd1', method: 'server/discover', params: { _meta: modernMeta() } }));
expect(r.resultType).toBe('complete');
expect(r.supportedVersions).toEqual(SUPPORTED);
expect((r.supportedVersions as string[])[0]).toBe('2026-07-28');
expect(r.capabilities).toEqual({ tools: {} });
expect(typeof r.instructions).toBe('string');
expect((r._meta as R)[META.serverInfo]).toMatchObject({ name: 'storebound' });
expect(r).toMatchObject({ ttlMs: TOOLS_TTL_MS, cacheScope: 'public' });
expect(r.protocolVersion).toBeUndefined();
});
test('tools/list and tools/call without any handshake', async () => {
const list = res(await handle({ jsonrpc: '2.0', id: 1, method: 'tools/list', params: { _meta: modernMeta() } }));
expect(list.resultType).toBe('complete');
expect((list.tools as unknown[]).length).toBe(TOOLS.length);
expect(list).toMatchObject({ ttlMs: TOOLS_TTL_MS, cacheScope: 'public' });
let seen: string[] = [];
const call = res(await handle({ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'check', arguments: {}, _meta: modernMeta() } }, async argv => { seen = argv; return { code: 0, out: 'ok' }; }));
expect(seen).toEqual(['check']);
expect(call).toMatchObject({ resultType: 'complete', isError: false, structuredContent: { exitCode: 0, dryRun: false } });
expect((call._meta as R)[META.serverInfo]).toBeDefined();
});
test('version and _meta errors', async () => {
const bad = errOf(await handle({ jsonrpc: '2.0', id: 3, method: 'tools/list', params: { _meta: modernMeta('2099-01-01') } }));
expect(bad.code).toBe(UNSUPPORTED_PROTOCOL_VERSION);
expect(bad.code).toBe(-32022);
expect(bad.data).toEqual({ supported: SUPPORTED, requested: '2099-01-01' });
const noCaps = errOf(await handle({ jsonrpc: '2.0', id: 4, method: 'tools/list', params: { _meta: { [META.protocolVersion]: PROTOCOL } } }));
expect(noCaps.code).toBe(-32602);
expect(noCaps.message).toContain('clientCapabilities');
const probe = errOf(await handle({ jsonrpc: '2.0', id: 5, method: 'server/discover', params: {} }));
expect(probe.code).toBe(-32602);
expect(errOf(await handle({ jsonrpc: '2.0', id: 6, method: 'resources/list', params: { _meta: modernMeta() } })).code).toBe(-32601);
const unknownTool = errOf(await handle({ jsonrpc: '2.0', id: 7, method: 'tools/call', params: { name: 'apple_submit', _meta: modernMeta() } }));
expect(unknownTool.code).toBe(-32602);
});
test('era detection', () => {
expect(eraOf({ jsonrpc: '2.0', id: 1, method: 'tools/list', params: { _meta: modernMeta() } })).toEqual({ era: 'modern', version: PROTOCOL });
expect(eraOf({ jsonrpc: '2.0', id: 1, method: 'tools/list', params: { _meta: modernMeta(LEGACY_PROTOCOL) } }).era).toBe('legacy');
expect(eraOf({ jsonrpc: '2.0', id: 1, method: 'tools/list' })).toEqual({ era: 'legacy', version: null });
});
});
describe('mcp 2025-11-25 (legacy handshake) stays compatible', () => {
test('initialize negotiates a legacy version; results have no resultType', async () => {
const init = res(await handle({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2025-11-25', capabilities: {}, clientInfo: { name: 'old', version: '1' } } }));
expect(init.protocolVersion).toBe('2025-11-25');
expect(init.capabilities).toEqual({ tools: { listChanged: false } });
expect(res(await handle({ jsonrpc: '2.0', id: 2, method: 'initialize', params: { protocolVersion: '2025-06-18' } })).protocolVersion).toBe('2025-06-18');
// a modern client that wrongly sends initialize with the modern version gets the newest legacy version back
expect(res(await handle({ jsonrpc: '2.0', id: 3, method: 'initialize', params: { protocolVersion: PROTOCOL } })).protocolVersion).toBe(LEGACY_PROTOCOL);
expect(await handle({ jsonrpc: '2.0', method: 'notifications/initialized' })).toBeNull();
expect(res(await handle({ jsonrpc: '2.0', id: 4, method: 'ping' }))).toEqual({});
const list = res(await handle({ jsonrpc: '2.0', id: 5, method: 'tools/list' }));
expect(list.resultType).toBeUndefined();
expect(list.ttlMs).toBeUndefined();
expect(LEGACY).toEqual(['2025-11-25', '2025-06-18', '2025-03-26', '2024-11-05']);
});
});
describe('stdio end to end', () => {
test('one process answers a modern probe and a legacy handshake', async () => {
const p = Bun.spawn([process.execPath, join(import.meta.dir, '..', 'cli', 'index.ts'), 'mcp'], { stdin: 'pipe', stdout: 'pipe', stderr: 'pipe' });
const lines = [
{ jsonrpc: '2.0', id: 'probe', method: 'server/discover', params: { _meta: modernMeta() } },
{ jsonrpc: '2.0', id: 'old', method: 'server/discover', params: { _meta: modernMeta('1999-01-01') } },
{ jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2025-11-25', capabilities: {}, clientInfo: { name: 'legacy', version: '1' } } },
{ jsonrpc: '2.0', method: 'notifications/initialized' },
{ jsonrpc: '2.0', id: 2, method: 'tools/list' },
{ jsonrpc: '2.0', id: 3, method: 'tools/list', params: { _meta: modernMeta() } },
];
p.stdin.write(lines.map(l => JSON.stringify(l)).join('\n') + '\n');
p.stdin.end();
const out = await new Response(p.stdout).text();
await p.exited;
const byId = new Map(out.trim().split('\n').map(l => JSON.parse(l) as R).map(r => [r.id, r] as const));
expect((byId.get('probe')!.result as R).supportedVersions).toContain('2026-07-28');
expect((byId.get('old')!.error as R).code).toBe(-32022);
expect((byId.get(1)!.result as R).protocolVersion).toBe('2025-11-25');
expect((byId.get(2)!.result as R).resultType).toBeUndefined();
expect((byId.get(3)!.result as R).resultType).toBe('complete');
expect(byId.size).toBe(5);
});
});

View file

@ -5,7 +5,7 @@ import { join } from 'node:path';
import { generate, parseSkill, readSkills } from '../tools/gen-adapters'; import { generate, parseSkill, readSkills } from '../tools/gen-adapters';
import { scan } from '../tools/scrub'; import { scan } from '../tools/scrub';
import { FORBIDDEN_ARGS, listTools, TOOLS, toArgv, validateInput } from '../cli/mcp-tools'; import { FORBIDDEN_ARGS, listTools, TOOLS, toArgv, validateInput } from '../cli/mcp-tools';
import { handle, PROTOCOL } from '../cli/mcp'; import { handle, LEGACY_PROTOCOL as PROTOCOL } from '../cli/mcp';
import { auditAppleKeywords, auditAso, auditPlayListing, crossLocaleHint } from '../cli/lib/aso'; import { auditAppleKeywords, auditAso, auditPlayListing, crossLocaleHint } from '../cli/lib/aso';
import { checkNotes, formatNotes, noteItems, setAppleWhatsNew, setPlayWhatsNew } from '../cli/lib/whats-new'; import { checkNotes, formatNotes, noteItems, setAppleWhatsNew, setPlayWhatsNew } from '../cli/lib/whats-new';
import { parseAppStoreListing, parsePlayListing } from '../cli/lib/copy'; import { parseAppStoreListing, parsePlayListing } from '../cli/lib/copy';