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(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, name: string, args: unknown): Promise { return (await handle({ jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name, arguments: args as Record } }, 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[]; 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 = {}) => 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'); const b = await fetch(`http://127.0.0.1:${s.port}/`, { headers: { accept: 'text/html,application/xhtml+xml' }, redirect: 'manual' }); expect(b.status).toBe(302); expect(b.headers.get('location')).toBe('https://storebound.dev/'); 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 = {}) => 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 = { 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); });