1/**2 * MCP surface tests: raw stateless streamable-HTTP JSON-RPC requests per the3 * 2026-07-28 spec (per-request _meta envelope + Mcp-Method/Mcp-Name headers,4 * no initialize handshake, no session) POSTed straight at the fetch handler.5 */6import { describe, expect, it, vi } from 'vitest';7import llmsTxtOnDisk from '../../../docs/llms.txt?raw';8import type {9 AuthedUser,10 ScriptRecord,11 SimBundle,12 SimRecord,13 TribesService,14 ValidationReport,15} from '@tribes/protocol';16import { docsText } from './docs.js';17import { createMcpFetchHandler, handleLlmsTxt } from './handler.js';1819// ---------------------------------------------------------------------------20// Fixtures21// ---------------------------------------------------------------------------2223const USER: AuthedUser = { userId: 'u_1', externalId: 'google:123' };24const GOOD_TOKEN = 'trb_valid_token';2526const SCRIPT: ScriptRecord = {27 id: 'lg_abc123',28 name: 'gatherer',29 lang: 'js',30 version: 3,31 hash: 'a'.repeat(64),32 status: 'validated',33 createdAt: '2026-08-15T00:00:00.000Z',34 expiresAt: '2026-08-29T00:00:00.000Z',35 starred: false,36 title: null,37 description: null,38 sourceShared: false,39 modelId: null,40 linkedScriptId: null,41 trashedUntil: null,42 listed: false,43};4445const REPORT: ValidationReport = { ok: true, failedStage: null, detail: null };4647const SIM: SimRecord = {48 id: 'sim_xyz789',49 seed: '424242',50 config: { tribeCount: 2, mapSize: 64, maxTicks: 10_000 },51 scripts: [52 { slot: 0, scriptId: 'lg_abc123', scriptName: 'gatherer', version: 3, hash: 'a'.repeat(64) },53 { slot: 1, scriptId: 'lg_def456', scriptName: 'rusher', version: 1, hash: 'b'.repeat(64) },54 ],55 createdAt: '2026-08-15T00:00:00.000Z',56 expiresAt: '2026-08-29T00:00:00.000Z',57 starred: false,58 url: 'https://tribes.example.workers.dev/s/sim_xyz789',59 visibility: 'private',60 kind: 'user',61};6263const BUNDLE: SimBundle = { sim: SIM, sources: { ['a'.repeat(64)]: 'function tick() {}' } };6465function makeService(): TribesService {66 return {67 submitLogic: vi.fn(async () => ({ script: SCRIPT, report: REPORT })),68 listLogic: vi.fn(async () => [SCRIPT]),69 getLogic: vi.fn(async () => ({ ...SCRIPT, source: 'function tick(s, m, a) {}' })),70 deleteLogic: vi.fn(async () => ({ trashedUntil: '2026-08-29T00:00:00.000Z' })),71 restoreLogic: vi.fn(async () => SCRIPT),72 updateLogicMeta: vi.fn(async () => SCRIPT),73 star: vi.fn(async () => undefined),74 unstar: vi.fn(async () => undefined),75 listStars: vi.fn(async () => [{ kind: 'script' as const, id: 'lg_abc123' }]),76 setSharing: vi.fn(async () => ({77 id: 'u_1',78 externalId: 'google:123',79 name: null,80 publicSharing: true,81 nameLockedUntil: null,82 })),83 deleteAccount: vi.fn(async () => undefined),84 heart: vi.fn(async () => undefined),85 unheart: vi.fn(async () => undefined),86 listHearts: vi.fn(async () => []),87 browseScripts: vi.fn(async () => []),88 getPublicScript: vi.fn(async () => {89 throw { code: 'not_found', message: 'no such public script' };90 }),91 leaderboard: vi.fn(async () => []),92 listModels: vi.fn(async () => []),93 createSim: vi.fn(async () => SIM),94 listSims: vi.fn(async () => [SIM]),95 getSimBundle: vi.fn(async () => BUNDLE),96 linkAccount: vi.fn(async () => ({ linked: true, alreadyLinked: false })),97 };98}99100const authenticate = async (req: Request): Promise<AuthedUser | null> =>101 req.headers.get('authorization') === `Bearer ${GOOD_TOKEN}` ? USER : null;102103function makeHandler(service: TribesService = makeService()) {104 return {105 service,106 fetch: createMcpFetchHandler({ service, authenticate, docsText }),107 };108}109110const ctx = {111 waitUntil() {},112 passThroughOnException() {},113 props: {},114} as unknown as ExecutionContext;115116// ---------------------------------------------------------------------------117// Raw 2026-07-28 stateless request construction118// ---------------------------------------------------------------------------119120/** Per-request envelope required by the modern (2026-07-28) revision. */121const ENVELOPE = {122 'io.modelcontextprotocol/protocolVersion': '2026-07-28',123 'io.modelcontextprotocol/clientCapabilities': {},124 'io.modelcontextprotocol/clientInfo': { name: 'vitest-raw-client', version: '1.0.0' },125};126127interface RpcOptions {128 method: string;129 params?: Record<string, unknown>;130 token?: string | null;131 /** Value for the Mcp-Name standard header (required for tools/call). */132 name?: string;133}134135function rpcRequest({ method, params = {}, token = GOOD_TOKEN, name }: RpcOptions): Request {136 return new Request('http://localhost/mcp', {137 method: 'POST',138 headers: {139 host: 'localhost',140 'content-type': 'application/json',141 accept: 'application/json, text/event-stream',142 'mcp-protocol-version': '2026-07-28',143 'mcp-method': method,144 ...(name !== undefined ? { 'mcp-name': name } : {}),145 ...(token !== null ? { authorization: `Bearer ${token}` } : {}),146 },147 body: JSON.stringify({148 jsonrpc: '2.0',149 id: 1,150 method,151 params: { ...params, _meta: ENVELOPE },152 }),153 });154}155156function callTool(tool: string, args: Record<string, unknown>, token: string | null = GOOD_TOKEN) {157 return rpcRequest({158 method: 'tools/call',159 params: { name: tool, arguments: args },160 token,161 name: tool,162 });163}164165interface ToolCallEnvelope {166 jsonrpc: string;167 id: number;168 result?: {169 content?: Array<{ type: string; text: string }>;170 isError?: boolean;171 [key: string]: unknown;172 };173 error?: { code: number; message: string };174}175176async function invoke(fetchHandler: ReturnType<typeof createMcpFetchHandler>, req: Request) {177 const res = await fetchHandler(req, {}, ctx);178 const body = (await res.json()) as ToolCallEnvelope;179 return { res, body };180}181182function textOf(body: ToolCallEnvelope): string {183 const text = body.result?.content?.[0]?.text;184 expect(text, `expected text content, got: ${JSON.stringify(body)}`).toBeTypeOf('string');185 return text as string;186}187188// ---------------------------------------------------------------------------189// Tests190// ---------------------------------------------------------------------------191192describe('createMcpFetchHandler', () => {193 it('rejects requests without a bearer token: 401 JSON + WWW-Authenticate', async () => {194 const { fetch } = makeHandler();195 const res = await fetch(callTool('list_logic', {}, null), {}, ctx);196 expect(res.status).toBe(401);197 expect(res.headers.get('www-authenticate')).toContain('Bearer');198 const body = (await res.json()) as { error: string; message: string };199 expect(body.error).toBe('unauthorized');200 expect(body.message).toContain('trb_');201 });202203 it('rejects requests with a wrong bearer token', async () => {204 const { fetch } = makeHandler();205 const res = await fetch(callTool('list_logic', {}, 'trb_wrong'), {}, ctx);206 expect(res.status).toBe(401);207 });208209 it('is stateless: never emits an Mcp-Session-Id header', async () => {210 const { fetch } = makeHandler();211 const res = await fetch(callTool('list_logic', {}), {}, ctx);212 expect(res.status).toBe(200);213 expect(res.headers.get('mcp-session-id')).toBeNull();214 });215216 it('lists all 24 tools without any prior initialize handshake', async () => {217 const { fetch } = makeHandler();218 const { res, body } = await invoke(fetch, rpcRequest({ method: 'tools/list' }));219 expect(res.status).toBe(200);220 const tools = (body.result as { tools: Array<{ name: string }> }).tools.map((t) => t.name);221 expect(tools.sort()).toEqual(222 [223 'browse_scripts',224 'create_sim',225 'delete_logic',226 'get_example',227 'link_account',228 'get_leaderboard',229 'get_logic',230 'get_public_script',231 'get_scripting_docs',232 'get_sim',233 'heart',234 'list_examples',235 'list_hearts',236 'list_logic',237 'list_models',238 'list_sims',239 'list_stars',240 'restore_logic',241 'set_sharing',242 'star',243 'submit_logic',244 'unheart',245 'unstar',246 'update_logic_meta',247 ].sort(),248 );249 });250251 it('submit_logic happy path returns id/hash/version and hits the service with the authed user', async () => {252 const { fetch, service } = makeHandler();253 const { res, body } = await invoke(254 fetch,255 callTool('submit_logic', { name: 'gatherer', source: 'function tick(s, m, a) {}' }),256 );257 expect(res.status).toBe(200);258 expect(body.result?.isError).not.toBe(true);259 const payload = JSON.parse(textOf(body)) as { script: ScriptRecord; report: ValidationReport };260 expect(payload.script.id).toBe('lg_abc123');261 expect(payload.script.hash).toBe('a'.repeat(64));262 expect(payload.script.version).toBe(3);263 expect(payload.report.ok).toBe(true);264 expect(service.submitLogic).toHaveBeenCalledWith(USER, {265 name: 'gatherer',266 source: 'function tick(s, m, a) {}',267 lang: 'js', // zod default applied268 });269 });270271 it('maps star_limit service errors to an isError tool result with an actionable message', async () => {272 const service = makeService();273 service.star = vi.fn(async () => {274 throw { code: 'star_limit', message: 'you already have 100 stars' };275 });276 const { fetch } = makeHandler(service);277 const { res, body } = await invoke(fetch, callTool('star', { kind: 'sim', id: 'sim_xyz789' }));278 expect(res.status).toBe(200); // tool errors are in-band, not HTTP errors279 expect(body.result?.isError).toBe(true);280 const text = textOf(body);281 expect(text).toContain('star_limit');282 expect(text).toContain('you already have 100 stars');283 expect(text).toContain('unstar'); // the actionable hint284 });285286 it('maps not_found service errors with a lookup hint', async () => {287 const service = makeService();288 service.getLogic = vi.fn(async () => {289 throw { code: 'not_found', message: 'no script lg_nope' };290 });291 const { fetch } = makeHandler(service);292 const { body } = await invoke(fetch, callTool('get_logic', { id: 'lg_nope' }));293 expect(body.result?.isError).toBe(true);294 expect(textOf(body)).toContain('list_logic');295 });296297 it('rejects invalid tool input via the protocol zod schema (create_sim needs >= 2 scripts)', async () => {298 const { fetch, service } = makeHandler();299 const { body } = await invoke(fetch, callTool('create_sim', { scriptIds: ['lg_only_one'] }));300 // Schema validation failure surfaces as an error (in-band tool error or301 // JSON-RPC invalid-params, depending on SDK dispatch) — never a service call.302 const failed =303 body.result?.isError === true || (body.error !== undefined && body.error.code !== 0);304 expect(failed).toBe(true);305 expect(service.createSim).not.toHaveBeenCalled();306 });307308 it('get_sim returns the SimRecord from the bundle, without sources', async () => {309 const { fetch, service } = makeHandler();310 const { body } = await invoke(fetch, callTool('get_sim', { id: 'sim_xyz789' }));311 const payload = JSON.parse(textOf(body)) as SimRecord & { sources?: unknown };312 expect(payload.id).toBe('sim_xyz789');313 expect(payload.url).toContain('/s/sim_xyz789');314 expect(payload.sources).toBeUndefined();315 expect(service.getSimBundle).toHaveBeenCalledWith('sim_xyz789');316 });317318 it('get_scripting_docs returns docsText verbatim', async () => {319 const { fetch } = makeHandler();320 const { body } = await invoke(fetch, callTool('get_scripting_docs', {}));321 expect(body.result?.isError).not.toBe(true);322 expect(textOf(body)).toBe(docsText);323 });324325 it('answers CORS preflight without authentication', async () => {326 const { fetch } = makeHandler();327 const res = await fetch(328 new Request('http://localhost/mcp', {329 method: 'OPTIONS',330 headers: { host: 'localhost', origin: 'http://localhost:5173' },331 }),332 {},333 ctx,334 );335 expect(res.status).toBeLessThan(300);336 expect(res.headers.get('access-control-allow-origin')).toBeTruthy();337 });338});339340describe('handleLlmsTxt', () => {341 it('serves the docs as text/plain', async () => {342 const res = handleLlmsTxt(docsText);343 expect(res.status).toBe(200);344 expect(res.headers.get('content-type')).toContain('text/plain');345 expect(await res.text()).toBe(docsText);346 });347});348349describe('docs sync', () => {350 it('docs/llms.txt matches docsText from docs.ts byte-for-byte', () => {351 expect(llmsTxtOnDisk).toBe(docsText);352 });353354 it('docsText documents the exact contract constants from game.ts', () => {355 // Spot-check the load-bearing numbers so doc edits can't silently drift.356 for (const needle of [357 'function tick(state, memory, api)',358 '512 interrupt polls',359 '64 KiB',360 '32 MiB',361 '10,000',362 '14 days',363 '100',364 'min(coin, coinEarned)/20 + tradeVolume/40',365 '`?` unexplored',366 'api.rng()',367 '2026-07-28',368 ]) {369 expect(docsText).toContain(needle);370 }371 });372});