import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; import type { Tool } from "@modelcontextprotocol/sdk/types.js"; import { getMcpUrl } from "../auth/token.js"; import { getPackageVersion } from "../version.js"; import { isTickTickAuthError, toTickTickAuthError } from "./errors.js"; export const TOOL_PREFIX = "ticktick__"; export class TickTickMcpClient { private client: Client | null = null; private transport: StreamableHTTPClientTransport | null = null; private tools: Tool[] = []; constructor(private readonly token: string) {} getTools(): Tool[] { return this.tools; } isConnected(): boolean { return this.client !== null; } async connect(): Promise { await this.close(); const transport = new StreamableHTTPClientTransport(new URL(getMcpUrl()), { requestInit: { headers: { Authorization: `Bearer ${this.token}`, }, }, }); const client = new Client({ name: "pi-ticktick", version: getPackageVersion() }); await client.connect(transport); const result = await client.listTools(); this.tools = result.tools ?? []; this.client = client; this.transport = transport; } async callTool(name: string, args: Record, signal?: AbortSignal) { if (!this.client) { throw new Error("TickTick MCP not connected"); } if (signal?.aborted) { throw new Error("Cancelled"); } try { return await this.client.callTool({ name, arguments: args }, undefined, { signal }); } catch (error) { if (isTickTickAuthError(error)) { throw toTickTickAuthError(error); } throw error; } } async close(): Promise { if (this.client) { try { await this.client.close(); } catch { // ignore close errors } } else if (this.transport) { try { await this.transport.close(); } catch { // ignore } } this.client = null; this.transport = null; this.tools = []; } } export function piToolName(mcpName: string): string { const safe = mcpName.replace(/[^a-zA-Z0-9_]/g, "_"); return `${TOOL_PREFIX}${safe}`; } export function allocatePiToolName(mcpName: string, usedNames: Set): string { const base = piToolName(mcpName); if (!usedNames.has(base)) { usedNames.add(base); return base; } let suffix = 2; while (usedNames.has(`${base}_${suffix}`)) { suffix += 1; } const unique = `${base}_${suffix}`; usedNames.add(unique); return unique; } export function extractTextContent(result: unknown): string { if (!result || typeof result !== "object") return ""; const record = result as { content?: unknown; isError?: boolean }; if (!Array.isArray(record.content)) { return record.isError ? "TickTick MCP returned an error with no content." : ""; } return record.content .filter( (part): part is { type: "text"; text: string } => typeof part === "object" && part !== null && (part as { type?: string }).type === "text" && typeof (part as { text?: unknown }).text === "string", ) .map((part) => part.text) .join("\n"); }