Clanki

  • src
import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { CallToolRequestSchema, ListToolsRequestSchema, ListResourcesRequestSchema, ReadResourceRequestSchema, } from "@modelcontextprotocol/sdk/types.js"; import { z } from "zod"; import * as http from "http"; // Constants const ANKI_CONNECT_URL = "http://localhost:8765"; // Type definitions for Anki responses interface AnkiCard { noteId: number; fields: { Front: { value: string }; Back: { value: string }; }; tags: string[]; } interface AnkiResponse<T> { result: T; error: string | null; } // Validation schemas const ListDecksArgumentsSchema = z.object({}); const CreateDeckArgumentsSchema = z.object({ name: z.string().min(1), }); const CreateCardArgumentsSchema = z.object({ deckName: z.string(), front: z.string(), back: z.string(), tags: z.array(z.string()).optional(), }); const CreateClozeCardArgumentsSchema = z.object({ deckName: z.string(), text: z.string(), backExtra: z.string().optional(), tags: z.array(z.string()).optional(), }); const UpdateCardArgumentsSchema = z.object({ noteId: z.number(), front: z.string().optional(), back: z.string().optional(), tags: z.array(z.string()).optional(), }); const UpdateClozeCardArgumentsSchema = z.object({ noteId: z.number(), text: z.string().optional(), backExtra: z.string().optional(), tags: z.array(z.string()).optional(), }); // Helper function for making AnkiConnect requests with retries async function ankiRequest<T>( action: string, params: Record<string, any> = {}, retries = 3, delay = 1000 ): Promise<T> { console.error( `Attempting AnkiConnect request: ${action} with params:`, params ); for (let attempt = 1; attempt <= retries; attempt++) { try { const result = await new Promise<T>((resolve, reject) => { const data = JSON.stringify({ action, version: 6, params, }); console.error("Request payload:", data); const options = { hostname: "127.0.0.1", port: 8765, path: "/", method: "POST", headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(data), }, }; const req = http.request(options, (res) => { let responseData = ""; res.on("data", (chunk: Buffer) => { responseData += chunk.toString(); }); res.on("end", () => { console.error(`AnkiConnect response status: ${res.statusCode}`); console.error(`AnkiConnect response body: ${responseData}`); if (res.statusCode !== 200) { reject( new Error( `AnkiConnect request failed with status ${res.statusCode}: ${responseData}` ) ); return; } try { const parsedData = JSON.parse(responseData) as AnkiResponse<T>; console.error("Parsed response:", parsedData); if (parsedData.error) { reject(new Error(`AnkiConnect error: ${parsedData.error}`)); return; } // Some actions like updateNoteFields return null on success if ( parsedData.result === null || parsedData.result === undefined ) { // For actions that are expected to return null/undefined, return an empty success response if (action === "updateNoteFields" || action === "replaceTags") { resolve({} as T); return; } // For other actions, treat null/undefined as an error reject(new Error("AnkiConnect returned null/undefined result")); return; } resolve(parsedData.result); } catch (parseError) { console.error("Parse error:", parseError); reject( new Error( `Failed to parse AnkiConnect response: ${responseData}` ) ); } }); }); req.on("error", (error: Error) => { console.error( `Error in ankiRequest (attempt ${attempt}/${retries}):`, error ); reject(error); }); // Write data to request body req.write(data); req.end(); }); return result; } catch (error) { if (attempt === retries) { throw error; } console.error( `Attempt ${attempt}/${retries} failed, retrying after ${delay}ms...` ); await new Promise((resolve) => setTimeout(resolve, delay)); // Increase delay for next attempt delay *= 2; } } throw new Error(`Failed after ${retries} attempts`); } async function main() { // Create server instance const server = new Server( { name: "anki-server", version: "1.0.0", }, { capabilities: { tools: {}, resources: {}, }, } ); // List available tools server.setRequestHandler(ListToolsRequestSchema, async () => { return { tools: [ { name: "create-deck", description: "Create a new Anki deck", inputSchema: { type: "object", properties: { name: { type: "string", description: "Name for the new deck", }, }, required: ["name"], }, }, { name: "create-card", description: "Create a new flashcard in a specified deck", inputSchema: { type: "object", properties: { deckName: { type: "string", description: "Name of the deck to add the card to", }, front: { type: "string", description: "Front side content of the card", }, back: { type: "string", description: "Back side content of the card", }, tags: { type: "array", items: { type: "string" }, description: "Optional tags for the card", }, }, required: ["deckName", "front", "back"], }, }, { name: "update-card", description: "Update an existing flashcard", inputSchema: { type: "object", properties: { noteId: { type: "number", description: "ID of the note to update", }, front: { type: "string", description: "New front side content", }, back: { type: "string", description: "New back side content", }, tags: { type: "array", items: { type: "string" }, description: "New tags for the card", }, }, required: ["noteId"], }, }, { name: "create-cloze-card", description: "Create a new cloze deletion card in a specified deck. Use {{c1::text}} syntax for cloze deletions.", inputSchema: { type: "object", properties: { deckName: { type: "string", description: "Name of the deck to add the card to", }, text: { type: "string", description: "Text containing cloze deletions using {{c1::text}} syntax", }, backExtra: { type: "string", description: "Optional extra information to show on the back of the card", }, tags: { type: "array", items: { type: "string" }, description: "Optional tags for the card", }, }, required: ["deckName", "text"], }, }, { name: "update-cloze-card", description: "Update an existing cloze deletion card", inputSchema: { type: "object", properties: { noteId: { type: "number", description: "ID of the note to update", }, text: { type: "string", description: "New text with cloze deletions using {{c1::text}} syntax", }, backExtra: { type: "string", description: "New extra information to show on the back of the card", }, tags: { type: "array", items: { type: "string" }, description: "New tags for the card", }, }, required: ["noteId"], }, }, ], }; }); // Handle tool execution server.setRequestHandler(CallToolRequestSchema, async (request) => { const { name, arguments: args } = request.params; try { if (name === "create-deck") { const { name: deckName } = CreateDeckArgumentsSchema.parse(args); await ankiRequest("createDeck", { deck: deckName, }); return { content: [ { type: "text", text: `Successfully created new deck "${deckName}"`, }, ], }; } if (name === "create-card") { const { deckName, front, back, tags = [], } = CreateCardArgumentsSchema.parse(args); await ankiRequest("addNote", { note: { deckName, modelName: "Basic", // Using the basic note type fields: { Front: front, Back: back, }, tags, }, }); return { content: [ { type: "text", text: `Successfully created new card in deck "${deckName}"`, }, ], }; } if (name === "update-card") { const { noteId, front, back, tags } = UpdateCardArgumentsSchema.parse(args); if (front || back) { const fields: Record<string, string> = {}; if (front) fields.Front = front; if (back) fields.Back = back; await ankiRequest("updateNoteFields", { note: { id: noteId, fields, }, }); } if (tags) { await ankiRequest("replaceTags", { notes: [noteId], tags: tags.join(" "), }); } return { content: [ { type: "text", text: `Successfully updated note ${noteId}`, }, ], }; } if (name === "create-cloze-card") { const { deckName, text, backExtra = "", tags = [], } = CreateClozeCardArgumentsSchema.parse(args); // Validate that the text contains at least one cloze deletion if (!text.includes("{{c") || !text.includes("}}")) { throw new Error( "Text must contain at least one cloze deletion using {{c1::text}} syntax" ); } await ankiRequest("addNote", { note: { deckName, modelName: "Cloze", // Using the cloze note type fields: { Text: text, Back: backExtra, }, tags, }, }); return { content: [ { type: "text", text: `Successfully created new cloze card in deck "${deckName}"`, }, ], }; } if (name === "update-cloze-card") { const { noteId, text, backExtra, tags } = UpdateClozeCardArgumentsSchema.parse(args); // Get the current note info to verify it's a cloze note const noteInfo = await ankiRequest<any[]>("notesInfo", { notes: [noteId], }); if (noteInfo.length === 0) { throw new Error(`No note found with ID ${noteId}`); } if (noteInfo[0].modelName !== "Cloze") { throw new Error("This note is not a cloze deletion note"); } // Update fields if provided if (text || backExtra !== undefined) { const fields: Record<string, string> = {}; if (text) { // Validate that the text contains at least one cloze deletion if (!text.includes("{{c") || !text.includes("}}")) { throw new Error( "Text must contain at least one cloze deletion using {{c1::text}} syntax" ); } fields.Text = text; } if (backExtra !== undefined) { fields.Back = backExtra; } await ankiRequest("updateNoteFields", { note: { id: noteId, fields, }, }); } // Update tags if provided if (tags) { await ankiRequest("replaceTags", { notes: [noteId], tags: tags.join(" "), }); } return { content: [ { type: "text", text: `Successfully updated cloze note ${noteId}`, }, ], }; } throw new Error(`Unknown tool: ${name}`); } catch (error) { if (error instanceof z.ZodError) { throw new Error( `Invalid arguments: ${error.errors .map((e) => `${e.path.join(".")}: ${e.message}`) .join(", ")}` ); } throw error; } }); // Add resource handlers for listing decks server.setRequestHandler(ListResourcesRequestSchema, async () => { try { const decks = await ankiRequest<string[]>("deckNames"); return { resources: decks.map((deck) => ({ uri: `anki://deck/${encodeURIComponent(deck)}`, name: deck, description: `Anki deck: ${deck}`, })), }; } catch (error) { console.error("Error listing resources:", error); throw error; } }); // Add handler for reading deck contents server.setRequestHandler(ReadResourceRequestSchema, async (request) => { try { const uri = request.params.uri; const match = uri.match(/^anki:\/\/deck\/(.+)$/); if (!match) { throw new Error(`Invalid resource URI: ${uri}`); } const deckName = decodeURIComponent(match[1]); console.error(`Attempting to fetch cards for deck: ${deckName}`); // Find all notes in the deck const noteIds = await ankiRequest<number[]>("findNotes", { query: `deck:${deckName}`, }); console.error(`Found ${noteIds.length} notes in deck ${deckName}`); if (noteIds.length === 0) { return { contents: [ { uri, mimeType: "text/plain", text: `Deck: ${deckName}\n\nNo notes found in this deck.`, }, ], }; } // Process notes in chunks of 5 const chunkSize = 5; let allNotes: any[] = []; for (let i = 0; i < noteIds.length; i += chunkSize) { const chunk = noteIds.slice(i, i + chunkSize); console.error( `Processing notes ${i + 1} to ${Math.min( i + chunkSize, noteIds.length )}` ); const chunkNotes = await ankiRequest<any[]>("notesInfo", { notes: chunk, }); allNotes = allNotes.concat(chunkNotes); } console.error(`Retrieved ${allNotes.length} notes total`); // Debug log to see note structure console.error( "First note structure:", JSON.stringify(allNotes[0], null, 2) ); if (allNotes.length > 1) { console.error( "Second note structure:", JSON.stringify(allNotes[1], null, 2) ); } // Map notes to our card format const cardInfo: AnkiCard[] = allNotes.map((note) => { if (note.modelName === "Cloze") { return { noteId: note.noteId, fields: { Front: { value: note.fields.Text.value }, Back: { value: note.fields["Back Extra"].value || "[Cloze deletion]", }, }, tags: note.tags, }; } else if (note.modelName === "Basic") { return { noteId: note.noteId, fields: { Front: { value: note.fields.Front.value }, Back: { value: note.fields.Back.value }, }, tags: note.tags, }; } else { // Default case for unknown note types console.error(`Unknown note type: ${note.modelName}`); return { noteId: note.noteId, fields: { Front: { value: "[Unknown note type]" }, Back: { value: "[Unknown note type]" }, }, tags: note.tags, }; } }); console.error(`Successfully retrieved info for ${cardInfo.length} cards`); const deckContent = cardInfo .map((card) => { return `Note ID: ${card.noteId}\nFront: ${ card.fields.Front.value }\nBack: ${card.fields.Back.value}\nTags: ${card.tags.join( ", " )}\n---`; }) .join("\n"); return { contents: [ { uri, mimeType: "text/plain", text: `Deck: ${deckName}\n\n${deckContent}`, }, ], }; } catch (error) { console.error(`Error reading deck: ${error}`); throw new Error( `Failed to read deck: ${ error instanceof Error ? error.message : "Unknown error" }. Make sure Anki is running and AnkiConnect plugin is installed.` ); } }); // Start the server const transport = new StdioServerTransport(); await server.connect(transport); console.error("Anki MCP Server running on stdio"); } // Run the server main().catch((error) => { console.error("Fatal error in main():", error); process.exit(1); });