import { defineTool } from "@earendil-works/pi-coding-agent"; import { AjvJsonSchemaValidator } from "@modelcontextprotocol/sdk/validation/ajv"; import type { Tool } from "@modelcontextprotocol/sdk/types.js"; import { Type } from "typebox"; import { z } from "zod"; import { MAX_GATEWAY_BYTES, SwadsGateway, failure } from "./swads-gateway.js"; import { reportDataSchema } from "./report-schema.js"; import type { ReportStore } from "./report-store.js"; export const commands = { capabilities: "tools/list", whoami: "swads_whoami", "metrics.catalog": "metrics_catalog", "metrics.query": "metrics_semantic_query", "runtime.account-overview": "runtime_account_overview", "runtime.proposals": "runtime_list_proposals", "runtime.config": "runtime_get_config", "runtime.findings": "runtime_findings", "campaign.list": "campaign_list", } as const; const id = z.string().min(1).max(160); const scope = { external_account_id: id.optional(), platform: z.literal("tiktok").optional(), tenant_id: id.optional() }; const schemas = { capabilities: z.object({}).strict(), whoami: z.object({}).strict(), "metrics.catalog": z.object({ platform: z.literal("tiktok") }).strict(), "metrics.query": z.object({ ...scope, platform: z.literal("tiktok"), query: z.record(z.string(), z.unknown()) }).strict(), "runtime.account-overview": z.object({ external_account_id: id.optional(), platform: z.literal("tiktok") }).strict(), "runtime.proposals": z.object({ ...scope, platform: z.literal("tiktok"), external_account_id: id, status: id.optional() }).strict(), "runtime.config": z.object({ ...scope, platform: z.literal("tiktok"), external_account_id: id }).strict(), "runtime.findings": z.object({ ...scope, platform: z.literal("tiktok"), external_account_id: id, metric: id.optional(), window: id.optional(), min_confidence: z.number().min(0).max(1).optional(), include_entities: z.boolean().optional() }).strict(), "campaign.list": z.object({ ...scope, platform: z.literal("tiktok"), page: z.number().int().min(1).max(1000).optional(), page_size: z.number().int().min(1).max(100).optional(), cursor: z.string().max(1000).optional(), status: id.optional() }).strict(), }; export const swadsCliSchema = z.object({ command: z.enum(Object.keys(commands) as [keyof typeof commands, ...Array]), arguments: z.record(z.string(), z.unknown()) }).strict(); export function validateCli(input: unknown) { assertBounds(input); const parsed = swadsCliSchema.safeParse(input); if (!parsed.success) throw failure("SWADS_INVALID_ARGUMENTS"); const args = schemas[parsed.data.command].safeParse(parsed.data.arguments); if (!args.success) throw failure("SWADS_INVALID_ARGUMENTS"); if (parsed.data.command === "metrics.query") { const query = parsed.data.arguments.query as Record; if (query.platform !== undefined && query.platform !== "tiktok") throw failure("SWADS_INVALID_ARGUMENTS"); } return { command: parsed.data.command, arguments: args.data }; } function assertBounds(input: unknown): void { let nodes = 0; const visit = (value: unknown, depth: number) => { if (++nodes > 4000 || depth > 10) throw failure("SWADS_INVALID_ARGUMENTS"); if (value && typeof value === "object") for (const [key, child] of Object.entries(value)) { if (/^(?:url|headers?|token|authorization|tool_?name|__proto__|constructor|prototype)$/i.test(key)) throw failure("SWADS_INVALID_ARGUMENTS"); visit(child, depth + 1); } }; visit(input, 0); if (Buffer.byteLength(JSON.stringify(input) ?? "") > 64 * 1024) throw failure("SWADS_INVALID_ARGUMENTS"); } export function isReadOnly(tool: Tool): boolean { return tool.annotations?.readOnlyHint === true && tool.annotations.destructiveHint !== true; } const required = ["swads_whoami", "metrics_catalog", "metrics_semantic_query"]; function assertCapabilities(tools: Tool[]): void { for (const name of required) { const tool = tools.find((item) => item.name === name); if (!tool) throw failure("SWADS_TOOL_MISSING"); if (!isReadOnly(tool)) throw failure("SWADS_NOT_READ_ONLY"); } } function unwrap(result: unknown): unknown { if (Buffer.byteLength(JSON.stringify(result)) > MAX_GATEWAY_BYTES) throw failure("SWADS_RESPONSE_TOO_LARGE"); const envelope = result as { isError?: boolean; structuredContent?: unknown; content?: Array<{ type: string; text?: string }> }; let data = envelope.structuredContent; if (data === undefined) { const content = envelope.content?.filter((part) => part.type === "text"); if (content?.length !== 1 || !content[0]?.text) throw failure("SWADS_INVALID_RESPONSE"); try { data = JSON.parse(content[0].text); } catch { throw failure(envelope.isError ? "SWADS_UPSTREAM_ERROR" : "SWADS_INVALID_RESPONSE"); } } const error = data as { error?: unknown; error_code?: string; upstream_status?: number; status?: number } | null; if (envelope.isError || error?.error || error?.error_code) { const status = error?.upstream_status ?? error?.status; throw failure(status === 401 ? "SWADS_AUTH_FAILED" : status === 403 ? "SWADS_FORBIDDEN" : error?.error_code === "platform_not_supported" ? "SWADS_CAPABILITY_MISSING" : "SWADS_UPSTREAM_ERROR"); } return data; } // Per-session, run-scoped evidence prevents rendering after failed/missing preflight. export class SwadsReportTools { private evidence: { runId: string; whoami: boolean; catalog: boolean; query: boolean } | undefined; constructor(private readonly gateway: SwadsGateway, private readonly reports: ReportStore, private readonly getRun: () => { runId: string; signal: AbortSignal } | undefined) {} async cli(input: unknown, signal: AbortSignal): Promise { let params: ReturnType; try { params = validateCli(input); } catch (error) { this.evidence = undefined; throw error; } const run = this.getRun(); if (!run) throw failure("SWADS_NO_ACTIVE_RUN"); if (this.evidence?.runId !== run.runId) this.evidence = { runId: run.runId, whoami: false, catalog: false, query: false }; try { return await this.gateway.execute(async (connection, requestSignal) => { const tools = await this.gateway.list(connection, requestSignal, params.command === "capabilities"); assertCapabilities(tools); if (params.command === "capabilities") return { server_time: new Date().toISOString(), tools: tools.filter((tool) => Object.values(commands).includes(tool.name as typeof commands[keyof typeof commands])) }; if (params.command === "metrics.query" && (!this.evidence?.whoami || !this.evidence.catalog)) throw failure("SWADS_PREFLIGHT_REQUIRED"); const tool = tools.find((item) => item.name === commands[params.command]); if (!tool) throw failure("SWADS_TOOL_MISSING"); if (!isReadOnly(tool)) throw failure("SWADS_NOT_READ_ONLY"); try { if (!new AjvJsonSchemaValidator().getValidator(tool.inputSchema as Parameters[0])(params.arguments).valid) throw failure("SWADS_TOOL_DRIFT"); } catch { throw failure("SWADS_TOOL_DRIFT"); } const data = unwrap(await connection.call(tool.name, params.arguments, requestSignal)); if (data === null || typeof data !== "object") throw failure("SWADS_INVALID_RESPONSE"); if (params.command === "metrics.query") { const rows = data as { columns?: Array<{ name?: unknown }>; rows?: unknown[] }; if (!Array.isArray(rows.columns) || !rows.columns.length || !rows.columns.every((column) => typeof column.name === "string") || !Array.isArray(rows.rows)) throw failure("SWADS_INVALID_RESPONSE"); if (!rows.rows.length) throw failure("SWADS_NO_DATA"); } if (params.command === "metrics.catalog" && Object.keys(data).length === 0) throw failure("SWADS_CAPABILITY_MISSING"); if (params.command === "whoami" && Object.keys(data).length === 0) throw failure("SWADS_INVALID_RESPONSE"); if (params.command === "whoami") this.evidence!.whoami = true; if (params.command === "metrics.catalog") this.evidence!.catalog = true; if (params.command === "metrics.query") this.evidence!.query = true; return data; }, AbortSignal.any([signal, run.signal])); } catch (error) { if (["capabilities", "whoami", "metrics.catalog", "metrics.query"].includes(params.command)) this.evidence = undefined; throw error; } } async render(input: unknown, signal: AbortSignal) { const run = this.getRun(); if (!run || this.evidence?.runId !== run.runId || !this.evidence.whoami || !this.evidence.catalog || !this.evidence.query) throw failure("SWADS_PREFLIGHT_REQUIRED"); const parsed = reportDataSchema.safeParse(this.gateway.sanitize(input)); if (!parsed.success) throw failure("SWADS_INVALID_REPORT"); try { return await this.reports.create(parsed.data, AbortSignal.any([signal, run.signal])); } catch { throw failure(signal.aborted || run.signal.aborted ? "SWADS_ABORTED" : "SWADS_REPORT_FAILED"); } } definitions() { return [defineTool({ name: "swads_cli", label: "SW Ads read-only CLI", description: "Read SW Ads via fixed commands. First capabilities then whoami and metrics.catalog. Arguments must follow the remote input schema and the local command schema; only platform tiktok is supported.", parameters: Type.Unsafe>({ type: "object", properties: { command: { type: "string", enum: Object.keys(commands) }, arguments: { type: "object" } }, required: ["command", "arguments"], additionalProperties: false, oneOf: Object.entries(schemas).map(([command, schema]) => ({ type: "object", properties: { command: { const: command }, arguments: z.toJSONSchema(schema) }, required: ["command", "arguments"], additionalProperties: false })) }), executionMode: "sequential", execute: async (_id, params, signal) => { const data = await this.cli(params, signal ?? new AbortController().signal); return { content: [{ type: "text" as const, text: JSON.stringify(data) }], details: { status: "success", command: params.command } }; }, }), defineTool({ name: "render_swads_daily_report", label: "SW Ads daily report", description: "After real SW Ads preflight and queries, render normalized data to persistent PNG/HTML/JSON. Output Markdown analysis first, then call this as the final action. No model-selected paths or executable arguments.", parameters: Type.Object({ data: Type.Unsafe>(z.toJSONSchema(reportDataSchema)) }, { additionalProperties: false }), executionMode: "sequential", execute: async (_id, params, signal) => { const artifact = await this.render(params.data, signal ?? new AbortController().signal); return { content: [{ type: "text" as const, text: JSON.stringify(artifact) }], details: artifact }; }, })]; } }