import type { CallToolResult } from "@modelcontextprotocol/server"; import * as z from "zod/v4"; import type { ApiClient } from "../api-client.js"; import { ApiError, describeApiError, type DescribeOptions, } from "../api-error.js"; import type { Config } from "../config.js"; import { type Logger, redact } from "../log.js"; import type { Clock } from "../pacer.js"; /** What every tool needs: the shared API client (one pacer for the process), config, log and clock. */ export interface ToolContext { api: ApiClient; config: Config; log: Logger; clock: Clock; } /** Every v0 tool only reads public market data through the user's own key. */ export const READ_ONLY_ANNOTATIONS = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true, } as const; export const MarketIdSchema = z .string() .trim() .min(1) .max(200) .regex( /^[^\s/?#]+$/, "must be one market id with no spaces, e.g. polymarket:0x… or a Kalshi ticker", ) .refine((v) => !/^\.+$/.test(v), "must be a market id") .describe( 'Market id from search_markets (e.g. "polymarket:0x…"), or a Polymarket condition id, a Kalshi ticker or a market slug', ); /** The spec asks for the same JSON as text next to structuredContent. */ export function ok>( output: T, ): CallToolResult { return { content: [{ type: "text", text: JSON.stringify(output) }], structuredContent: output, }; } export function fail(text: string): CallToolResult { return { content: [{ type: "text", text }], isError: true }; } /** Runs a tool body; API failures and surprises become isError results the model can act on. */ export async function guarded( ctx: ToolContext, tool: string, body: () => Promise, describeOptions: (err: ApiError) => DescribeOptions = () => ({}), ): Promise { const secrets = ctx.config.apiKey ? [ctx.config.apiKey] : []; try { return await body(); } catch (err) { if (err instanceof ApiError) { // The API's own message can be quoted, so redact in case it echoes the key. return fail( redact( describeApiError(err, { timeoutMs: ctx.config.timeoutMs, ...describeOptions(err), }), secrets, ), ); } const message = err instanceof Error ? err.message : String(err); ctx.log.error(`${tool} failed: ${redact(message, secrets)}`); return fail( "The Assymetrix MCP server hit an unexpected error. Try again; if it keeps happening, report it to Assymetrix support.", ); } } export function badResponse(requestId: string): ApiError { return new ApiError({ kind: "bad_response", message: "unexpected reply shape", requestId, }); } export function record(value: unknown): Record { return value && typeof value === "object" && !Array.isArray(value) ? (value as Record) : {}; } export function numOrNull(value: unknown): number | null { if (typeof value === "number") return Number.isFinite(value) ? value : null; if (typeof value === "string" && value.trim() !== "") { const n = Number(value); return Number.isFinite(n) ? n : null; } return null; } /** USD amounts arrive as decimal strings with 6 places; 2 are enough for the model. */ export function usdOrNull(value: unknown): number | null { const n = numOrNull(value); return n === null ? null : Math.round(n * 100) / 100; } export function strOrNull(value: unknown): string | null { return typeof value === "string" ? value : null; } export function boolOrNull(value: unknown): boolean | null { return typeof value === "boolean" ? value : null; } export function truncate(text: string | null, max: number): string | null { if (text === null || text.length <= max) return text; return `${text.slice(0, max - 1)}…`; }