329 lines
21 KiB
TypeScript
329 lines
21 KiB
TypeScript
// 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 }
|
|
|
|
// Apple allows about 20 search calls a minute per IP: one shared budget for the whole server, whoever asks
|
|
const SEARCH_PER_MIN = 20;
|
|
const searchTimes: number[] = [];
|
|
export function searchBudget(now = Date.now()): { ok: true } | { ok: false; retryAfter: number } {
|
|
while (searchTimes.length && searchTimes[0]! <= now - 60_000) searchTimes.shift();
|
|
if (searchTimes.length >= SEARCH_PER_MIN) return { ok: false, retryAfter: Math.ceil((searchTimes[0]! + 60_000 - now) / 1000) };
|
|
searchTimes.push(now);
|
|
return { ok: true };
|
|
}
|
|
|
|
export async function searchApps(term: string, country: string, limit: number, base: string, f: typeof fetch): Promise<{ results: Record<string, unknown>[] }> {
|
|
const b = searchBudget();
|
|
if (!b.ok) throw new Error(`the App Store search is busy (Apple allows ${SEARCH_PER_MIN} calls a minute for this server): try again in ${b.retryAfter} s`);
|
|
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';
|
|
}
|