Skip to main content
Glama

Anki MCP Server

#!/usr/bin/env node import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { CallToolRequestSchema, ErrorCode, ListToolsRequestSchema, McpError, } from "@modelcontextprotocol/sdk/types.js"; import axios, { AxiosError } from "axios"; import * as dotenv from "dotenv"; import * as path from "path"; import * as fs from "fs"; // Get the root directory of the project const rootDir = path.resolve(path.dirname(process.argv[1]), ".."); console.error(`Project root directory: ${rootDir}`); // Load environment variables based on NODE_ENV if (process.env.NODE_ENV === 'test') { const testEnvPath = path.join(rootDir, '.env.test'); console.error(`Loading test environment from ${testEnvPath}`); if (fs.existsSync(testEnvPath)) { dotenv.config({ path: testEnvPath }); } else { console.error(`Warning: Test environment file not found at ${testEnvPath}`); dotenv.config({ path: path.join(rootDir, '.env') }); } } else { const envPath = path.join(rootDir, '.env'); console.error(`Loading standard environment from ${envPath}`); if (fs.existsSync(envPath)) { dotenv.config({ path: envPath }); } else { console.error(`Warning: Environment file not found at ${envPath}, using defaults`); } } // AnkiConnect configuration with fallback values const ANKI_CONNECT_URL = process.env.ANKI_CONNECT_URL || "http://localhost:8765"; const ANKI_CONNECT_VERSION = process.env.ANKI_CONNECT_VERSION ? parseInt(process.env.ANKI_CONNECT_VERSION) : 6; const ANKI_MOCK_MODE = process.env.ANKI_MOCK_MODE === 'true'; // Log the current configuration console.error(`Starting anki-mcp-server with configuration:`); console.error(` ANKI_CONNECT_URL: ${ANKI_CONNECT_URL}`); console.error(` ANKI_CONNECT_VERSION: ${ANKI_CONNECT_VERSION}`); console.error(` ANKI_MOCK_MODE: ${ANKI_MOCK_MODE}`); // Types for AnkiConnect responses and card data interface AnkiConnectResponse<T> { result: T; error: string | null; } interface CardInfo { id: number; noteId: number; deck: string; modelName: string; fields: Record<string, { value: string; order: number }>; tags: string[]; front: string; back: string; statistics: { ease: number; interval: number; reviews: number; lapses: number; }; } /** * Client for interacting with AnkiConnect API */ class AnkiConnectClient { private readonly url: string; private readonly version: number; private readonly axiosInstance; private readonly mockMode: boolean; constructor( url: string = ANKI_CONNECT_URL, version: number = ANKI_CONNECT_VERSION, mockMode: boolean = ANKI_MOCK_MODE ) { this.url = url; this.version = version; this.mockMode = mockMode; // Configure axios with timeouts and retry settings this.axiosInstance = axios.create({ timeout: 30000, // 30 second timeout maxContentLength: 50 * 1024 * 1024, // 50MB max response size }); if (this.mockMode) { console.error("AnkiConnectClient initialized in MOCK MODE - no actual Anki operations will be performed"); } } /** * Delay execution for specified milliseconds */ private async delay(ms: number): Promise<void> { return new Promise((resolve) => setTimeout(resolve, ms)); } /** * Send a request to AnkiConnect API or return mock response if in mock mode */ async request<T>( action: string, params: Record<string, any> = {}, retries = 3 // Allow for retries on network errors ): Promise<T> { // If in mock mode, return mock responses if (this.mockMode) { return this.getMockResponse<T>(action, params); } try { const response = await this.axiosInstance.post<AnkiConnectResponse<T>>( this.url, { action, version: this.version, params, } ); if (response.data.error) { throw new Error(`AnkiConnect error: ${response.data.error}`); } // Success! Add a small delay to prevent overwhelming AnkiConnect await this.delay(50); return response.data.result; } catch (error) { if (axios.isAxiosError(error)) { const axiosError = error as AxiosError; // Handle connection refused (Anki not running) if (axiosError.code === "ECONNREFUSED") { throw new Error( "Could not connect to Anki. Is Anki running with AnkiConnect installed?" ); } // Handle connection reset errors with retry logic if (axiosError.code === "ECONNRESET" && retries > 0) { console.error( `Connection reset error, retrying (${retries} attempts left)...` ); // Exponential backoff before retry (500ms, 1s, 2s) await this.delay(500 * Math.pow(2, 3 - retries)); return this.request<T>(action, params, retries - 1); } throw new Error( `Network error: ${axiosError.code || axiosError.message}` ); } throw error; } } /** * Generate mock responses for testing */ private getMockResponse<T>(action: string, params: Record<string, any> = {}): T { console.error(`[MOCK] AnkiConnect action: ${action}`); console.error(`[MOCK] Params: ${JSON.stringify(params, null, 2)}`); switch (action) { case "version": return "6" as unknown as T; case "findCards": // Mock finding leech cards - return 3 fake card IDs return [1234567890, 1234567891, 1234567892] as unknown as T; case "cardsInfo": // Mock card info const cardIds = params.cards || []; return cardIds.map((cardId: number) => ({ cardId, note: cardId + 1000000, // Mock note ID deckName: "Mock Deck", modelName: "Mock Model", interval: 10, factor: 2500, // Anki stores this as factor*1000 reps: 5, lapses: 2, })) as unknown as T; case "notesInfo": // Mock note info const noteIds = params.notes || []; return noteIds.map((noteId: number) => ({ noteId, modelName: "Mock Model", tags: ["leech", "mock_tag"], fields: { Front: { value: "Mock front content", order: 0 }, Back: { value: "Mock back content", order: 1 }, }, })) as unknown as T; case "addTags": // Mock adding tags console.error(`[MOCK] Would add tag "${params.tags}" to notes: ${params.notes.join(", ")}`); return true as unknown as T; default: console.error(`[MOCK] Unknown action: ${action}`); return null as unknown as T; } } /** * Find all cards with the leech tag */ async findLeechCards(): Promise<number[]> { return this.request<number[]>("findCards", { query: "tag:leech", }); } /** * Get card info with optimized batch processing */ async getCardsInfo(cardIds: number[]): Promise<CardInfo[]> { if (cardIds.length === 0) { return []; } try { // Get basic card information for all cards at once const cardsInfo = await this.request<any[]>("cardsInfo", { cards: cardIds, }); // Collect all noteIds to batch request note information const noteIds = cardsInfo.map((card) => card.note); const uniqueNoteIds = [...new Set(noteIds)]; // Remove duplicates // Get all notes information in one batch request const notesInfoArray = await this.request<any[]>("notesInfo", { notes: uniqueNoteIds, }); // Create a map for faster note lookup const notesInfoMap = new Map(); notesInfoArray.forEach((noteInfo) => { notesInfoMap.set(noteInfo.noteId, noteInfo); }); // Process cards in smaller batches to avoid overwhelming AnkiConnect const result: CardInfo[] = []; const BATCH_SIZE = 5; // Small batch size to prevent connection issues for (let i = 0; i < cardsInfo.length; i += BATCH_SIZE) { const batch = cardsInfo.slice(i, i + BATCH_SIZE); // Process this batch sequentially to prevent overwhelming AnkiConnect for (const cardInfo of batch) { try { // Add a small delay between card processing within a batch if (i > 0 || batch.indexOf(cardInfo) > 0) { await this.delay(100); } // Set placeholder for front and back content // Based directly on card fields instead of using renderQA or guiBrowse const front = "[Basic card information only]"; const back = "[Basic card information only]"; const noteInfo = notesInfoMap.get(cardInfo.note); if (!noteInfo) { console.error( `Note info not found for note ID: ${cardInfo.note}` ); continue; } // Combine information result.push({ id: cardInfo.cardId, noteId: cardInfo.note, deck: cardInfo.deckName, modelName: noteInfo.modelName, fields: noteInfo.fields, tags: noteInfo.tags, front: front, // Use our captured front content back: back, // Use our captured back content statistics: { ease: cardInfo.factor / 1000, // Convert from Anki's internal representation interval: cardInfo.interval, reviews: cardInfo.reps, lapses: cardInfo.lapses, }, }); } catch (error) { console.error(`Error processing card ${cardInfo.cardId}:`, error); // Continue with other cards even if one fails } } // Add a longer delay between batches if (i + BATCH_SIZE < cardsInfo.length) { await this.delay(500); } } return result; } catch (error) { console.error("Error in getCardsInfo:", error); throw error; } } /** * Generate a tag with current date in format "見直し_yyyyMMdd" */ private generateReviewedTag(customPrefix: string = "見直し"): string { const now = new Date(); const year = now.getFullYear(); const month = String(now.getMonth() + 1).padStart(2, '0'); const day = String(now.getDate()).padStart(2, '0'); return `${customPrefix}_${year}${month}${day}`; } /** * Add "reviewed" tag to specified cards */ async addTagsToCards(cardIds: number[], customTagPrefix?: string): Promise<boolean> { if (cardIds.length === 0) { console.error("No cards provided to tag"); return false; } try { // First, get the note IDs for the specified cards const cardsInfo = await this.request<any[]>("cardsInfo", { cards: cardIds, }); // Extract unique note IDs const noteIds = [...new Set(cardsInfo.map(card => card.note))]; if (noteIds.length === 0) { console.error("Could not find note IDs for the provided card IDs"); return false; } // Generate the tag with current date const reviewedTag = this.generateReviewedTag(customTagPrefix); // Add the tag to all notes const result = await this.request<boolean>("addTags", { notes: noteIds, tags: reviewedTag, }); console.error(`Tag '${reviewedTag}' added to ${noteIds.length} notes (from ${cardIds.length} cards)`); return result; } catch (error) { console.error("Error adding tags to cards:", error); throw error; } } /** * Check if Anki is available by making a simple request */ async isAvailable(): Promise<boolean> { try { await this.request<string>("version"); return true; } catch (error) { return false; } } } /** * MCP Server implementation for Anki */ class AnkiMcpServer { private server: Server; private ankiClient: AnkiConnectClient; constructor() { this.ankiClient = new AnkiConnectClient(); this.server = new Server( { name: "anki-mcp-server", version: "1.0.0", }, { capabilities: { tools: {}, }, } ); this.setupToolHandlers(); // Error handling this.server.onerror = (error) => console.error("[MCP Error]", error); process.on("SIGINT", async () => { await this.server.close(); process.exit(0); }); } /** * Randomly select a subset of card IDs */ private getRandomCardIds(cardIds: number[], count: number): number[] { // If count is greater than or equal to the total number of cards, return all cards if (count >= cardIds.length) { return cardIds; } // Fisher-Yates shuffle and take the first 'count' elements const shuffled = [...cardIds]; for (let i = shuffled.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]; } return shuffled.slice(0, count); } /** * Set up the tool handlers for the server */ private setupToolHandlers() { // List available tools this.server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [ { name: "get_leech_cards", description: "Retrieve cards tagged as leeches from Anki", inputSchema: { type: "object", properties: { detailed: { type: "boolean", description: "Whether to return detailed card information or just IDs", }, count: { type: "number", description: "Number of random cards to return (defaults to all)", minimum: 1, }, }, }, }, { name: "tag_reviewed_cards", description: "Add a 'reviewed on date' tag to specified cards", inputSchema: { type: "object", properties: { card_ids: { type: "array", items: { type: "number" }, description: "Array of card IDs to tag as reviewed", }, custom_tag_prefix: { type: "string", description: "Custom prefix for the tag (default: '見直し')", }, }, required: ["card_ids"], }, }, ], })); // Handle tool calls this.server.setRequestHandler(CallToolRequestSchema, async (request) => { // Check if Anki is available for all tools const isAvailable = await this.ankiClient.isAvailable(); if (!isAvailable && !ANKI_MOCK_MODE) { return { content: [ { type: "text", text: "Could not connect to Anki. Please make sure Anki is running and AnkiConnect is installed.", }, ], isError: true, }; } try { // Handle different tools switch (request.params.name) { case "get_leech_cards": return await this.handleGetLeechCards(request); case "tag_reviewed_cards": return await this.handleTagReviewedCards(request); default: throw new McpError( ErrorCode.MethodNotFound, `Unknown tool: ${request.params.name}` ); } } catch (error) { let errorMessage = "Unknown error occurred"; if (error instanceof Error) { errorMessage = error.message; } return { content: [ { type: "text", text: `Error: ${errorMessage}`, }, ], isError: true, }; } }); } /** * Handle get_leech_cards tool requests */ private async handleGetLeechCards(request: any) { // Default to detailed = true if not specified const detailed = request.params.arguments?.detailed !== false; // Get the count parameter (optional) const count = typeof request.params.arguments?.count === "number" ? request.params.arguments.count : undefined; // Find all leech cards const allLeechCardIds = await this.ankiClient.findLeechCards(); if (allLeechCardIds.length === 0) { return { content: [ { type: "text", text: "No leech cards found in Anki.", }, ], }; } // Select random subset if count is specified const leechCardIds = count ? this.getRandomCardIds(allLeechCardIds, count) : allLeechCardIds; // Add information about total vs. returned cards const cardCountInfo = count ? `Returning ${leechCardIds.length} random cards out of ${allLeechCardIds.length} total leech cards.` : `Returning all ${leechCardIds.length} leech cards.`; if (!detailed) { // Just return the IDs if detailed=false return { content: [ { type: "text", text: JSON.stringify( { message: cardCountInfo, count: leechCardIds.length, totalLeechCards: allLeechCardIds.length, cardIds: leechCardIds, }, null, 2 ), }, ], }; } // Get detailed information for each selected leech card const cardsInfo = await this.ankiClient.getCardsInfo(leechCardIds); return { content: [ { type: "text", text: JSON.stringify( { message: cardCountInfo, count: cardsInfo.length, totalLeechCards: allLeechCardIds.length, cards: cardsInfo.map((card) => ({ id: card.id, noteId: card.noteId, deck: card.deck, modelName: card.modelName, fields: Object.entries(card.fields).reduce( (acc, [key, field]) => { acc[key] = field.value; return acc; }, {} as Record<string, string> ), tags: card.tags, front: card.front, back: card.back, statistics: card.statistics, })), }, null, 2 ), }, ], }; } /** * Handle tag_reviewed_cards tool requests */ private async handleTagReviewedCards(request: any) { const cardIds = request.params.arguments?.card_ids; const customTagPrefix = request.params.arguments?.custom_tag_prefix; // Validate card IDs if (!cardIds || !Array.isArray(cardIds) || cardIds.length === 0) { return { content: [ { type: "text", text: "Error: No card IDs provided. Please provide an array of card IDs to tag.", }, ], isError: true, }; } // Add tags to cards const success = await this.ankiClient.addTagsToCards(cardIds, customTagPrefix); if (success) { const now = new Date(); const tagDate = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}`; const tagPrefix = customTagPrefix || "見直し"; return { content: [ { type: "text", text: JSON.stringify( { message: `Successfully tagged ${cardIds.length} cards with '${tagPrefix}_${tagDate}'`, tagged_cards: cardIds, tag_added: `${tagPrefix}_${tagDate}`, success: true, }, null, 2 ), }, ], }; } else { return { content: [ { type: "text", text: "Failed to add tags to cards. Please check if the card IDs are valid.", }, ], isError: true, }; } } /** * Start the MCP server */ async run() { const transport = new StdioServerTransport(); await this.server.connect(transport); console.error("Anki MCP server running on stdio"); } } // Initialize and start the server const server = new AnkiMcpServer(); server.run().catch(console.error);

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/captain-blue210/anki-mcp-server'

If you have feedback or need assistance with the MCP directory API, please join our Discord server