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:
parent
398d8cd375
commit
c500866295
8 changed files with 695 additions and 15 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
317
cli/mcp-public.ts
Normal 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';
|
||||
}
|
||||
21
cli/mcp.ts
21
cli/mcp.ts
|
|
@ -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>;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue