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:
parent
952a3a6e37
commit
20db1345e4
3 changed files with 167 additions and 21 deletions
91
cli/mcp.ts
91
cli/mcp.ts
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
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) {
|
switch (m.method) {
|
||||||
case 'initialize': {
|
case 'server/discover':
|
||||||
const asked = String(m.params?.protocolVersion ?? '');
|
return ok({ supportedVersions: SUPPORTED, capabilities: { tools: {} }, instructions: INSTRUCTIONS, ttlMs: TOOLS_TTL_MS, cacheScope: 'public' });
|
||||||
return ok({
|
|
||||||
protocolVersion: SUPPORTED.includes(asked) ? asked : PROTOCOL,
|
|
||||||
capabilities: { tools: { listChanged: false } },
|
|
||||||
serverInfo: { name: 'storebound', title: 'storebound', version: pkg.version, description: pkg.description },
|
|
||||||
instructions: INSTRUCTIONS,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
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
95
tests/mcp.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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';
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue