storebound/tests/mcp-public.test.ts

297 lines
17 KiB
TypeScript

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('the bare host is the endpoint too: POST / answers MCP, GET / names the connect URL', async () => {
const s = await startHttp({ ...base, public: { rate: 100, trustProxy: false } });
try {
const r = await fetch(`http://127.0.0.1:${s.port}/`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list' }) });
expect(r.status).toBe(200);
expect(((await r.json()) as { result: { tools: unknown[] } }).result.tools.length).toBe(6);
const g = await fetch(`http://127.0.0.1:${s.port}/`);
expect(g.status).toBe(200);
expect(await g.text()).toContain('claude mcp add --transport http storebound');
expect((await fetch(`http://127.0.0.1:${s.port}/other`)).status).toBe(404);
} finally { s.close(); }
});
test('a signed-in server keeps / closed', async () => {
const s = await startHttp({ ...base, auth: { kind: 'token', token: 't' } as const });
try { expect((await fetch(`http://127.0.0.1:${s.port}/`)).status).toBe(404); } 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');
});
});
test('App Store search keeps one shared budget of 20 calls a minute', async () => {
const { searchBudget } = await import('../cli/mcp-public');
const t0 = Date.now() + 3_600_000; // after any real call earlier in this file
let last: ReturnType<typeof searchBudget> = { ok: true };
for (let i = 0; i < 25; i++) last = searchBudget(t0 + i);
expect(last.ok).toBe(false);
expect(searchBudget(t0 + 61_000).ok).toBe(true);
});