import { type Context, Hono } from 'hono' import { Cli as incur_Cli, type Openapi } from 'incur' import * as z from 'zod/mini' import type * as App from '../App.js' import * as Auth from '../internal/Auth.js' import * as OpenApi from '../internal/OpenApi.js' import * as Metadata from './metadata.js' /** Required document tag group whose operations surface as MCP tools. */ const dataTagGroup = 'Data API' /** Optional document tag group added when the funding app is mounted. */ const fundingTagGroup = 'Funding & Bridge API' /** Tags inside the group hidden from MCP: partner-shaped CoinGecko mirrors and the MCP endpoint itself. */ const excludedTags = ['CoinGecko', 'MCP'] /** Usage guidance surfaced to MCP clients on initialize. */ const instructions = "Tempo MCP provides access to the Tempo API's data and inbound funding domains, plus documentation. Authenticated tools accept a Tempo API key sent as an `Authorization: Bearer` header on the HTTP request. List endpoints paginate via `cursor`/`nextCursor`. `docs_*` tools search the Tempo documentation." /** Zod schemas owned by the MCP endpoint's OpenAPI description. */ export namespace schema { /** JSON-RPC request identifier. */ export const Id = z .union([z.string(), z.number(), z.null()]) .check(z.describe('Client-supplied JSON-RPC request id used to match responses to requests.')) /** JSON-RPC error payload. */ export const Error = z .object({ code: z .number() .check(z.int(), z.describe('Numeric JSON-RPC error code returned by the MCP server.')), data: z .optional(z.unknown()) .check(z.describe('Optional extra error details returned by the MCP server.')), message: z.string().check(z.describe('Human-readable JSON-RPC error message.')), }) .check(z.describe('JSON-RPC error object returned when a message fails.')) /** MCP JSON-RPC response payload. */ export const Response = z .object({ error: z .optional(Error) .check(z.describe('JSON-RPC error object returned when a message fails.')), id: z .optional(Id) .check( z.describe('Client-supplied JSON-RPC request id used to match responses to requests.'), ), jsonrpc: z.literal('2.0').check(z.describe('JSON-RPC protocol version; MCP uses `2.0`.')), result: z.optional(z.unknown()).check(z.describe('Result returned by the MCP method.')), }) .check(z.describe('One MCP JSON-RPC response returned by the server.')) } /** * The MCP route group: a stateless streamable-HTTP MCP server at `/mcp` whose * tools mirror the API's data domain and its optional inbound funding domain, * plus the remote Tempo docs tools. Mount alongside the other route groups: * * ```ts * const app = App.create(options) * const fetch = app.fetch.bind(app) * app.route('/', mcp({ fetch })) * ``` * * Capture `fetch` before adding request-level wrappers, so nested calls stay * within the outer request's instrumentation lifecycle. */ export function mcp(options: mcp.Options) { const { docs = 'https://mcp.tempo.xyz/mcp', fetch, name = 'tempo', title = 'Tempo MCP', version = '0.0.0', } = options // One incur instance per caller origin: the OpenAPI document is parsed once, // and generated commands rebase onto the origin so tool results embed real // absolute URLs (e.g. token asset URIs). const clis = new Map>() function resolve(origin: string) { let cli = clis.get(origin) if (!cli) { cli = build(origin) clis.set(origin, cli) // A failed build (spec fetch or docs resolution) retries next request. cli.catch(() => clis.delete(origin)) } return cli } async function build(origin: string) { const response = await fetch(new Request(new URL('/openapi.json', origin))) if (!response.ok) throw new Error(`openapi.json request failed: ${response.status}`) const document = (await response.json()) as Document const cli = incur_Cli.create(name, { description: 'Tempo API', // Generated commands build requests against `http://localhost`; rebase // them onto the caller origin and dispatch in-process. fetch: (request) => { const url = new URL(request.url) return fetch(new Request(new URL(url.pathname + url.search, origin), request)) }, mcp: { instructions, title }, openapi: scope(document), // `compact` + `security: false` keep tool schemas lean: no examples, // oversized regexes, or per-tool credential inputs (auth rides the // forwarded `Authorization` header). openapiConfig: { compact: true, forwardHeaders: ['authorization'], mode: 'namespace', security: false, }, version, }) if (docs !== false) cli.command('docs', { description: 'Search the Tempo documentation.', mcp: docs }) return cli } async function handler(c: Context) { const cli = await resolve(new URL(c.req.url).origin) // incur routes on the first path segment; pin the path to `/mcp` so a // mount prefix never leaks into its router. return cli.fetch(new Request(new URL('/mcp', c.req.url), c.req.raw)) } // The documented POST carries the auth policy (described routes are // default-closed); other methods fall through to the bare route and incur's 405. const app = new Hono() .post( '/mcp', Auth.policy({ apiKey: { scopes: [] }, public: { rateLimit: { limit: 100, period: 'minute' } }, }), OpenApi.describeRoute({ description: "A stateless streamable-HTTP MCP server: send JSON-RPC 2.0 messages as `POST` with `Accept: application/json, text/event-stream`. Tools mirror the API's data domain, plus `docs_*` documentation search. Anonymous requests allow 100 requests per minute; send `Authorization: Bearer ` for protected tools.", operationId: 'mcpRequest', // The JSON-RPC request envelope is protocol-owned (like `/rpc`), so the // body documents an example rather than a schema. requestBody: { content: { 'application/json': { example: { jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} }, }, }, required: true, }, responses: { 200: { content: { 'application/json': { example: { jsonrpc: '2.0', id: 1, result: { tools: [{ name: 'search_tools', description: 'Search available tools' }], }, }, schema: OpenApi.resolver(schema.Response), }, }, description: 'JSON-RPC response returned by the MCP server.', headers: { ...OpenApi.successHeaders }, }, 202: { description: 'The message was a notification or client response; there is nothing to return.', }, 400: { content: { 'application/json': { schema: OpenApi.resolver(schema.Response) } }, description: 'Malformed or invalid MCP message, returned as a JSON-RPC error response rather than the standard error envelope. A malformed API key returns the standard `400` envelope instead.', }, 401: OpenApi.standardError( 401, 'The presented API key is invalid. Anonymous requests are allowed under the public quota.', ['api_key_invalid'], ), 406: { content: { 'application/json': { schema: OpenApi.resolver(schema.Response) } }, description: 'The `Accept` header must include both `application/json` and `text/event-stream`. Returned as a JSON-RPC error response.', }, 429: OpenApi.standardError(429, 'Rate limit exceeded.'), 500: OpenApi.standardError(500, 'Internal server error.'), }, summary: 'Call MCP', tags: ['MCP'], }), handler, ) .all('/mcp', handler) return Metadata.attach(app, openapi) } /** The OpenAPI metadata this group owns: the MCP endpoint's tag, surfaced under the `Data API` group. */ const openapi = { tags: [ { name: 'MCP', description: 'A hosted Model Context Protocol server: the data domain and Tempo docs exposed as tools.', }, ], 'x-tagGroups': [{ name: dataTagGroup, tags: ['MCP'] }], } satisfies App.create.Metadata /** The slice of the OpenAPI document the domain filter reads. */ type Document = Openapi.OpenAPISpec & { paths: Record> 'x-tagGroups'?: readonly { name: string; tags: readonly string[] }[] | undefined } /** HTTP methods that carry operations in an OpenAPI path item. */ const methods: readonly string[] = ['delete', 'get', 'head', 'options', 'patch', 'post', 'put', 'trace'] // prettier-ignore /** * Narrows the document to the data and optional inbound funding domains. * Management, relay, and session operations never surface as tools. */ function scope(document: Document): Openapi.OpenAPISpec { const groups = document['x-tagGroups'] ?? [] const dataGroup = groups.find((entry) => entry.name === dataTagGroup) if (!dataGroup) throw new Error(`openapi.json is missing the "${dataTagGroup}" tag group`) const fundingGroup = groups.find((entry) => entry.name === fundingTagGroup) const tags = new Set( [...dataGroup.tags, ...(fundingGroup?.tags ?? [])].filter((tag) => !excludedTags.includes(tag)), ) const paths: Document['paths'] = {} for (const [path, item] of Object.entries(document.paths)) { // Keep in-domain operations; non-method keys (parameters, summary) ride along. const entries = Object.entries(item).filter(([method, operation]) => { if (!methods.includes(method)) return true const tags_operation = (operation as { tags?: readonly string[] | undefined }).tags return tags_operation?.some((tag) => tags.has(tag)) ?? false }) if (entries.some(([method]) => methods.includes(method))) paths[path] = Object.fromEntries( entries.map(([method, operation]) => methods.includes(method) ? [method, condense(operation)] : [method, operation], ), ) } return { ...document, paths } } /** Terse tool descriptions for parameters repeated across most operations; the public document keeps the full prose. */ const parameterDescriptions: Record = { chainId: 'Chain: `mainnet` (default), `testnet`, or a numeric chain id.', cursor: 'Keyset cursor from a previous response `nextCursor`; omit for the first page.', include: 'Comma-separated related resources to include.', limit: 'Items per page.', order: 'Sort order: `asc` or `desc` (default).', page: '1-indexed page number; mutually exclusive with `cursor`.', } /** Swaps shared query-parameter prose for the terse tool descriptions. */ function condense(operation: unknown) { const { parameters } = operation as { parameters?: readonly { in?: string; name?: string }[] | undefined } if (!parameters) return operation return { ...(operation as object), parameters: parameters.map((parameter) => { const description = parameter.in === 'query' && parameter.name ? parameterDescriptions[parameter.name] : undefined return description ? { ...parameter, description } : parameter }), } } export declare namespace mcp { /** Options for the MCP route group. */ type Options = { /** Remote docs MCP source mounted as the `docs` tool group, or `false` to omit it. @default 'https://mcp.tempo.xyz/mcp' */ docs?: Source | false | undefined /** In-process fetch handler for the composed app (e.g. `(request) => app.fetch(request)`). */ fetch: (request: Request) => Response | Promise /** MCP server name. @default 'tempo' */ name?: string | undefined /** Human-readable MCP server title. @default 'Tempo MCP' */ title?: string | undefined /** MCP server version. @default '0.0.0' */ version?: string | undefined } /** A remote streamable-HTTP MCP server endpoint. */ type Source = | string | URL | { /** Fetch handler used for MCP requests. Defaults to `globalThis.fetch`. */ fetch?: ((request: Request) => Response | Promise) | undefined /** Headers merged into every MCP request. */ headers?: Record | undefined /** Streamable-HTTP MCP endpoint URL. */ url: string | URL } }