import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, truncateHead, } from "@earendil-works/pi-coding-agent"; import type { Tool } from "@modelcontextprotocol/sdk/types.js"; import { allocatePiToolName, extractTextContent, type TickTickMcpClient } from "./client.js"; import { coerceMcpArguments, mcpSchemaToTypeBox } from "./schema.js"; const PROMPT_GUIDELINE = "Use ticktick__ tools when the user asks about TickTick tasks, lists, habits, or focus."; export function registerMcpTools( pi: ExtensionAPI, mcpClient: TickTickMcpClient, ensureConnected: () => Promise, ): number { const usedNames = new Set(); const tools = mcpClient.getTools(); for (const tool of tools) { registerOneTool(pi, ensureConnected, tool, usedNames); } return tools.length; } function registerOneTool( pi: ExtensionAPI, ensureConnected: () => Promise, tool: Tool, usedNames: Set, ): void { const piName = allocatePiToolName(tool.name, usedNames); const label = humanizeToolName(tool.name); pi.registerTool({ name: piName, label, description: tool.description ?? `TickTick MCP tool: ${tool.name}`, promptGuidelines: [PROMPT_GUIDELINE], parameters: mcpSchemaToTypeBox(tool.inputSchema), prepareArguments(args) { return coerceMcpArguments(args, tool.inputSchema); }, async execute(_toolCallId, params, signal, onUpdate) { onUpdate?.({ content: [{ type: "text", text: `Calling TickTick (${tool.name})…` }], details: {}, }); const client = await ensureConnected(); if (!client) { throw new Error( "TickTick MCP is not connected. Run /ticktick-status to connect, or set TICKTICK_ACCESS_TOKEN.", ); } const args = params && typeof params === "object" && !Array.isArray(params) ? (params as Record) : {}; const result = await client.callTool(tool.name, args, signal); const resultRecord = result as { isError?: boolean }; if (resultRecord.isError) { throw new Error(extractTextContent(result) || `TickTick tool ${tool.name} failed`); } const rawText = extractTextContent(result) || JSON.stringify(result, null, 2); const truncated = truncateHead(rawText, { maxBytes: DEFAULT_MAX_BYTES, maxLines: DEFAULT_MAX_LINES, }); let text = truncated.content; if (truncated.truncated) { text += `\n\n[Output truncated: ${truncated.outputLines} of ${truncated.totalLines} lines]`; } return { content: [{ type: "text", text }], details: { mcpTool: tool.name, piTool: piName, truncated: truncated.truncated, }, }; }, }); } function humanizeToolName(name: string): string { return name .split(/[_-]+/) .filter(Boolean) .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) .join(" "); }