Anki MCP Server

/** * MCP Tool handlers for Anki */ import { ErrorCode, McpError } from "@modelcontextprotocol/sdk/types.js"; import { AnkiClient } from "./utils.js"; /** * Handles all MCP tool operations for Anki */ export class McpToolHandler { private ankiClient: AnkiClient; constructor() { this.ankiClient = new AnkiClient(); } /** * Get tool schema for all available tools */ async getToolSchema(): Promise<{ tools: { name: string; description: string; inputSchema: Record<string, any>; }[]; }> { return { tools: [ { name: "list_decks", description: "List all available Anki decks", inputSchema: { type: "object", properties: {}, required: [], }, }, { name: "create_deck", description: "Create a new Anki deck", inputSchema: { type: "object", properties: { name: { type: "string", description: "Name of the deck to create", }, }, required: ["name"], }, }, { name: "get_note_type_info", description: "Get detailed structure of a note type", inputSchema: { type: "object", properties: { modelName: { type: "string", description: "Name of the note type/model", }, }, required: ["modelName"], }, }, { name: "create_note", description: "Create a new note (LLM Should get note type info first)", inputSchema: { type: "object", properties: { type: { type: "string", description: "Note type", }, deck: { type: "string", description: "Deck name", }, fields: { type: "object", description: "Custom fields for the note(get note type info first)", additionalProperties: true, }, tags: { type: "array", items: { type: "string", }, description: "Tags for the note", }, }, required: ["type", "deck", "fields"], }, }, { name: "batch_create_notes", description: "Create multiple notes at once", inputSchema: { type: "object", properties: { notes: { type: "array", items: { type: "object", properties: { type: { type: "string", enum: ["Basic", "Cloze"], }, deck: { type: "string", }, fields: { type: "object", additionalProperties: true, }, tags: { type: "array", items: { type: "string", }, }, }, required: ["type", "deck", "fields"], }, }, stopOnError: { type: "boolean", description: "Whether to stop on first error", }, }, required: ["notes"], }, }, { name: "search_notes", description: "Search for notes using Anki query syntax", inputSchema: { type: "object", properties: { query: { type: "string", description: "Anki search query", }, }, required: ["query"], }, }, { name: "get_note_info", description: "Get detailed information about a note", inputSchema: { type: "object", properties: { noteId: { type: "number", description: "Note ID", }, }, required: ["noteId"], }, }, { name: "update_note", description: "Update an existing note", inputSchema: { type: "object", properties: { id: { type: "number", description: "Note ID", }, fields: { type: "object", description: "Fields to update", }, tags: { type: "array", items: { type: "string", }, description: "New tags for the note", }, }, required: ["id", "fields"], }, }, { name: "delete_note", description: "Delete a note", inputSchema: { type: "object", properties: { noteId: { type: "number", description: "Note ID to delete", }, }, required: ["noteId"], }, }, { name: "list_note_types", description: "List all available note types", inputSchema: { type: "object", properties: {}, required: [], }, }, { name: "create_note_type", description: "Create a new note type", inputSchema: { type: "object", properties: { name: { type: "string", description: "Name of the new note type", }, fields: { type: "array", items: { type: "string", }, description: "Field names for the note type", }, css: { type: "string", description: "CSS styling for the note type", }, templates: { type: "array", items: { type: "object", properties: { name: { type: "string", }, front: { type: "string", }, back: { type: "string", }, }, required: ["name", "front", "back"], }, description: "Card templates", }, }, required: ["name", "fields", "templates"], }, }, ], }; } /** * Handle tool execution */ async executeTool( name: string, args: any, ): Promise<{ content: { type: string; text: string; }[]; isError?: boolean; }> { await this.ankiClient.checkConnection(); try { switch (name) { // Deck tools case "list_decks": return this.listDecks(); case "create_deck": return this.createDeck(args); // Note type tools case "list_note_types": return this.listNoteTypes(); case "create_note_type": return this.createNoteType(args); case "get_note_type_info": return this.getNoteTypeInfo(args); // Note tools case "create_note": return this.createNote(args); case "batch_create_notes": return this.batchCreateNotes(args); case "search_notes": return this.searchNotes(args); case "get_note_info": return this.getNoteInfo(args); case "update_note": return this.updateNote(args); case "delete_note": return this.deleteNote(args); // Dynamic model-specific note creation default: const typeToolMatch = name.match(/^create_(.+)_note$/); if (typeToolMatch) { const modelName = typeToolMatch[1].replace(/_/g, " "); return this.createModelSpecificNote(modelName, args); } throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${name}`); } } catch (error) { if (error instanceof McpError) { throw error; } return { content: [ { type: "text", text: `Error: ${ error instanceof Error ? error.message : String(error) }`, }, ], isError: true, }; } } /** * List all decks */ private async listDecks(): Promise<{ content: { type: string; text: string; }[]; }> { const decks = await this.ankiClient.getDeckNames(); return { content: [ { type: "text", text: JSON.stringify({ decks, count: decks.length }, null, 2), }, ], }; } /** * Create a new deck */ private async createDeck(args: { name: string }): Promise<{ content: { type: string; text: string; }[]; }> { if (!args.name) { throw new McpError(ErrorCode.InvalidParams, "Deck name is required"); } const deckId = await this.ankiClient.createDeck(args.name); return { content: [ { type: "text", text: JSON.stringify({ deckId, name: args.name }, null, 2), }, ], }; } /** * List all note types */ private async listNoteTypes(): Promise<{ content: { type: string; text: string; }[]; }> { const noteTypes = await this.ankiClient.getModelNames(); return { content: [ { type: "text", text: JSON.stringify({ noteTypes, count: noteTypes.length }, null, 2), }, ], }; } /** * Create a new note type */ private async createNoteType(args: { name: string; fields: string[]; css?: string; templates: { name: string; front: string; back: string; }[]; }): Promise<{ content: { type: string; text: string; }[]; }> { if (!args.name) { throw new McpError(ErrorCode.InvalidParams, "Note type name is required"); } if (!args.fields || args.fields.length === 0) { throw new McpError(ErrorCode.InvalidParams, "Fields are required"); } if (!args.templates || args.templates.length === 0) { throw new McpError(ErrorCode.InvalidParams, "Templates are required"); } // Check if model already exists const existingModels = await this.ankiClient.getModelNames(); if (existingModels.includes(args.name)) { throw new McpError( ErrorCode.InvalidParams, `Note type already exists: ${args.name}`, ); } await this.ankiClient.createModel({ modelName: args.name, inOrderFields: args.fields, css: args.css || "", cardTemplates: args.templates, }); return { content: [ { type: "text", text: JSON.stringify( { success: true, modelName: args.name, fields: args.fields, templates: args.templates.length, }, null, 2, ), }, ], }; } /** * Get note type info */ private async getNoteTypeInfo(args: { modelName: string }): Promise<{ content: { type: string; text: string; }[]; }> { if (!args.modelName) { throw new McpError(ErrorCode.InvalidParams, "Model name is required"); } // Check if model exists const existingModels = await this.ankiClient.getModelNames(); if (!existingModels.includes(args.modelName)) { throw new McpError( ErrorCode.InvalidParams, `Note type not found: ${args.modelName}`, ); } // Get model information in parallel const [fields, templates, styling] = await Promise.all([ this.ankiClient.getModelFieldNames(args.modelName), this.ankiClient.getModelTemplates(args.modelName), this.ankiClient.getModelStyling(args.modelName), ]); return { content: [ { type: "text", text: JSON.stringify( { modelName: args.modelName, fields, templates, css: styling.css, }, null, 2, ), }, ], }; } /** * Create a new note */ private async createNote(args: { type: string; deck: string; fields: Record<string, string>; tags?: string[]; }): Promise<{ content: { type: string; text: string; }[]; }> { if (!args.type) { throw new McpError(ErrorCode.InvalidParams, "Note type is required"); } if (!args.deck) { throw new McpError(ErrorCode.InvalidParams, "Deck name is required"); } if (!args.fields || Object.keys(args.fields).length === 0) { throw new McpError(ErrorCode.InvalidParams, "Fields are required"); } // Check if deck exists, create if not const decks = await this.ankiClient.getDeckNames(); if (!decks.includes(args.deck)) { await this.ankiClient.createDeck(args.deck); } // Check if model exists const models = await this.ankiClient.getModelNames(); if (!models.includes(args.type)) { throw new McpError( ErrorCode.InvalidParams, `Note type not found: ${args.type}`, ); } // Validate fields const modelFields = await this.ankiClient.getModelFieldNames(args.type); for (const field of modelFields) { if (!args.fields[field] && !args.fields[field.toLowerCase()]) { throw new McpError( ErrorCode.InvalidParams, `Missing required field: ${field}`, ); } } // Normalize field names to match the model const normalizedFields: Record<string, string> = {}; for (const field of modelFields) { normalizedFields[field] = args.fields[field] || args.fields[field.toLowerCase()] || ""; } const noteId = await this.ankiClient.addNote({ deckName: args.deck, modelName: args.type, fields: normalizedFields, tags: args.tags || [], }); return { content: [ { type: "text", text: JSON.stringify( { noteId, deck: args.deck, modelName: args.type, }, null, 2, ), }, ], }; } /** * Create a model-specific note */ private async createModelSpecificNote( modelName: string, args: Record<string, any>, ): Promise<{ content: { type: string; text: string; }[]; }> { if (!args.deck) { throw new McpError(ErrorCode.InvalidParams, "Deck name is required"); } // Check if model exists const models = await this.ankiClient.getModelNames(); if (!models.includes(modelName)) { throw new McpError( ErrorCode.InvalidParams, `Note type not found: ${modelName}`, ); } // Check if deck exists, create if not const decks = await this.ankiClient.getDeckNames(); if (!decks.includes(args.deck)) { await this.ankiClient.createDeck(args.deck); } // Get model fields const modelFields = await this.ankiClient.getModelFieldNames(modelName); // Validate fields const fields: Record<string, string> = {}; for (const field of modelFields) { const fieldValue = args[field.toLowerCase()] || args[field] || ""; fields[field] = fieldValue; } // Extract tags if provided const tags = Array.isArray(args.tags) ? args.tags : []; const noteId = await this.ankiClient.addNote({ deckName: args.deck, modelName: modelName, fields, tags, }); return { content: [ { type: "text", text: JSON.stringify( { noteId, deck: args.deck, modelName, }, null, 2, ), }, ], }; } /** * Create multiple notes at once */ private async batchCreateNotes(args: { notes: { type: string; deck: string; fields: Record<string, string>; tags?: string[]; }[]; stopOnError?: boolean; }): Promise<{ content: { type: string; text: string; }[]; }> { if (!args.notes || !Array.isArray(args.notes) || args.notes.length === 0) { throw new McpError(ErrorCode.InvalidParams, "Notes array is required"); } const results: { success: boolean; noteId?: number | null; error?: string; index: number; }[] = []; const stopOnError = args.stopOnError !== false; // Process each note for (let i = 0; i < args.notes.length; i++) { const note = args.notes[i]; try { // Check if deck exists, create if not const decks = await this.ankiClient.getDeckNames(); if (!decks.includes(note.deck)) { await this.ankiClient.createDeck(note.deck); } // Check if model exists const models = await this.ankiClient.getModelNames(); if (!models.includes(note.type)) { throw new Error(`Note type not found: ${note.type}`); } // Get model fields const modelFields = await this.ankiClient.getModelFieldNames(note.type); // Normalize field names to match the model const normalizedFields: Record<string, string> = {}; for (const field of modelFields) { normalizedFields[field] = note.fields[field] || note.fields[field.toLowerCase()] || ""; } const noteId = await this.ankiClient.addNote({ deckName: note.deck, modelName: note.type, fields: normalizedFields, tags: note.tags || [], }); results.push({ success: true, noteId, index: i, }); } catch (error) { results.push({ success: false, error: error instanceof Error ? error.message : String(error), index: i, }); if (stopOnError) { break; } } } return { content: [ { type: "text", text: JSON.stringify( { results, total: args.notes.length, successful: results.filter((r) => r.success).length, failed: results.filter((r) => !r.success).length, }, null, 2, ), }, ], }; } /** * Search for notes */ private async searchNotes(args: { query: string }): Promise<{ content: { type: string; text: string; }[]; }> { if (!args.query) { throw new McpError(ErrorCode.InvalidParams, "Search query is required"); } const noteIds = await this.ankiClient.findNotes(args.query); let notes: any[] = []; if (noteIds.length > 0) { // Get detailed info for the first 50 notes const limit = Math.min(noteIds.length, 50); const notesInfo = await this.ankiClient.notesInfo( noteIds.slice(0, limit), ); notes = notesInfo; } return { content: [ { type: "text", text: JSON.stringify( { query: args.query, total: noteIds.length, notes, limitApplied: noteIds.length > 50, }, null, 2, ), }, ], }; } /** * Get note info */ private async getNoteInfo(args: { noteId: number }): Promise<{ content: { type: string; text: string; }[]; }> { if (!args.noteId) { throw new McpError(ErrorCode.InvalidParams, "Note ID is required"); } const notesInfo = await this.ankiClient.notesInfo([args.noteId]); if (!notesInfo || notesInfo.length === 0) { throw new McpError( ErrorCode.InvalidParams, `Note not found: ${args.noteId}`, ); } return { content: [ { type: "text", text: JSON.stringify(notesInfo[0], null, 2), }, ], }; } /** * Update a note */ private async updateNote(args: { id: number; fields: Record<string, string>; tags?: string[]; }): Promise<{ content: { type: string; text: string; }[]; }> { if (!args.id) { throw new McpError(ErrorCode.InvalidParams, "Note ID is required"); } if (!args.fields || Object.keys(args.fields).length === 0) { throw new McpError(ErrorCode.InvalidParams, "Fields are required"); } // Check if note exists const notesInfo = await this.ankiClient.notesInfo([args.id]); if (!notesInfo || notesInfo.length === 0) { throw new McpError(ErrorCode.InvalidParams, `Note not found: ${args.id}`); } // Update fields await this.ankiClient.updateNoteFields({ id: args.id, fields: args.fields, }); return { content: [ { type: "text", text: JSON.stringify( { success: true, noteId: args.id, }, null, 2, ), }, ], }; } /** * Delete a note */ private async deleteNote(args: { noteId: number }): Promise<{ content: { type: string; text: string; }[]; }> { if (!args.noteId) { throw new McpError(ErrorCode.InvalidParams, "Note ID is required"); } await this.ankiClient.deleteNotes([args.noteId]); return { content: [ { type: "text", text: JSON.stringify( { success: true, noteId: args.noteId, }, null, 2, ), }, ], }; } }