UNPKG

yanki-mcp-server

Version:

A Model Context Protocol server for interacting with Anki flashcards

341 lines 12.2 kB
#!/usr/bin/env node import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { CallToolRequestSchema, ListResourcesRequestSchema, ListToolsRequestSchema, ReadResourceRequestSchema, } from "@modelcontextprotocol/sdk/types.js"; import { YankiConnect } from "yanki-connect"; const client = new YankiConnect(); // Default inbox prefix if environment variable is not set const DECK = "00_Inbox"; // Get inbox prefix from environment variable or use default const PREFIX_DECK = process.env.ANKI_DECK || DECK; /** * Create an MCP server with capabilities for resources (to get Anki cards), * and tools (to answer cards, create new cards and get cards). */ const server = new Server({ name: "anki-server", version: "1.0.0", }, { capabilities: { resources: {}, tools: {}, }, }); /** * Handler for listing Anki cards as resources. * Cards are exposed as a resource with: * - An anki:// URI scheme plus a filter * - JSON MIME type * - All resources return a list of cards under different filters */ server.setRequestHandler(ListResourcesRequestSchema, async () => { return { resources: [ { uri: "anki://search/deckcurrent", mimeType: "application/json", name: "Current Deck", description: "Current Anki deck", }, { uri: "anki://search/isdue", mimeType: "application/json", name: "Due cards", description: "Cards in review and learning waiting to be studied", }, { uri: "anki://search/isnew", mimiType: "application/json", name: "New cards", description: "All unseen cards", }, ], }; }); /** * Filters Anki cards based on selected resource */ server.setRequestHandler(ReadResourceRequestSchema, async (request) => { const url = new URL(request.params.uri); const query = url.pathname.split("/").pop(); if (!query) { throw new Error("Invalid resource URI"); } const cards = await findCardsAndOrder(query); return { contents: [ { uri: request.params.uri, mimeType: "application/json", text: JSON.stringify(cards), }, ], }; }); // Returns a list of cards ordered by due date async function findCardsAndOrder(query) { const cardIds = await client.card.findCards({ query: formatQuery(query), }); const cards = (await client.card.cardsInfo({ cards: cardIds })) .map((card) => ({ cardId: card.cardId, question: cleanWithRegex(card.question), answer: cleanWithRegex(card.answer), due: card.due, })) .sort((a, b) => a.due - b.due); return cards; } // Formats the uri to be a proper query function formatQuery(query) { if (query.startsWith("deck")) { return `deck:${query.slice(4)}`; } if (query.startsWith("is")) { return `is:${query.slice(2)}`; } return query; } function getTodayDeckName() { const today = new Date(); const yyyy = today.getFullYear(); const mm = String(today.getMonth() + 1).padStart(2, "0"); const dd = String(today.getDate()).padStart(2, "0"); return `${PREFIX_DECK}::${yyyy}::${mm}::${dd}`; } /** * Creates a deck if it doesn't already exist * @param deckName The name of the deck to create * @returns Promise resolving to true if deck was created or already exists */ async function createDeckIfNeeded(deckName) { try { // Get list of existing decks const deckNames = await client.deck.deckNames(); // If deck doesn't exist, create it if (!deckNames.includes(deckName)) { // Use YankiConnect's native createDeck method const deckId = await client.deck.createDeck({ deck: deckName }); return deckId !== null; } // Deck already exists return true; } catch (error) { return false; } } // Strip away formatting that isn't necessary function cleanWithRegex(htmlString) { return (htmlString // Remove style tags and their content .replace(/<style[^>]*>[\s\S]*?<\/style>/gi, "") // Replace divs with newlines .replace(/<div[^>]*>/g, "\n") // Remove all HTML tags .replace(/<[^>]+>/g, " ") // Remove anki play tags .replace(/\[anki:play:[^\]]+\]/g, "") // Convert HTML entities .replace(/&nbsp;/g, " ") .replace(/&amp;/g, "&") .replace(/&lt;/g, "<") .replace(/&gt;/g, ">") .replace(/&quot;/g, '"') // Clean up whitespace but preserve newlines .split("\n") .map((line) => line.trim()) .filter((line) => line.length > 0) .join("\n")); } /** * Handler that lists available tools. */ server.setRequestHandler(ListToolsRequestSchema, async () => { return { tools: [ { name: "update_cards", description: "After the user answers cards you've quizzed them on, use this tool to mark them answered and update their ease", inputSchema: { type: "object", properties: { answers: { type: "array", items: { type: "object", properties: { cardId: { type: "number", description: "Id of the card to answer", }, ease: { type: "number", description: "Ease of the card between 1 (Again) and 4 (Easy)", }, }, }, }, }, }, }, { name: "add_card", description: 'Create a new flashcard in Anki for the user. Must use HTML formatting only. IMPORTANT FORMATTING RULES:\n1. Must use HTML tags for ALL formatting - NO markdown\n2. Use <br> for ALL line breaks\n3. For code blocks, use <pre> with inline CSS styling\n4. Example formatting:\n - Line breaks: <br>\n - Code: <pre style="background-color: transparent; padding: 10px; border-radius: 5px;">\n - Title: <h2> to <h6> tags\n - Lists: <ol> and <li> tags\n - Bold: <strong>\n - Italic: <em>', inputSchema: { type: "object", properties: { front: { type: "string", description: "The front of the card. Must use HTML formatting only.", }, back: { type: "string", description: "The back of the card. Must use HTML formatting only.", }, }, required: ["front", "back"], }, }, { name: "get_due_cards", description: "Returns a given number (num) of cards due for review.", inputSchema: { type: "object", properties: { num: { type: "number", description: "Number of due cards to get", }, }, required: ["num"], }, }, { name: "get_new_cards", description: "Returns a given number (num) of new and unseen cards.", inputSchema: { type: "object", properties: { num: { type: "number", description: "Number of new cards to get", }, }, required: ["num"], }, }, ], }; }); /** * Handler for the update_cards, add_card, get_due_cards and get_new_cards tools. */ server.setRequestHandler(CallToolRequestSchema, async (request) => { const { name, arguments: args } = request.params; if (!args) { throw new Error(`No arguments provided for tool: ${name}`); } switch (name) { case "update_cards": { const answers = args.answers; const result = await client.card.answerCards({ answers: answers }); const successfulCards = answers .filter((_, index) => result[index]) .map((card) => card.cardId); const failedCards = answers.filter((_, index) => !result[index]); if (failedCards.length > 0) { const failedCardIds = failedCards.map((card) => card.cardId); throw new Error(`Failed to update cards with IDs: ${failedCardIds.join(", ")}`); } return { content: [ { type: "text", text: `Updated cards ${successfulCards.join(", ")}`, }, ], }; } case "add_card": { const front = String(args.front); const back = String(args.back); const deckName = getTodayDeckName(); // Create the deck if it doesn't exist const deckCreated = await createDeckIfNeeded(deckName); if (!deckCreated) { throw new Error(`Failed to create deck: ${deckName}`); } const note = { note: { deckName: deckName, fields: { Back: back, Front: front, }, modelName: "Basic", }, }; const noteId = await client.note.addNote(note); if (!noteId) { throw new Error("Failed to add note to Anki"); } const cardIds = await client.card.findCards({ query: `nid:${noteId}` }); const cardId = cardIds[0]; return { content: [ { type: "text", text: `Created card with id ${cardId} in deck ${deckName}`, }, ], }; } case "get_due_cards": { const num = Number(args.num); const cards = await findCardsAndOrder("is:due"); return { content: [ { type: "text", text: JSON.stringify(cards.slice(0, num)), }, ], }; } case "get_new_cards": { const num = Number(args.num); const cards = await findCardsAndOrder("is:new"); return { content: [ { type: "text", text: JSON.stringify(cards.slice(0, num)), }, ], }; } default: throw new Error("Unknown tool"); } }); /** * Start the server using stdio transport. * This allows the server to communicate via standard input/output streams. */ async function main() { // Connect to Anki silently try { await client.deck.deckNames(); } catch (error) { // Silent fail - we don't want to break the MCP protocol } const transport = new StdioServerTransport(); await server.connect(transport); } main().catch((error) => { process.exit(1); }); //# sourceMappingURL=index.js.map