mcp --http --public: key-less public server (skills, pitfalls, limits, listing lint, App Store search), rate limit, 64 KB bodies

This commit is contained in:
CRTkafa 2026-10-29 18:54:58 +07:00
parent 398d8cd375
commit c500866295
8 changed files with 695 additions and 15 deletions

View file

@ -3,6 +3,8 @@
## Unreleased
- `storebound mcp --http --public`: a public, key-less MCP server (no sign-in, no store credentials, no project folders, no child processes, read-only). Tools: `skills_list`, `skill_get`, `pitfalls_search`, `store_limits`, `listing_lint` and `aso_search` (the public iTunes Search API, the only network call). Rate limit per client (`--rate`, 429 with Retry-After; `--trust-proxy` for X-Forwarded-For), 64 KB request bodies, CORS preflight. docs/mcp.md.
## 0.8.58 · 2026-10-29
- `check` and `scan` are fast on big repos: project walks skip dot folders (tool state, worktree copies), linked git worktrees and the folders git ignores (Pods, Carthage, SourcePackages and ios / android stay in), and read entry types from the listing instead of a stat per file. A game repo with worktree copies went from 244 s to 46 s for `check`.

View file

@ -657,10 +657,13 @@
"issuer",
"port",
"project",
"public",
"rate",
"read-only",
"resource",
"roots",
"token-file"
"token-file",
"trust-proxy"
],
"onboard": [
"accounts",
@ -1363,7 +1366,9 @@
"propose",
"prune",
"prune-languages",
"public",
"q",
"rate",
"rating",
"react-native",
"read-only",
@ -1421,6 +1426,7 @@
"tracking-domains",
"traffic",
"translate",
"trust-proxy",
"type",
"udid",
"unanswered",

View file

@ -10,5 +10,6 @@ export async function run(a: ReturnType<typeof parseArgs>): Promise<void> {
auth: a.get('auth'), tokenFile: a.get('token-file'), introspectUrl: a.get('introspect-url'), introspectKeyFile: a.get('introspect-key-file'),
resource: a.get('resource'), issuer: a.get('issuer'), allowRemoteToken: a.has('allow-remote-token'),
brokerProfiles: a.get('broker-profiles'), readOnly: a.has('read-only'),
public: a.has('public'), rate: a.get('rate'), trustProxy: a.has('trust-proxy'),
});
}

View file

@ -2,6 +2,7 @@
// machine (or one host serves a whole team) instead of one stdio process per session.
// storebound mcp --http [--host 127.0.0.1] [--port 7787] [--roots <dir,dir>] [--project <dir>]
// [--auth token|introspect] [--token-file ~/.storebound/mcp-token]
// storebound mcp --http --public [--port 7788] [--host 127.0.0.1] [--rate 60] [--trust-proxy] (see below)
// [--introspect-url <url> --introspect-key-file <file> --resource <url> --issuer <url>]
// Transport (https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#streamable-http): one endpoint,
// /mcp. POST carries one JSON-RPC message and gets one JSON answer (202 for a notification); GET and DELETE are 405
@ -9,6 +10,9 @@
// Origin header, when sent, must be a localhost origin unless --allow-origin names it (DNS rebinding).
// Project: every tool gets a `project` argument, the absolute path of the app folder the CLI runs in. It must be inside
// --roots (default: the home folder); --project (or STOREBOUND_PROJECT) is the default when a call has none.
// Public (--public): the key-less server behind a public URL. No sign-in, no project, no store credentials; it serves only the
// read-only tools of mcp-public.ts (skills, pitfalls, limits, listing lint, App Store search), never starts a process, rate
// limited per client (--rate per minute, 429 + Retry-After; --trust-proxy reads the first X-Forwarded-For entry), body <= 64 KB.
// Auth (every request to /mcp):
// token (default): `Authorization: Bearer <token>`, the token in --token-file (made with mode 600 on first start;
// never printed). Clients: claude mcp add --transport http storebound http://127.0.0.1:7787/mcp
@ -25,12 +29,17 @@ import { homedir } from 'node:os';
import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
import pkg from '../package.json' with { type: 'json' };
import { handle, LEGACY, PROTOCOL } from './mcp';
import { clientKey, createLimiter, publicSurface, type PublicDeps } from './mcp-public';
import { TOOLS } from './mcp-tools';
import { brokerConfig, brokerProfileEnv } from './lib/broker';
import { runCommand } from './lib/runner';
export const DEFAULT_PORT = 7787;
export const PUBLIC_PORT = 7788;
const MAX_BODY = 4 * 1024 * 1024;
/** Public mode: one JSON-RPC message of a read-only tool never needs more. */
export const PUBLIC_MAX_BODY = 64 * 1024;
export const PUBLIC_DEFAULT_RATE = 60;
export interface HttpOptions {
host: string;
@ -42,7 +51,9 @@ export interface HttpOptions {
brokerProfiles?: string[];
/** Only the tools that change nothing (no dry_run tools at all). */
readOnly?: boolean;
auth: { kind: 'token'; token: string } | { kind: 'introspect'; url: string; key: string; resource: string; issuer: string };
/** Public key-less mode (`--public`): auth kind 'none', the tool surface of mcp-public.ts, a rate limit per client. */
public?: { rate: number; trustProxy: boolean } & PublicDeps;
auth: { kind: 'none' } | { kind: 'token'; token: string } | { kind: 'introspect'; url: string; key: string; resource: string; issuer: string };
}
export const PROJECT_PROP = { type: 'string', description: 'The app folder (the one with storebound.config.ts) to run in: an absolute path, or a folder name under the server roots. Required unless the server was started with --project.' } as const;
@ -96,7 +107,7 @@ const bearer = (req: IncomingMessage): string | null => { const h = req.headers.
const cache = new Map<string, { ok: boolean; until: number }>();
async function authorized(req: IncomingMessage, o: HttpOptions): Promise<boolean> {
const t = bearer(req);
if (!t) return false;
if (!t || o.auth.kind === 'none') return false;
if (o.auth.kind === 'token') {
const a = createHash('sha256').update(t).digest(), b = createHash('sha256').update(o.auth.token).digest();
return a.equals(b);
@ -115,14 +126,14 @@ async function authorized(req: IncomingMessage, o: HttpOptions): Promise<boolean
return ok;
}
function send(res: ServerResponse, status: number, body?: unknown, headers: Record<string, string> = {}): void {
function sendRaw(res: ServerResponse, status: number, body?: unknown, headers: Record<string, string> = {}): void {
res.writeHead(status, { 'cache-control': 'no-store', ...(body === undefined ? {} : { 'content-type': 'application/json' }), ...headers });
res.end(body === undefined ? undefined : JSON.stringify(body));
}
async function readBody(req: IncomingMessage): Promise<string | null> {
async function readBody(req: IncomingMessage, max = MAX_BODY): Promise<string | null> {
let size = 0; const parts: Buffer[] = [];
for await (const c of req) { size += (c as Buffer).length; if (size > MAX_BODY) return null; parts.push(c as Buffer); }
for await (const c of req) { size += (c as Buffer).length; if (size > max) return null; parts.push(c as Buffer); }
return Buffer.concat(parts).toString('utf8');
}
@ -151,6 +162,9 @@ export async function answer(msg: Record<string, unknown>, o: Pick<HttpOptions,
return r;
}
/** Public mode never runs the CLI: a call that would is a bug, not a request. */
const noRun = async (): Promise<{ code: number; out: string }> => { throw new Error('public mode does not run commands'); };
let brokerEnv: { env: Promise<Record<string, string>>; until: number } | null = null;
/** The broker profiles' credentials as child env, fetched at most every 10 minutes (memory only, never logged). */
async function profileEnvFor(profiles: string[] | undefined): Promise<Record<string, string>> {
@ -174,6 +188,11 @@ async function runIn(argv: string[], cwd: string): Promise<{ code: number; out:
export function startHttp(o: HttpOptions): Promise<{ close: () => void; port: number }> {
serverProfiles = o.brokerProfiles;
if (o.auth.kind === 'none' && !o.public) throw new Error('auth none is only for --public');
const pub = o.public ? { limiter: createLimiter(o.public.rate), surface: publicSurface(o.public), trustProxy: o.public.trustProxy } : null;
// no ambient credential in public mode, so any browser origin may call it
const cors: Record<string, string> = pub ? { 'access-control-allow-origin': '*', 'access-control-allow-methods': 'POST, OPTIONS', 'access-control-allow-headers': 'content-type, accept, mcp-protocol-version', 'access-control-max-age': '86400' } : {};
const send = (res: ServerResponse, status: number, body?: unknown, headers: Record<string, string> = {}) => sendRaw(res, status, body, { ...cors, ...headers });
const resourceMeta = o.auth.kind === 'introspect' ? metadataUrl(o.auth.resource) : null;
const server = createServer(async (req, res) => {
const path = (req.url ?? '/').split('?')[0];
@ -181,17 +200,21 @@ export function startHttp(o: HttpOptions): Promise<{ close: () => void; port: nu
return send(res, 200, { resource: o.auth.resource, authorization_servers: [o.auth.issuer], bearer_methods_supported: ['header'], scopes_supported: ['mcp'], resource_name: 'storebound' });
if (path === '/health' && req.method === 'GET') return send(res, 200, { ok: true, version: pkg.version });
if (path !== '/mcp') return send(res, 404, { error: 'not found' });
if (!originAllowed(req.headers.origin, o.allowOrigins)) return send(res, 403, { error: 'origin not allowed' });
if (!(await authorized(req, o)))
if (pub) {
if (req.method === 'OPTIONS') return send(res, 204);
const hit = pub.limiter.hit(clientKey(req.headers['x-forwarded-for'], req.socket.remoteAddress, pub.trustProxy));
if (!hit.ok) return send(res, 429, { error: 'rate limit exceeded' }, { 'retry-after': String(hit.retryAfter) });
} else if (!originAllowed(req.headers.origin, o.allowOrigins)) return send(res, 403, { error: 'origin not allowed' });
if (!pub && !(await authorized(req, o)))
return send(res, 401, { error: 'unauthorized' }, { 'www-authenticate': resourceMeta ? `Bearer resource_metadata="${resourceMeta}"` : 'Bearer' });
if (req.method !== 'POST') return send(res, 405, { error: 'POST only (no server-initiated stream)' }, { allow: 'POST' });
const body = await readBody(req);
const body = await readBody(req, pub ? PUBLIC_MAX_BODY : MAX_BODY);
if (body === null) return send(res, 413, { error: 'body too large' });
let msg: unknown;
try { msg = JSON.parse(body); } catch { return send(res, 400, { jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error' } }); }
if (Array.isArray(msg) || typeof msg !== 'object' || msg === null) return send(res, 400, { jsonrpc: '2.0', id: null, error: { code: -32600, message: 'One JSON-RPC message per request' } });
try {
const r = await answer(msg as Record<string, unknown>, o);
const r = pub ? await handle(msg as never, noRun, pub.surface) : await answer(msg as Record<string, unknown>, o);
return r ? send(res, 200, r) : send(res, 202);
} catch (e) { return send(res, 500, { jsonrpc: '2.0', id: (msg as { id?: unknown }).id ?? null, error: { code: -32603, message: String((e as Error).message) } }); }
});
@ -221,10 +244,20 @@ const home = (p: string) => (p.startsWith('~') ? join(homedir(), p.slice(1)) : p
export interface HttpFlags { host?: string; port?: string; roots?: string; project?: string; allowOrigin?: string; auth?: string; tokenFile?: string;
introspectUrl?: string; introspectKeyFile?: string; resource?: string; issuer?: string; allowRemoteToken?: boolean;
brokerProfiles?: string; readOnly?: boolean }
brokerProfiles?: string; readOnly?: boolean; public?: boolean; rate?: string; trustProxy?: boolean }
export async function serveHttp(f: HttpFlags): Promise<void> {
const host = f.host ?? '127.0.0.1';
if (f.public) {
const bad = (['auth', 'tokenFile', 'roots', 'project', 'brokerProfiles', 'allowOrigin', 'introspectUrl'] as const).filter(k => f[k] !== undefined);
if (bad.length || f.readOnly) throw new Error('--public takes no credentials or project: do not combine it with --auth, --token-file, --roots, --project, --broker-profiles, --allow-origin, --introspect-* or --read-only');
const rate = Number(f.rate ?? PUBLIC_DEFAULT_RATE);
if (!Number.isInteger(rate) || rate < 1) throw new Error('--rate: requests per minute per client, a whole number >= 1');
const port = Number(f.port ?? PUBLIC_PORT);
const s = await startHttp({ host, port, roots: [], project: null, allowOrigins: [], auth: { kind: 'none' }, public: { rate, trustProxy: !!f.trustProxy } });
console.error(`[storebound mcp] ${pkg.version} PUBLIC key-less server on http://${host}:${s.port}/mcp (MCP ${PROTOCOL}, legacy ${LEGACY.join(', ')}), read-only, ${rate} requests/min per client${f.trustProxy ? ', X-Forwarded-For trusted' : ''}`);
return;
}
const port = Number(f.port ?? DEFAULT_PORT);
const roots = (f.roots ?? homedir()).split(',').map(s => resolve(home(s.trim()))).filter(Boolean);
const projectArg = f.project ?? process.env.STOREBOUND_PROJECT;

317
cli/mcp-public.ts Normal file
View file

@ -0,0 +1,317 @@
// The public, key-less MCP surface (`storebound mcp --http --public`): what anyone may call without signing in.
// Everything is pure and in-process. This module never reads store credentials, project folders or the cwd, never
// starts a child process (no runner, no CLI) and makes one kind of network call: the public iTunes Search API
// (aso_search). The only files it reads are the skills/ folder of the storebound package itself.
import { existsSync, readdirSync, readFileSync } from 'node:fs';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import type { Surface } from './mcp';
import { APPLE_LIMITS, PLAY_LIMITS } from './lib/copy';
import { parseFrontMatter } from './lib/frontmatter';
import { appleLocaleIssues, playLangIssues } from './lib/listing-rules';
import { IMAGE_SPECS, PREVIEW_RULES, VIDEO_TARGETS } from './shots/spec';
export const PUBLIC_INSTRUCTIONS = [
'This is the public, key-less storebound server: anyone may use it, it needs no sign-in and it never touches store accounts, keys or project files.',
'It is read-only and offers store knowledge: the skills (skills_list, skill_get), known store pitfalls (pitfalls_search), listing and screenshot limits (store_limits), a text linter for App Store / Google Play listings (listing_lint) and a public App Store search (aso_search).',
'For real store work (builds, uploads, listings, releases) install the toolchain: `npx storebound` (see https://storebound.dev) and use its own MCP server, `npx storebound mcp`.',
].join(' ');
export interface PublicDeps {
/** The folder with <name>/SKILL.md files. Default: skills/ of the storebound package that contains this file. */
skillsDir?: string;
/** iTunes Search API endpoint (tests point it at a local fake). */
searchBase?: string;
fetch?: typeof fetch;
}
export interface PublicResult { text: string; data?: unknown; isError?: boolean }
export interface PublicTool {
name: string; title: string; description: string;
inputSchema: { type: 'object'; properties: Record<string, Schema>; required?: string[]; additionalProperties: false };
network?: boolean;
run(input: Record<string, unknown>): Promise<PublicResult> | PublicResult;
}
export interface Schema { type: string; description?: string; enum?: string[]; pattern?: string; minLength?: number; maxLength?: number; minimum?: number; maximum?: number;
properties?: Record<string, Schema>; additionalProperties?: boolean }
export const SEARCH_BASE = 'https://itunes.apple.com/search';
const MAX_TEXT = 40_000;
const SKILL_NAME = /^[a-z0-9-]+$/;
// ---- input validation (JSON-Schema subset these tools use) ----
export function validatePublic(schema: Schema, input: unknown, path = ''): string[] {
const e: string[] = [];
if (input === undefined || input === null) input = {};
if (typeof input !== 'object' || Array.isArray(input)) return [`${path || 'arguments'}: must be an object`];
const i = input as Record<string, unknown>;
const props = schema.properties ?? {};
for (const r of (schema as { required?: string[] }).required ?? []) if (i[r] === undefined) e.push(`${path}${r}: required`);
for (const [k, v] of Object.entries(i)) {
const p = props[k];
if (!p) { e.push(`${path}${k}: unknown argument`); continue; }
if (v === undefined) continue;
if (p.type === 'object') { e.push(...validatePublic(p, v, `${path}${k}.`)); continue; }
if (p.type === 'string') {
if (typeof v !== 'string') { e.push(`${path}${k}: must be a string`); continue; }
if (p.enum && !p.enum.includes(v)) e.push(`${path}${k}: one of ${p.enum.join(', ')}`);
if (p.pattern && !new RegExp(p.pattern).test(v)) e.push(`${path}${k}: does not match ${p.pattern}`);
if (p.minLength !== undefined && v.length < p.minLength) e.push(`${path}${k}: at least ${p.minLength} characters`);
if (p.maxLength !== undefined && v.length > p.maxLength) e.push(`${path}${k}: at most ${p.maxLength} characters`);
} else if (p.type === 'integer') {
if (typeof v !== 'number' || !Number.isInteger(v)) { e.push(`${path}${k}: must be an integer`); continue; }
if (p.minimum !== undefined && v < p.minimum) e.push(`${path}${k}: >= ${p.minimum}`);
if (p.maximum !== undefined && v > p.maximum) e.push(`${path}${k}: <= ${p.maximum}`);
}
}
return e;
}
const clipText = (s: string, max = MAX_TEXT): string => (s.length <= max ? s : `${s.slice(0, max)}\n[... ${s.length - max} characters cut]`);
const oneLine = (s: string, max: number): string => { const t = s.replace(/\s+/g, ' ').trim(); return t.length <= max ? t : `${t.slice(0, max - 1)}…`; };
// ---- skills (from the package) ----
function packageSkillsDir(): string {
const start = dirname(fileURLToPath(import.meta.url));
for (let d = start; ; d = dirname(d)) {
try {
const pkg = JSON.parse(readFileSync(join(d, 'package.json'), 'utf8')) as { name?: string };
if (pkg.name === 'storebound') return join(d, 'skills');
} catch { /* keep looking */ }
if (dirname(d) === d) break;
}
return resolve(start, '..', 'skills');
}
export interface SkillInfo { name: string; description: string; text: string }
export function loadSkills(dir: string): SkillInfo[] {
if (!existsSync(dir)) return [];
const out: SkillInfo[] = [];
for (const name of readdirSync(dir).filter(n => SKILL_NAME.test(n)).sort()) {
let text: string;
try { text = readFileSync(join(dir, name, 'SKILL.md'), 'utf8'); } catch { continue; }
const { data } = parseFrontMatter(text);
out.push({ name, description: oneLine(typeof data.description === 'string' ? data.description : '', 400), text });
}
return out;
}
// ---- pitfalls ----
export interface Section { heading: string; body: string }
export function sectionsOf(md: string): Section[] {
const parts = md.split(/^## /m).slice(1);
return parts.map(p => { const nl = p.indexOf('\n'); return { heading: (nl < 0 ? p : p.slice(0, nl)).trim(), body: nl < 0 ? '' : p.slice(nl + 1).trim() }; });
}
const APPLE_RE = /app store|apple|testflight|\bios\b|\basc\b|xcode|itunes/i;
const PLAY_RE = /google play|\bplay\b|\bgradle\b|\baab\b|android/i;
export function searchSections(sections: Section[], query: string, store?: 'apple' | 'play', max = 8): { heading: string; excerpt: string; score: number }[] {
const words = [...new Set(query.toLowerCase().split(/[^\p{L}\p{N}]+/u).filter(w => w.length > 1))].slice(0, 12);
if (!words.length) return [];
const scored = sections.map(s => {
const head = s.heading.toLowerCase(), body = s.body.toLowerCase();
let score = 0;
for (const w of words) { if (head.includes(w)) score += 5; const n = body.split(w).length - 1; score += Math.min(n, 5); }
return { s, score };
}).filter(x => x.score > 0);
const filtered = scored.filter(({ s }) => {
if (!store) return true;
const t = `${s.heading} ${s.body}`;
const a = APPLE_RE.test(t), p = PLAY_RE.test(t);
return store === 'apple' ? a || !p : p || !a;
});
return filtered.sort((a, b) => b.score - a.score).slice(0, max).map(({ s, score }) => {
// the excerpt starts at the first matching line
const lines = s.body.split('\n');
const at = Math.max(0, lines.findIndex(l => words.some(w => l.toLowerCase().includes(w))));
return { heading: s.heading, score, excerpt: clipText(lines.slice(at).join('\n'), 1200) };
});
}
// ---- limits ----
export function limits(store?: 'apple' | 'play'): Record<string, unknown> {
const specs = (s: 'apple' | 'play') => Object.entries(IMAGE_SPECS).filter(([, v]) => v.store === s).map(([key, v]) => ({
key, type: v.type, label: v.label, acceptedPixelSizes: v.sizes.map(([w, h]) => `${w}x${h}`), perSet: `${v.min}-${v.max}`,
maxBytes: v.maxBytes, alpha: v.alpha, ...(v.landscapeOnly ? { landscapeOnly: true } : {}),
}));
const previews = (s: 'apple' | 'play') => Object.values(VIDEO_TARGETS).filter(v => v.store === s).map(v => ({ label: v.label, size: `${v.w}x${v.h}` }));
const apple = {
textLimitsCharacters: { ...APPLE_LIMITS },
keywordField: ['at most 100 characters (characters, not bytes)', 'comma-separated, no spaces around commas (they count)', 'fill at least 90 of the 100', 'no repeats of words already in the name or subtitle (they are indexed anyway)', 'no duplicates', 'single words combine on their own; multi-word phrases waste characters'],
screenshots: specs('apple'),
appPreviews: { targets: previews('apple'), ...PREVIEW_RULES },
};
const play = {
textLimitsCharacters: { ...PLAY_LIMITS },
screenshots: specs('play'),
images: { featureGraphic: '1024x500, JPEG or 24-bit PNG, no alpha', icon: '512x512 PNG (Play listing icon)' },
promoVideo: previews('play'),
};
return store === 'apple' ? { apple } : store === 'play' ? { play } : { apple, play };
}
// ---- listing lint ----
const APPLE_FIELDS = ['name', 'subtitle', 'keywords', 'promotionalText', 'description', 'whatsNew'] as const;
const PLAY_FIELDS = ['title', 'shortDescription', 'fullDescription', 'whatsNew'] as const;
const FIELD_MAX = 5000;
export function lintListing(store: 'apple' | 'play', fields: Record<string, unknown>, locale: string): { problems: { level: string; field: string; msg: string }[]; counts: Record<string, string> } {
const f = fields as Record<string, string | undefined>;
const counts: Record<string, string> = {};
let issues: { level: string; field: string; msg: string }[];
if (store === 'apple') {
const v = Object.fromEntries(APPLE_FIELDS.filter(k => f[k] !== undefined).map(k => [k, f[k]!]));
issues = appleLocaleIssues(locale, v, '');
// keywords with no name / subtitle given still get the stand-alone keyword rules
if (v.keywords === undefined) issues = issues.filter(i => i.field !== 'keywords');
for (const k of APPLE_FIELDS) if (v[k] !== undefined) counts[k] = `${[...v[k]!].length}/${APPLE_LIMITS[k]}`;
} else {
const v = { title: f.title, short: f.shortDescription, full: f.fullDescription, whatsNew: f.whatsNew };
issues = playLangIssues(locale, v, f.title ?? '');
for (const [k, lim] of [['title', 'title'], ['shortDescription', 'short'], ['fullDescription', 'full'], ['whatsNew', 'whatsNew']] as const)
if (f[k] !== undefined) counts[k] = `${[...f[k]!].length}/${PLAY_LIMITS[lim]}`;
const rename: Record<string, string> = { short: 'shortDescription', full: 'fullDescription' };
issues = issues.map(i => ({ ...i, field: rename[i.field] ?? i.field, msg: i.msg.replace(/^(short|full) is /, (_m, w: string) => `${rename[w]} is `) }));
}
// fields not given are not "missing": only the ones the caller sent are judged
const given = new Set(Object.keys(fields));
const problems = issues.filter(i => !/is required$|description is required$/.test(i.msg) || given.has(i.field));
return { problems: problems.map(i => ({ level: i.level, field: i.field, msg: i.msg })), counts };
}
// ---- iTunes search ----
interface ItunesApp { trackId?: number; trackName?: string; sellerName?: string; averageUserRating?: number; userRatingCount?: number; primaryGenreName?: string }
export async function searchApps(term: string, country: string, limit: number, base: string, f: typeof fetch): Promise<{ results: Record<string, unknown>[] }> {
const url = `${base}${base.includes('?') ? '&' : '?'}${new URLSearchParams({ term, country: country.toLowerCase(), media: 'software', entity: 'software', limit: String(limit) })}`;
const r = await f(url, { signal: AbortSignal.timeout(10_000), headers: { accept: 'application/json' } });
if (!r.ok) throw new Error(`the App Store search answered ${r.status}`);
const j = (await r.json()) as { results?: ItunesApp[] };
const results = (j.results ?? []).slice(0, limit).map((a, n) => ({
rank: n + 1, name: oneLine(a.trackName ?? '', 120), developer: oneLine(a.sellerName ?? '', 120), id: a.trackId ?? null,
rating: typeof a.averageUserRating === 'number' ? Math.round(a.averageUserRating * 100) / 100 : null,
ratingCount: a.userRatingCount ?? 0, genre: a.primaryGenreName ?? null,
}));
return { results };
}
// ---- the tools ----
const store: Schema = { type: 'string', enum: ['apple', 'play'] };
export function publicTools(deps: PublicDeps = {}): PublicTool[] {
let skills: SkillInfo[] | null = null;
const getSkills = () => (skills ??= loadSkills(deps.skillsDir ?? packageSkillsDir()));
const obj = (properties: Record<string, Schema>, required?: string[]): PublicTool['inputSchema'] => ({ type: 'object', properties, ...(required ? { required } : {}), additionalProperties: false });
const textField = (description: string, maxLength = FIELD_MAX): Schema => ({ type: 'string', description, maxLength });
const locale: Schema = { type: 'string', description: 'Locale or language id, e.g. en-US (default en-US)', pattern: '^[A-Za-z]{2,3}([-_][A-Za-z0-9]{2,8}){0,2}$', maxLength: 20 };
return [
{ name: 'skills_list', title: 'List storebound skills', description: 'Name and one-line description of every storebound skill (step-by-step guides for store releases, listings, screenshots, IAP, privacy, ASO ...).',
inputSchema: obj({}),
run: () => { const list = getSkills().map(s => ({ name: s.name, description: s.description })); return { text: list.map(s => `${s.name}: ${s.description}`).join('\n'), data: { skills: list } }; } },
{ name: 'skill_get', title: 'Read one skill', description: 'The full SKILL.md text of one skill (name from skills_list).',
inputSchema: obj({ name: { type: 'string', description: 'Skill name, e.g. aso-basics', pattern: SKILL_NAME.source, minLength: 1, maxLength: 80 } }, ['name']),
run: i => {
const s = getSkills().find(x => x.name === i.name);
if (!s) return { text: `Unknown skill: ${String(i.name)}. Call skills_list for the names.`, isError: true };
return { text: clipText(s.text, 120_000), data: { name: s.name } };
} },
{ name: 'pitfalls_search', title: 'Search known store pitfalls', description: 'Sections of the store-pitfalls skill (mistakes made in real App Store and Google Play releases) that match the query words, best first (at most 8, each clipped).',
inputSchema: obj({ query: { type: 'string', description: 'Words to look for, e.g. "keywords rejected screenshots"', minLength: 2, maxLength: 200 }, store }, ['query']),
run: i => {
const sk = getSkills().find(x => x.name === 'store-pitfalls');
if (!sk) return { text: 'The store-pitfalls skill is not in this package.', isError: true };
const hits = searchSections(sectionsOf(sk.text), String(i.query), i.store as 'apple' | 'play' | undefined);
if (!hits.length) return { text: 'No pitfall matches those words. Try fewer or different words.', data: { matches: [] } };
return { text: clipText(hits.map(h => `## ${h.heading}\n${h.excerpt}`).join('\n\n---\n\n')), data: { matches: hits.map(h => ({ heading: h.heading, score: h.score })) } };
} },
{ name: 'store_limits', title: 'Store listing and image limits', description: 'Listing field character limits, the App Store keyword field rules, screenshot and app preview sizes per device class and the Google Play feature graphic and icon sizes.',
inputSchema: obj({ store }),
run: i => { const d = limits(i.store as 'apple' | 'play' | undefined); return { text: JSON.stringify(d, null, 2), data: d }; } },
{ name: 'listing_lint', title: 'Lint a store listing', description: 'Runs storebound\'s listing and ASO checks on the text you give (limits, keyword field rules, repeated name words, other platforms or apps named, placeholder text, keyword stuffing). Only the fields you send are judged. Nothing is stored.',
inputSchema: obj({
store, locale,
fields: { type: 'object', description: 'Apple: name, subtitle, keywords, promotionalText, description, whatsNew. Play: title, shortDescription, fullDescription, whatsNew.', additionalProperties: false,
properties: Object.fromEntries([...new Set([...APPLE_FIELDS, ...PLAY_FIELDS])].map(k => [k, textField(`The ${k} text`)])) },
}, ['store', 'fields']),
run: i => {
const st = i.store as 'apple' | 'play';
const fields = i.fields as Record<string, unknown>;
const allowed: readonly string[] = st === 'apple' ? APPLE_FIELDS : PLAY_FIELDS;
const bad = Object.keys(fields).filter(k => !allowed.includes(k));
if (bad.length) return { text: `Invalid arguments: fields: ${bad.join(', ')} not used for ${st}; use ${allowed.join(', ')}`, isError: true };
if (!Object.keys(fields).length) return { text: 'Invalid arguments: fields: send at least one field', isError: true };
const r = lintListing(st, fields, String(i.locale ?? 'en-US').replace('_', '-'));
const lines = r.problems.map(p => `${p.level.toUpperCase()} ${p.field}: ${p.msg}`);
return { text: `${Object.entries(r.counts).map(([k, v]) => `${k} ${v}`).join(', ')}\n${lines.length ? lines.join('\n') : 'No problems found.'}`, data: r };
} },
{ name: 'aso_search', title: 'Search the App Store', description: 'The public iTunes Search API: the top apps for a search term in one storefront (name, developer, id, rating, rating count, genre). The order is close to, not the same as, App Store search.',
network: true,
inputSchema: obj({ term: { type: 'string', description: 'Search term', minLength: 1, maxLength: 100 }, country: { type: 'string', description: 'Two-letter storefront, default us', pattern: '^[A-Za-z]{2}$', minLength: 2, maxLength: 2 },
limit: { type: 'integer', description: 'Results, 1-25 (default 10)', minimum: 1, maximum: 25 } }, ['term']),
run: async i => {
try {
const r = await searchApps(String(i.term), String(i.country ?? 'us'), Number(i.limit ?? 10), deps.searchBase ?? SEARCH_BASE, deps.fetch ?? fetch);
return { text: r.results.map(a => `${a.rank}. ${String(a.name)} (${String(a.developer)}) id ${String(a.id)}, ${a.rating ?? 'no rating'} / ${String(a.ratingCount)} ratings, ${String(a.genre)}`).join('\n') || 'No results.', data: r };
} catch (e) { return { text: `Search failed: ${(e as Error).message}`, isError: true }; }
} },
];
}
/** The surface handle() serves in public mode: only these tools exist (the type lives in mcp.ts). */
export function publicSurface(deps: PublicDeps = {}): Surface {
const tools = publicTools(deps);
return {
instructions: PUBLIC_INSTRUCTIONS,
list: () => tools.map(t => ({ name: t.name, title: t.title, description: t.description, inputSchema: t.inputSchema,
annotations: { title: t.title, readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: !!t.network } })),
call: async (name, input) => {
const t = tools.find(x => x.name === name);
if (!t) return null;
const problems = validatePublic(t.inputSchema, input);
if (problems.length) return { text: `Invalid arguments: ${problems.join('; ')}`, isError: true };
const r = await t.run((input ?? {}) as Record<string, unknown>);
return { ...r, text: clipText(r.text) };
},
};
}
// ---- rate limit ----
export interface Limiter { hit(key: string): { ok: boolean; retryAfter: number } }
/** Fixed one-minute window per client key, kept in memory; expired windows are swept so the map stays small. */
export function createLimiter(perMinute: number, now: () => number = Date.now): Limiter {
const win = new Map<string, { start: number; n: number }>();
let sweptAt = now();
return {
hit(key) {
const t = now();
if (t - sweptAt > 60_000 || win.size > 50_000) { for (const [k, w] of win) if (t - w.start >= 60_000) win.delete(k); sweptAt = t; }
let w = win.get(key);
if (!w || t - w.start >= 60_000) { w = { start: t, n: 0 }; win.set(key, w); }
w.n++;
return w.n <= perMinute ? { ok: true, retryAfter: 0 } : { ok: false, retryAfter: Math.max(1, Math.ceil((w.start + 60_000 - t) / 1000)) };
},
};
}
/** The client key: the first X-Forwarded-For entry only when the proxy is trusted, else the socket address. */
export function clientKey(forwardedFor: string | string[] | undefined, remote: string | undefined, trustProxy: boolean): string {
if (trustProxy) {
const h = Array.isArray(forwardedFor) ? forwardedFor[0] : forwardedFor;
const first = h?.split(',')[0]?.trim();
if (first) return first.slice(0, 64);
}
return remote ?? 'unknown';
}

View file

@ -56,6 +56,13 @@ export const INSTRUCTIONS = [
'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> }
@ -71,7 +78,7 @@ export function eraOf(m: Msg): { era: 'modern' | 'legacy'; version: string | nul
}
/** 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, 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);
@ -83,7 +90,7 @@ export async function handle(m: Msg, run: (argv: string[], id: Id) => Promise<{
protocolVersion: LEGACY.includes(asked) ? asked : LEGACY_PROTOCOL,
capabilities: { tools: { listChanged: false } },
serverInfo: { ...serverInfo(), description: pkg.description },
instructions: INSTRUCTIONS,
instructions: surface?.instructions ?? INSTRUCTIONS,
} };
}
@ -101,11 +108,17 @@ export async function handle(m: Msg, run: (argv: string[], id: Id) => Promise<{
switch (m.method) {
case 'server/discover':
return ok({ supportedVersions: SUPPORTED, capabilities: { tools: {} }, instructions: INSTRUCTIONS, ttlMs: TOOLS_TTL_MS, cacheScope: 'public' });
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: listTools(bypassOn()), ...(era === 'modern' ? { ttlMs: TOOLS_TTL_MS, cacheScope: 'public' } : {}) });
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>;

View file

@ -58,6 +58,44 @@ refused unless `--allow-origin` names it.
- `project` may also be a folder name under the roots (a shared server keeps its checkouts there).
- `/health` answers `{ ok, version }` without auth.
## Public key-less server
`storebound mcp --http --public [--port 7788] [--host 127.0.0.1] [--rate 60] [--trust-proxy]` is a separate mode for a
server anyone may connect to (for example behind a public HTTPS address). No sign-in, no token, no project: it never
reads store credentials, project folders or the working directory, never starts a child process and never runs the CLI.
It is read-only and keeps no state. The only network call it makes is the public iTunes Search API (`aso_search`).
It cannot be combined with `--auth`, `--token-file`, `--roots`, `--project`, `--broker-profiles`, `--allow-origin`
or `--read-only`.
Tools (and only these; the CLI tools of the normal server do not exist here, a call gets "Unknown tool"):
- `skills_list`: name and one-line description of every skill in the package.
- `skill_get {name}`: the full SKILL.md of one skill (`^[a-z0-9-]+$`, must be in the list).
- `pitfalls_search {query, store?}`: the sections of the store-pitfalls skill that match the query words, best first (at
most 8, each clipped). `store` is `apple` or `play`.
- `store_limits {store?}`: listing field character limits, the App Store keyword field rules, screenshot and app
preview sizes per device class, Play feature graphic and icon sizes.
- `listing_lint {store, fields, locale?}`: storebound's listing and ASO checks on the text you send (limits, keyword
field rules, repeated name words, other platforms or apps named, placeholder text, keyword stuffing). Apple fields:
`name`, `subtitle`, `keywords`, `promotionalText`, `description`, `whatsNew`. Play fields: `title`,
`shortDescription`, `fullDescription`, `whatsNew`. Only the fields you send are judged; nothing is stored.
- `aso_search {term, country?, limit?}`: top apps for a term in one storefront from the iTunes Search API (name,
developer, id, rating, rating count, genre; 1-25 results, 10 s timeout).
Limits: `--rate N` requests per minute per client (default 60; over the limit the answer is 429 with `Retry-After`),
bodies over 64 KB get 413, every tool input is validated against a closed schema, and output is bounded. Behind a
reverse proxy add `--trust-proxy` so the first `X-Forwarded-For` entry is the client; without it that header is
ignored. Any browser origin may call it (there is no ambient credential), and CORS preflight is answered. `/health`
works as before. Request bodies are not logged.
```bash
storebound mcp --http --public --port 7788 --trust-proxy
claude mcp add --transport http storebound-public https://mcp.example.com/mcp
```
For real store work (builds, uploads, listings, releases) use `npx storebound` and its own MCP server.
## Desktop machine interface
`storebound serve --stdio` is what the desktop app (`apps/desktop`, in progress) runs as its sidecar: newline-delimited

270
tests/mcp-public.test.ts Normal file
View file

@ -0,0 +1,270 @@
import { describe, expect, test } from 'bun:test';
import { readFileSync } from 'node:fs';
import { createServer } from 'node:http';
import { handle } from '../cli/mcp';
import { PUBLIC_MAX_BODY, startHttp } from '../cli/mcp-http';
import { clientKey, createLimiter, publicSurface, searchSections, sectionsOf, validatePublic } from '../cli/mcp-public';
const noRun = async (): Promise<{ code: number; out: string }> => { throw new Error('a child process must never start in public mode'); };
async function fakeItunes(): Promise<{ url: string; close: () => void; queries: string[] }> {
const queries: string[] = [];
const srv = createServer((req, res) => {
queries.push(req.url ?? '');
res.setHeader('content-type', 'application/json');
res.end(JSON.stringify({ resultCount: 2, results: [
{ trackId: 111, trackName: 'Example One', sellerName: 'Example Studio', averageUserRating: 4.56789, userRatingCount: 1234, primaryGenreName: 'Games', bundleId: 'com.example.one', description: 'x'.repeat(5000) },
{ trackId: 222, trackName: 'Example Two', sellerName: 'Other Studio', primaryGenreName: 'Productivity' },
] }));
});
await new Promise<void>(r => srv.listen(0, '127.0.0.1', () => r()));
return { url: `http://127.0.0.1:${(srv.address() as { port: number }).port}/search`, close: () => srv.close(), queries };
}
type Reply = { error?: { message: string }; result?: { isError: boolean; content: { text: string }[]; structuredContent?: { result: any } } };
/** One tools/call through the real handler, as the HTTP server does it. */
async function call(surface: ReturnType<typeof publicSurface>, name: string, args: unknown): Promise<Reply> {
return (await handle({ jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name, arguments: args as Record<string, unknown> } }, noRun, surface)) as Reply;
}
const text = (r: Reply) => r.result!.content[0]!.text;
describe('tool list', () => {
const surface = publicSurface();
test('only the public tools, all read-only, closed schemas', async () => {
const r = (await handle({ jsonrpc: '2.0', id: 1, method: 'tools/list' }, noRun, surface))!.result as { tools: any[] };
expect(r.tools.map(t => t.name).sort()).toEqual(['aso_search', 'listing_lint', 'pitfalls_search', 'skill_get', 'skills_list', 'store_limits']);
for (const t of r.tools) {
expect(t.inputSchema.additionalProperties).toBe(false);
expect(t.annotations.readOnlyHint).toBe(true);
expect(t.annotations.destructiveHint).toBe(false);
}
expect(r.tools.find(t => t.name === 'aso_search').annotations.openWorldHint).toBe(true);
expect(r.tools.find(t => t.name === 'store_limits').annotations.openWorldHint).toBe(false);
});
test('initialize and server/discover say it is the public key-less server', async () => {
const init = (await handle({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2025-06-18' } }, noRun, surface))!.result as { instructions: string };
expect(init.instructions).toContain('public, key-less');
expect(init.instructions).toContain('npx storebound');
const meta = { 'io.modelcontextprotocol/protocolVersion': '2026-07-28', 'io.modelcontextprotocol/clientCapabilities': {} };
const d = (await handle({ jsonrpc: '2.0', id: 2, method: 'server/discover', params: { _meta: meta } }, noRun, surface))!.result as { instructions: string };
expect(d.instructions).toContain('public, key-less');
});
test('CLI tools are not reachable', async () => {
for (const name of ['check', 'play_listing', 'owner_step', 'validate', 'api_get']) {
const r = await call(surface, name, {});
expect(r.error?.message).toContain('Unknown tool');
}
});
});
describe('skills', () => {
const surface = publicSurface();
test('skills_list names every skill of the package', async () => {
const r = await call(surface, 'skills_list', {});
const skills = r.result!.structuredContent!.result.skills as { name: string; description: string }[];
expect(skills.length).toBeGreaterThan(20);
expect(skills.find(s => s.name === 'store-pitfalls')!.description.length).toBeGreaterThan(20);
expect(skills.every(s => /^[a-z0-9-]+$/.test(s.name) && !s.description.includes('\n'))).toBe(true);
});
test('skill_get returns the file text; bad names are refused', async () => {
const r = await call(surface, 'skill_get', { name: 'aso-basics' });
expect(r.result!.isError).toBe(false);
expect(text(r)).toContain('name: aso-basics');
for (const bad of ['../package', 'ASO-basics', 'no-such-skill', 'a/b', '']) expect((await call(surface, 'skill_get', { name: bad })).result!.isError).toBe(true);
expect((await call(surface, 'skill_get', {})).result!.isError).toBe(true);
expect((await call(surface, 'skill_get', { name: 'aso-basics', extra: 1 })).result!.isError).toBe(true);
});
});
describe('pitfalls_search', () => {
const surface = publicSurface();
test('finds sections, best first, at most 8', async () => {
const r = await call(surface, 'pitfalls_search', { query: 'keyword field length' });
expect(r.result!.isError).toBe(false);
expect(text(r)).toMatch(/^## /);
expect(text(r).toLowerCase()).toContain('keyword');
const m = r.result!.structuredContent!.result.matches as { score: number }[];
expect(m.length).toBeLessThanOrEqual(8);
expect(m.map(x => x.score)).toEqual([...m.map(x => x.score)].sort((a, b) => b - a));
expect(text(r).length).toBeLessThan(40_100);
});
test('store filter, no match, bad input', async () => {
expect((await call(surface, 'pitfalls_search', { query: 'screenshots', store: 'play' })).result!.isError).toBe(false);
expect(text(await call(surface, 'pitfalls_search', { query: 'zzqqxxnotaword' }))).toContain('No pitfall');
expect((await call(surface, 'pitfalls_search', { query: 'x', store: 'steam' })).result!.isError).toBe(true);
expect((await call(surface, 'pitfalls_search', { query: 'a' })).result!.isError).toBe(true);
expect((await call(surface, 'pitfalls_search', {})).result!.isError).toBe(true);
});
test('pure parts', () => {
const md = '# T\n\n## One\nalpha beta\n\n## Two\nbeta beta beta google play\n';
const s = sectionsOf(md);
expect(s.map(x => x.heading)).toEqual(['One', 'Two']);
expect(searchSections(s, 'beta')[0]!.heading).toBe('Two');
expect(searchSections(s, 'beta', 'apple').map(x => x.heading)).toEqual(['One']);
expect(searchSections(s, ' ')).toEqual([]);
});
});
describe('store_limits', () => {
const surface = publicSurface();
test('numbers come from the code', async () => {
const all = (await call(surface, 'store_limits', {})).result!.structuredContent!.result;
expect(all.apple.textLimitsCharacters).toMatchObject({ name: 30, subtitle: 30, keywords: 100 });
expect(all.play.textLimitsCharacters).toMatchObject({ title: 30, short: 80, full: 4000 });
expect(JSON.stringify(all.apple.screenshots)).toContain('1290x2796');
expect(JSON.stringify(all.play.screenshots)).toContain('1024x500');
const apple = (await call(surface, 'store_limits', { store: 'apple' })).result!.structuredContent!.result;
expect(Object.keys(apple)).toEqual(['apple']);
expect((await call(surface, 'store_limits', { store: 'x' })).result!.isError).toBe(true);
});
});
describe('listing_lint', () => {
const surface = publicSurface();
test('apple: limits, repeats, spaces; only sent fields are judged', async () => {
const r = await call(surface, 'listing_lint', { store: 'apple', fields: { name: 'Pixel Garden', subtitle: 'Grow plants', keywords: 'garden, plants,tiny,pixel' } });
expect(r.result!.isError).toBe(false);
const t = text(r);
expect(t).toContain('spaces around commas');
expect(t).toContain('already indexed');
expect(t).not.toContain('is required');
const long = await call(surface, 'listing_lint', { store: 'apple', fields: { name: 'N'.repeat(31) } });
expect(text(long)).toContain('ERROR name');
expect(text(long)).toContain('31/30');
});
test('play: stuffing, claims, limits', async () => {
const full = Array(120).fill('puzzle').join(' ') + ' fun games to enjoy daily with friends';
const r = await call(surface, 'listing_lint', { store: 'play', fields: { title: 'Best Puzzle', shortDescription: 'S'.repeat(81), fullDescription: full } });
const t = text(r);
expect(t).toContain('shortDescription is 81 characters');
expect(t).toContain('keyword stuffing');
expect(t).toContain('"best"');
});
test('bad input', async () => {
expect((await call(surface, 'listing_lint', { store: 'apple', fields: {} })).result!.isError).toBe(true);
expect((await call(surface, 'listing_lint', { store: 'apple', fields: { title: 'x' } })).result!.isError).toBe(true);
expect((await call(surface, 'listing_lint', { store: 'apple', fields: { name: 5 } })).result!.isError).toBe(true);
expect((await call(surface, 'listing_lint', { store: 'apple', fields: { name: 'x'.repeat(5001) } })).result!.isError).toBe(true);
expect((await call(surface, 'listing_lint', { store: 'apple', fields: { name: 'x' }, locale: 'en US!' })).result!.isError).toBe(true);
expect((await call(surface, 'listing_lint', { fields: { name: 'x' } })).result!.isError).toBe(true);
});
});
describe('aso_search (fake server only)', () => {
test('trims results, passes term, country and limit', async () => {
const fake = await fakeItunes();
try {
const surface = publicSurface({ searchBase: fake.url });
const r = await call(surface, 'aso_search', { term: 'pixel garden', country: 'DE', limit: 5 });
expect(r.result!.isError).toBe(false);
const res = r.result!.structuredContent!.result.results as Record<string, unknown>[];
expect(res[0]).toEqual({ rank: 1, name: 'Example One', developer: 'Example Studio', id: 111, rating: 4.57, ratingCount: 1234, genre: 'Games' });
expect(res[1]).toMatchObject({ id: 222, rating: null, ratingCount: 0 });
expect(JSON.stringify(res)).not.toContain('com.example.one');
expect(fake.queries[0]).toContain('term=pixel+garden');
expect(fake.queries[0]).toContain('country=de');
expect(fake.queries[0]).toContain('limit=5');
expect(fake.queries[0]).toContain('entity=software');
} finally { fake.close(); }
});
test('bad input is refused before any request; a failing server is an error result', async () => {
const fake = await fakeItunes();
try {
const surface = publicSurface({ searchBase: fake.url });
for (const bad of [{}, { term: '' }, { term: 'x', country: 'usa' }, { term: 'x', limit: 0 }, { term: 'x', limit: 26 }, { term: 'x', limit: 1.5 }, { term: 'x', url: 'http://evil.example.com' }])
expect((await call(surface, 'aso_search', bad)).result!.isError).toBe(true);
expect(fake.queries.length).toBe(0);
} finally { fake.close(); }
const dead = publicSurface({ searchBase: 'http://127.0.0.1:1/search' });
const r = await call(dead, 'aso_search', { term: 'x' });
expect(r.result!.isError).toBe(true);
expect(text(r)).toContain('Search failed');
});
});
describe('module design', () => {
test('mcp-public.ts has no process, runner, credential or cwd access', () => {
const src = readFileSync(new URL('../cli/mcp-public.ts', import.meta.url), 'utf8');
expect(src).not.toMatch(/child_process|\.\/lib\/runner|\.\/lib\/rt'|\.\/lib\/credentials|\.\/lib\/broker|process\.cwd|process\.env|spawn|exec\(/);
});
test('validatePublic', () => {
expect(validatePublic({ type: 'object', properties: { a: { type: 'string', maxLength: 2 } } }, { a: 'abc' })).toEqual(['a: at most 2 characters']);
expect(validatePublic({ type: 'object', properties: {} }, [])).toEqual(['arguments: must be an object']);
});
});
describe('rate limit', () => {
test('window counts, retry-after, sweep', () => {
let t = 1_000_000;
const l = createLimiter(3, () => t);
expect([1, 2, 3].map(() => l.hit('a').ok)).toEqual([true, true, true]);
const over = l.hit('a');
expect(over.ok).toBe(false);
expect(over.retryAfter).toBeGreaterThan(0);
expect(over.retryAfter).toBeLessThanOrEqual(60);
expect(l.hit('b').ok).toBe(true);
t += 61_000;
expect(l.hit('a').ok).toBe(true);
});
test('client key: forwarded header only when the proxy is trusted', () => {
expect(clientKey('client-a, proxy-1', 'proxy-1', true)).toBe('client-a');
expect(clientKey('client-a', 'proxy-1', false)).toBe('proxy-1');
expect(clientKey(undefined, 'proxy-1', true)).toBe('proxy-1');
});
});
describe('http, public mode', () => {
const post = (port: number, body: unknown, headers: Record<string, string> = {}) =>
fetch(`http://127.0.0.1:${port}/mcp`, { method: 'POST', headers: { 'content-type': 'application/json', ...headers }, body: typeof body === 'string' ? body : JSON.stringify(body) });
const base = { host: '127.0.0.1', port: 0, roots: [], project: null, allowOrigins: [], auth: { kind: 'none' } as const };
test('no sign-in, any origin, CORS preflight, tools/list, /health, GET 405', async () => {
const s = await startHttp({ ...base, public: { rate: 100, trustProxy: false } });
try {
const r = await post(s.port, { jsonrpc: '2.0', id: 1, method: 'tools/list' }, { origin: 'https://claude.example.com' });
expect(r.status).toBe(200);
expect(r.headers.get('access-control-allow-origin')).toBe('*');
expect(((await r.json()) as { result: { tools: unknown[] } }).result.tools.length).toBe(6);
const pre = await fetch(`http://127.0.0.1:${s.port}/mcp`, { method: 'OPTIONS', headers: { origin: 'https://claude.example.com', 'access-control-request-method': 'POST' } });
expect(pre.status).toBe(204);
expect((await fetch(`http://127.0.0.1:${s.port}/health`)).status).toBe(200);
expect((await fetch(`http://127.0.0.1:${s.port}/mcp`)).status).toBe(405);
const c = await post(s.port, { jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'check', arguments: { project: '/' } } });
expect(((await c.json()) as { error: { message: string } }).error.message).toContain('Unknown tool');
expect((await post(s.port, { jsonrpc: '2.0', method: 'notifications/initialized' })).status).toBe(202);
} finally { s.close(); }
});
test('429 with Retry-After over the limit; X-Forwarded-For counts only with trustProxy', async () => {
const s = await startHttp({ ...base, public: { rate: 2, trustProxy: false } });
try {
const ping = (h: Record<string, string> = {}) => post(s.port, { jsonrpc: '2.0', id: 1, method: 'ping' }, h);
expect((await ping({ 'x-forwarded-for': 'client-a' })).status).toBe(200);
expect((await ping({ 'x-forwarded-for': 'client-b' })).status).toBe(200); // not trusted: same client
const over = await ping({ 'x-forwarded-for': 'client-c' });
expect(over.status).toBe(429);
expect(Number(over.headers.get('retry-after'))).toBeGreaterThan(0);
expect((await fetch(`http://127.0.0.1:${s.port}/health`)).status).toBe(200); // health is not limited
} finally { s.close(); }
const t = await startHttp({ ...base, public: { rate: 1, trustProxy: true } });
try {
const ping = (ip: string) => post(t.port, { jsonrpc: '2.0', id: 1, method: 'ping' }, { 'x-forwarded-for': `${ip}, proxy-1` });
expect((await ping('client-a')).status).toBe(200);
expect((await ping('client-b')).status).toBe(200);
expect((await ping('client-a')).status).toBe(429);
} finally { t.close(); }
});
test('body over 64 KB is refused; bad JSON is a parse error', async () => {
const s = await startHttp({ ...base, public: { rate: 100, trustProxy: false } });
try {
const big = JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'ping', params: { pad: 'x'.repeat(PUBLIC_MAX_BODY) } });
expect((await post(s.port, big)).status).toBe(413);
expect((await post(s.port, '{nope')).status).toBe(400);
} finally { s.close(); }
});
test('auth none needs public mode', async () => {
expect(() => startHttp({ ...base })).toThrow('only for --public');
});
});