import { Cli as incur_Cli, z } from 'incur' import { App } from 'tapimo' import { data } from 'tapimo/apps' import * as TestApp from '../../test/App.js' import * as Runtime from '../../test/runtime.js' import { mcp } from './mcp.js' const origin = 'https://api.tempo-api.test' /** API key with read-only webhook scope, used to prove header forwarding. */ const reader = { id: 'key_reader', orgId: 'org_test', scopes: ['webhooks:read'], token: 'secret_reader', } satisfies TestApp.kvStore.Key /** Composes a test app with the MCP group mounted, dispatching in-process. */ function createHost( options: { app?: Host | undefined docs?: mcp.Options['docs'] | undefined } = {}, ) { const app = options.app ?? TestApp.create() const host = mcp({ docs: options.docs ?? false, fetch: (request) => app.fetch(request) }) return { app, host } } /** Stub remote docs MCP server: an in-process incur CLI serving `/mcp`. */ function docsStub() { const remote = incur_Cli.create('docs-remote', { version: '1.0.0' }).command('search', { description: 'Search docs', options: z.object({ query: z.string().describe('Search query') }), run: (context) => ({ hits: [context.options.query] }), }) return { fetch: (request: Request) => remote.fetch(request), url: 'http://docs.local/mcp' } } /** Anything serving `/mcp`: the group host, or a composed app it is mounted into. */ type Host = { fetch: (request: Request) => Response | Promise } async function request(host: Host, body: unknown, headers: Record = {}) { const response = await host.fetch( new Request(new URL('/mcp', origin), { body: JSON.stringify(body), headers: { accept: 'application/json, text/event-stream', 'content-type': 'application/json', ...headers, }, method: 'POST', }), ) expect(response.status).toBe(200) return (await response.json()) as { result: any } } function initialize(host: Host) { return request(host, { id: 1, jsonrpc: '2.0', method: 'initialize', params: { capabilities: {}, clientInfo: { name: 'test-client', version: '1.0.0' }, protocolVersion: '2025-03-26', }, }) } async function toolsList(host: Host) { const body = await request(host, { id: 2, jsonrpc: '2.0', method: 'tools/list', params: {} }) return (body.result.tools as { name: string }[]).map((tool) => tool.name) } async function toolsCall( host: Host, name: string, args: Record, headers: Record = {}, ) { const body = await request( host, { id: 3, jsonrpc: '2.0', method: 'tools/call', params: { arguments: args, name } }, headers, ) return body.result as { content: { text: string; type: string }[] isError?: boolean structuredContent?: Record } } /** Searches every page of the progressive tool catalog. */ async function toolsSearch(host: Host) { const tools: { annotations?: { readOnlyHint?: boolean }; name: string }[] = [] let offset = 0 while (true) { const result = resultJson(await toolsCall(host, 'search_tools', { limit: 20, offset })) as { nextOffset?: number tools: typeof tools } tools.push(...result.tools) if (result.nextOffset === undefined) return tools offset = result.nextOffset } } /** Loads the complete schema and metadata for one catalog tool. */ async function toolDetails(host: Host, name: string) { return resultJson(await toolsCall(host, 'get_tool_details', { name })) as { annotations?: { readOnlyHint?: boolean } inputSchema: { properties?: Record } name: string } } /** Inspects and executes a generated tool through its matching read or write gate. */ async function generatedToolCall( host: Host, name: string, args: Record, headers: Record = {}, ) { const details = await toolDetails(host, name) return toolsCall( host, details.annotations?.readOnlyHint === true ? 'call_read_tool' : 'call_write_tool', { arguments: args, name }, headers, ) } /** Parses a tool result: structured content when present, else the JSON text item. */ function resultJson(result: Awaited>) { return result.structuredContent ?? JSON.parse(result.content[0]!.text) } test('identifies as Tempo MCP', async () => { const { host } = createHost() expect((await initialize(host)).result).toMatchObject({ instructions: expect.stringContaining('Tempo MCP provides access'), serverInfo: { name: 'tempo', title: 'Tempo MCP', version: '0.0.0', }, }) }) test('scopes tools to the data and inbound funding domains', async () => { const { app, host } = createHost({ docs: docsStub() }) // The domain filter must drop paths that actually exist in the spec. const spec = (await (await app.fetch(new Request(new URL('/openapi.json', origin)))).json()) as { paths: Record } expect(Object.keys(spec.paths).some((path) => path.startsWith('/gecko/'))).toBe(true) expect(Object.keys(spec.paths).some((path) => path.startsWith('/v1/auth/'))).toBe(true) expect(Object.keys(spec.paths).some((path) => path.startsWith('/v1/orgs'))).toBe(true) await initialize(host) expect(await toolsList(host)).toEqual([ 'search_tools', 'get_tool_details', 'call_read_tool', 'call_write_tool', ]) const tools = (await toolsSearch(host)).map((tool) => tool.name) expect(tools).toContain('v1_tokens_get') expect(tools).toContain('v1_funding_quotes') expect(tools).toContain('v1_funding_transfers_post') expect(tools).toContain('docs_search') // RPC is part of the data domain tag group. expect(tools.some((name) => name.startsWith('rpc'))).toBe(true) // Out of domain: CoinGecko mirrors, session auth, management, relay. expect(tools.some((name) => name.startsWith('gecko_'))).toBe(false) expect(tools.some((name) => name.startsWith('v1_auth_'))).toBe(false) expect(tools.some((name) => name.startsWith('v1_orgs'))).toBe(false) expect(tools.some((name) => name.startsWith('v1_me'))).toBe(false) }) test('keeps the data tools functional when funding is omitted', async () => { const app = App.create({ auth: false, db: TestApp.database() }).route('/', data()) const { host } = createHost({ app }) await initialize(host) const tools = (await toolsSearch(host)).map((tool) => tool.name) expect(tools).toContain('v1_tokens_get') expect(tools.some((name) => name.startsWith('v1_funding_'))).toBe(false) }) test('keeps tool input schemas compact', async () => { const { host } = createHost() await initialize(host) const tool = await toolDetails(host, 'v1_tokens_get') const properties = tool.inputSchema.properties ?? {} // Credential inputs stay off the schema; auth rides the forwarded header. expect(properties['authorization']).toBeUndefined() expect(properties['tempo-api-key']).toBeUndefined() // Shared parameters carry the terse tool descriptions. expect(properties['limit']?.description).toBe('Items per page.') expect(properties['cursor']?.description).toBe( 'Keyset cursor from a previous response `nextCursor`; omit for the first page.', ) // `compact` strips examples from generated schemas. expect(JSON.stringify(tool.inputSchema)).not.toContain('"examples"') }) test('executes generated tools against the composed app', async () => { // Verified tokens are database-backed, so seed the default chain. const db = TestApp.database() await TestApp.verifiedSeed(db, Runtime.get().chainId) const { host } = createHost({ app: TestApp.create({ db }) }) await initialize(host) const result = await generatedToolCall(host, 'v1_tokens_get', { verified: true }) expect(result.isError).toBeFalsy() const body = resultJson(result) as { data: { verified: boolean }[]; nextCursor: unknown } expect(body.data.length).toBeGreaterThan(0) expect(body.data.every((token) => token.verified === true)).toBe(true) }) test('forwards the caller Authorization header to authenticated tools', async () => { const { host } = createHost({ app: TestApp.create({ auth: { keys: [reader] } }) }) await initialize(host) const denied = await generatedToolCall(host, 'v1_webhooks_get', {}) expect(denied.isError).toBe(true) const allowed = await generatedToolCall( host, 'v1_webhooks_get', {}, { authorization: `Bearer ${reader.token}` }, ) expect(allowed.isError).toBeFalsy() expect(resultJson(allowed)).toMatchObject({ data: [] }) }) test('proxies docs tool calls to the remote MCP source', async () => { const { host } = createHost({ docs: docsStub() }) await initialize(host) const result = await generatedToolCall(host, 'docs_search', { query: 'fees' }) expect(result.isError).toBeFalsy() expect(resultJson(result)).toMatchObject({ hits: ['fees'] }) }) test('documents POST /mcp and keeps it open under auth enforcement', async () => { // Worker-style composition: the group mounted into the app it dispatches to. const app = TestApp.create() app.route('/', mcp({ docs: false, fetch: (request) => app.fetch(request) })) const spec = (await (await app.fetch(new Request(new URL('/openapi.json', origin)))).json()) as { paths: Record 'x-tagGroups': { name: string; tags: string[] }[] } const operation = spec.paths['/mcp']?.post expect(operation?.operationId).toBe('mcpRequest') expect(operation?.tags).toEqual(['MCP']) // Anonymous and any-valid-key access; no required scopes. expect(operation?.security).toEqual([{ apiKey: [] }, { queryApiKey: [] }, { bearerAuth: [] }, {}]) // Surfaces under `Data API` in the reference, but the tool filter excludes the `MCP` tag. const groups = Object.fromEntries(spec['x-tagGroups'].map((group) => [group.name, group.tags])) expect(groups['MCP']).toBeUndefined() expect(groups['Data API']).toContain('MCP') // Documenting the route makes it auth-enforced; the public lane keeps it // anonymously reachable, and the endpoint never surfaces itself as a tool. await initialize(app) const tools = (await toolsSearch(app)).map((tool) => tool.name) expect(tools.length).toBeGreaterThan(0) expect(tools.some((name) => name.startsWith('mcp'))).toBe(false) // A presented-but-invalid key is rejected at the transport. const rejected = await app.fetch( new Request(new URL('/mcp', origin), { body: JSON.stringify({ id: 1, jsonrpc: '2.0', method: 'tools/list', params: {} }), headers: { accept: 'application/json, text/event-stream', authorization: 'Bearer not-a-key', 'content-type': 'application/json', }, method: 'POST', }), ) expect(rejected.status).toBe(401) }) test('applies the MCP anonymous rate limit', async () => { const app = TestApp.create() app.route('/', mcp({ docs: false, fetch: (request) => app.fetch(request) })) const response = await app.fetch( new Request(new URL('/mcp', origin), { body: JSON.stringify({ id: 1, jsonrpc: '2.0', method: 'tools/list', params: {} }), headers: { accept: 'application/json, text/event-stream', 'content-type': 'application/json', }, method: 'POST', }), ) expect(response.headers.get('RateLimit-Limit')).toMatchInlineSnapshot(`"100"`) })