mankey
Version:
MCP server for Anki integration via Anki-Connect
913 lines • 81.6 kB
JavaScript
#!/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 { z } from "zod";
// Configuration
const ANKI_CONNECT_URL = process.env.ANKI_CONNECT_URL || "http://127.0.0.1:8765";
const ANKI_CONNECT_VERSION = 6;
const DEBUG = process.env.DEBUG === "true";
// Debug logging helper (writes to stderr which shows in stdio)
function debug(message, data) {
if (DEBUG) {
console.error(`[DEBUG] ${message}`, data ? JSON.stringify(data) : "");
}
}
// Utility function to normalize tags from various formats
function normalizeTags(tags) {
debug("normalizeTags input:", tags);
// Already an array - return as is
if (Array.isArray(tags)) {
debug("Tags already array");
return tags;
}
// String that might be JSON or space-separated
if (typeof tags === "string") {
// Try parsing as JSON array
if (tags.startsWith("[")) {
try {
const parsed = JSON.parse(tags);
if (Array.isArray(parsed)) {
debug("Parsed tags from JSON:", parsed);
return parsed;
}
}
catch {
debug("Failed to parse JSON tags, using space-split");
}
}
// Fall back to space-separated
const split = tags.split(" ").filter(t => t.trim());
debug("Split tags by space:", split);
return split;
}
debug("Unknown tag format, returning empty array");
return [];
}
// Utility to normalize fields from various formats
function normalizeFields(fields) {
debug("normalizeFields input:", fields);
if (!fields) {
return undefined;
}
// Already an object
if (typeof fields === "object" && !Array.isArray(fields)) {
debug("Fields already object");
return fields;
}
// String that might be JSON
if (typeof fields === "string") {
try {
const parsed = JSON.parse(fields);
if (typeof parsed === "object" && !Array.isArray(parsed)) {
debug("Parsed fields from JSON:", parsed);
return parsed;
}
}
catch {
debug("Failed to parse JSON fields");
}
}
debug("Unknown fields format, returning undefined");
return undefined;
}
// Helper for base64 encoding (for media operations)
function _encodeBase64(data) {
if (typeof data === "string") {
return Buffer.from(data).toString("base64");
}
return data.toString("base64");
}
async function ankiConnect(action, params = {}) {
try {
const response = await fetch(ANKI_CONNECT_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action, version: ANKI_CONNECT_VERSION, params }),
});
const data = await response.json();
if (data.error) {
// Clean up nested error messages
const cleanError = data.error.replace(/^Anki-Connect: /, "");
throw new Error(`${action} failed: ${cleanError}`);
}
return data.result;
}
catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
throw new McpError(ErrorCode.InternalError, `Anki-Connect: ${errorMessage}`);
}
}
// Simplified Zod to JSON Schema converter
function zodToJsonSchema(schema) {
const properties = {};
const required = [];
if (!(schema instanceof z.ZodObject)) {
return { type: "object", properties: {} };
}
Object.entries(schema.shape).forEach(([key, value]) => {
const field = value;
let type = "string";
let items = undefined;
// Determine type
if (field instanceof z.ZodNumber) {
type = "number";
}
else if (field instanceof z.ZodBoolean) {
type = "boolean";
}
else if (field instanceof z.ZodArray) {
type = "array";
items = { type: "string" }; // Simplified
}
else if (field instanceof z.ZodObject || field instanceof z.ZodRecord) {
type = "object";
}
const prop = { type };
if (items) {
prop.items = items;
}
const fieldDef = field._def;
if (fieldDef?.description) {
prop.description = fieldDef.description;
}
properties[key] = prop;
if (!field.isOptional()) {
required.push(key);
}
});
return {
type: "object",
properties,
...(required.length > 0 && { required }),
};
}
// Define all tools
const tools = {
// === DECK OPERATIONS ===
deckNames: {
description: "Gets the complete list of deck names for the current user. Returns all decks including nested decks (formatted as 'Parent::Child'). Useful for getting an overview of available decks before performing operations. Returns paginated results to handle large collections efficiently",
schema: z.object({
offset: z.number().optional().default(0).describe("Starting position for pagination"),
limit: z.number().optional().default(1000).describe("Maximum decks to return (default 1000, max 10000)"),
}),
handler: async ({ offset = 0, limit = 1000 }) => {
const allDecks = await ankiConnect("deckNames");
const total = allDecks.length;
const effectiveLimit = Math.min(limit, 10000);
const paginatedDecks = allDecks.slice(offset, offset + effectiveLimit);
return {
decks: paginatedDecks,
pagination: {
offset,
limit: effectiveLimit,
total,
hasMore: offset + effectiveLimit < total,
nextOffset: offset + effectiveLimit < total ? offset + effectiveLimit : null,
}
};
},
},
createDeck: {
description: "Creates a new empty deck. Will not overwrite a deck that exists with the same name. Use '::' separator for nested decks (e.g., 'Japanese::JLPT N5'). Returns the deck ID on success. Safe to call multiple times - acts as 'ensure exists' operation",
schema: z.object({
deck: z.string().describe("Deck name (use :: for nested decks)"),
}),
handler: async ({ deck }) => ankiConnect("createDeck", { deck }),
},
getDeckStats: {
description: "Gets detailed statistics for specified decks including: new_count (blue cards), learn_count (red cards in learning), review_count (green cards due), and total_in_deck. Essential for understanding deck workload and progress. Returns stats keyed by deck ID",
schema: z.object({
decks: z.array(z.string()).describe("Deck names to get stats for"),
}),
handler: async ({ decks }) => ankiConnect("getDeckStats", { decks }),
},
deckNamesAndIds: {
description: "Gets complete mapping of deck names to their internal IDs. IDs are persistent and used internally by Anki. Useful when you need to work with deck IDs directly or correlate names with IDs. Returns object with deck names as keys and IDs as values. Paginated for large collections",
schema: z.object({
offset: z.number().optional().default(0).describe("Starting position for pagination"),
limit: z.number().optional().default(1000).describe("Maximum entries to return (default 1000, max 10000)"),
}),
handler: async ({ offset = 0, limit = 1000 }) => {
const allDecks = await ankiConnect("deckNamesAndIds");
const entries = Object.entries(allDecks);
const total = entries.length;
const effectiveLimit = Math.min(limit, 10000);
const paginatedEntries = entries.slice(offset, offset + effectiveLimit);
return {
decks: Object.fromEntries(paginatedEntries),
pagination: {
offset,
limit: effectiveLimit,
total,
hasMore: offset + effectiveLimit < total,
nextOffset: offset + effectiveLimit < total ? offset + effectiveLimit : null,
}
};
},
},
getDeckConfig: {
description: "Gets the configuration group object for a deck. Contains review settings like: new cards per day, review limits, ease factors, intervals, leech thresholds, and more. Decks can share config groups. Understanding config is crucial for optimizing learning efficiency",
schema: z.object({
deck: z.string().describe("Deck name"),
}),
handler: async ({ deck }) => ankiConnect("getDeckConfig", { deck }),
},
deleteDecks: {
description: "Permanently deletes specified decks. CAUTION: Setting cardsToo=true (default) will delete all cards in the decks. Cards cannot be recovered after deletion. cardsToo MUST be explicitly set. Deleting parent deck deletes all subdecks. Returns true on success",
schema: z.object({
decks: z.array(z.string()).describe("Deck names to delete"),
cardsToo: z.boolean().default(true).describe("Also delete cards"),
}),
handler: async ({ decks, cardsToo }) => {
const result = await ankiConnect("deleteDecks", { decks, cardsToo });
return result === null ? true : result; // Normalize null to true
},
},
// === NOTE OPERATIONS ===
addNotes: {
description: "Bulk create multiple notes in a single operation. Each note creates one or more cards based on the model's templates. Returns array of note IDs (null for failures). More efficient than multiple addNote calls. Duplicates return null unless allowDuplicate=true. Note: Fields must match the model's field names exactly",
schema: z.object({
notes: z.array(z.union([
z.object({
deckName: z.string(),
modelName: z.string(),
fields: z.record(z.string()),
tags: z.array(z.string()).optional(),
options: z.object({
allowDuplicate: z.boolean().optional(),
}).optional(),
}),
z.string() // Allow JSON string representation
])),
}),
handler: async ({ notes }) => {
debug("addNotes called with:", notes);
// Parse and normalize notes
const parsedNotes = notes.map((note) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let parsedNote = note;
if (typeof note === "string") {
try {
parsedNote = JSON.parse(note);
}
catch (_e) {
throw new Error("Invalid note format");
}
}
// Normalize tags using utility
if (parsedNote.tags) {
parsedNote.tags = normalizeTags(parsedNote.tags);
}
return parsedNote;
});
return ankiConnect("addNotes", { notes: parsedNotes });
},
},
addNote: {
description: "Creates a single note (fact) which generates cards based on the model's templates. Basic model creates 1 card, Cloze can create many. Returns the new note ID. Fields must match the model exactly (case-sensitive). Common models: 'Basic' (Front/Back), 'Basic (and reversed card)' (Front/Back, creates 2 cards), 'Cloze' (Text/Extra, use {{c1::text}}). Tags are passed as an array of strings. IMPORTANT: Field names are case-sensitive and must exactly match the model's field names. Use modelFieldNames to check exact field names first",
schema: z.object({
deckName: z.string().describe("Target deck"),
modelName: z.string().describe("Note type (e.g., 'Basic', 'Cloze')"),
fields: z.record(z.string()).describe("Field content"),
tags: z.union([z.array(z.string()), z.string()]).optional().describe("Tags"),
allowDuplicate: z.boolean().optional().describe("Allow duplicates"),
}),
handler: async (args) => {
debug("addNote called with:", args);
const tags = args.tags ? normalizeTags(args.tags) : [];
return ankiConnect("addNote", {
note: {
deckName: args.deckName,
modelName: args.modelName,
fields: args.fields,
tags,
options: { allowDuplicate: args.allowDuplicate || false },
}
});
},
},
findNotes: {
description: "Search for notes using Anki's powerful query syntax. Returns note IDs matching the query. Common queries: 'deck:DeckName' (notes in deck), 'tag:tagname' (tagged notes), 'is:new' (new notes), 'is:due' (notes with due cards), 'added:7' (added in last 7 days), 'front:text' (search Front field), '*' (all notes). Combine with AND/OR. IMPORTANT: Deck names with '::' hierarchy need quotes: 'deck:\"Parent::Child\"'. Returns paginated results for large collections. Note: Returns notes, not cards",
schema: z.object({
query: z.string().describe("Search query (e.g., 'deck:current', 'deck:Default', 'tag:vocab')"),
offset: z.number().optional().default(0).describe("Starting position for pagination"),
limit: z.number().optional().default(100).describe("Maximum notes to return (default 100, max 1000)"),
}),
handler: async ({ query, offset = 0, limit = 100 }) => {
try {
const allNotes = await ankiConnect("findNotes", { query });
const total = Array.isArray(allNotes) ? allNotes.length : 0;
const effectiveLimit = Math.min(limit, 1000);
const paginatedNotes = Array.isArray(allNotes) ? allNotes.slice(offset, offset + effectiveLimit) : [];
return {
notes: paginatedNotes,
pagination: {
offset,
limit: effectiveLimit,
total,
hasMore: offset + effectiveLimit < total,
nextOffset: offset + effectiveLimit < total ? offset + effectiveLimit : null,
}
};
}
catch (_error) {
// Return empty result set on error
return {
notes: [],
pagination: {
offset,
limit: Math.min(limit, 1000),
total: 0,
hasMore: false,
nextOffset: null,
}
};
}
},
},
updateNote: {
description: "Updates an existing note's fields and/or tags. Only provided fields are updated, others remain unchanged. Field names must match the model exactly. Tags array replaces all existing tags (not additive). Changes affect all cards generated from this note. Updates modification time. Note: Changing fields that affect card generation may reset card scheduling. IMPORTANT: Field names are case-sensitive. Use notesInfo first to check current field names. Returns true on success",
schema: z.object({
id: z.union([z.number(), z.string()]).describe("Note ID"),
fields: z.record(z.string()).optional().describe("Fields to update"),
tags: z.union([z.array(z.string()), z.string()]).optional().describe("New tags"),
}),
handler: async ({ id, fields, tags }) => {
debug("updateNote called with:", { id, fields, tags });
const noteData = {
id: typeof id === "string" ? parseInt(id, 10) : id
};
// Use utility to normalize fields
const normalizedFields = normalizeFields(fields);
if (normalizedFields) {
noteData.fields = normalizedFields;
}
// Use utility to normalize tags
if (tags) {
noteData.tags = normalizeTags(tags);
}
debug("Sending to Anki-Connect:", noteData);
const result = await ankiConnect("updateNote", {
note: noteData
});
return result === null ? true : result; // Normalize null to true
},
},
deleteNotes: {
description: "Permanently deletes notes and all their associated cards. CAUTION: This is irreversible. Cards' review history is also deleted. Deletion is immediate and cannot be undone. Use suspend instead if you might need the notes later. Accepts array of note IDs. Returns true on success",
schema: z.object({
notes: z.array(z.union([z.number(), z.string()])).describe("Note IDs to delete"),
}),
handler: async ({ notes }) => {
const result = await ankiConnect("deleteNotes", {
notes: notes.map((id) => typeof id === "string" ? parseInt(id, 10) : id)
});
return result === null ? true : result; // Normalize null to true
},
},
notesInfo: {
description: "Gets comprehensive information about notes including: noteId, modelName, tags array, all fields with their values and order, cards array (IDs of all cards from this note), and modification time. Essential for displaying or editing notes. Automatically paginates large requests to handle bulk operations efficiently. Returns null for non-existent notes",
schema: z.object({
notes: z.array(z.union([z.number(), z.string()])).describe("Note IDs (automatically batched if >100)"),
}),
handler: async ({ notes }) => {
const noteIds = notes.map((id) => typeof id === "string" ? parseInt(id, 10) : id);
// Batch process if more than 100 notes
if (noteIds.length <= 100) {
return ankiConnect("notesInfo", { notes: noteIds });
}
// Process in batches of 100
const batchSize = 100;
const results = [];
for (let i = 0; i < noteIds.length; i += batchSize) {
const batch = noteIds.slice(i, i + batchSize);
const batchResult = await ankiConnect("notesInfo", { notes: batch });
results.push(...batchResult);
}
return {
notes: results,
metadata: {
total: results.length,
batches: Math.ceil(noteIds.length / batchSize),
batchSize,
}
};
},
},
getTags: {
description: "Gets all unique tags used across the entire collection. Tags are hierarchical using '::' separator (e.g., 'japanese::grammar'). Returns flat list of all tags including parent and child tags separately. Useful for tag management, autocomplete, or finding unused tags. Returns paginated results for collections with many tags",
schema: z.object({
offset: z.number().optional().default(0).describe("Starting position for pagination"),
limit: z.number().optional().default(1000).describe("Maximum tags to return (default 1000, max 10000)"),
}),
handler: async ({ offset = 0, limit = 1000 }) => {
const allTags = await ankiConnect("getTags");
const total = allTags.length;
const effectiveLimit = Math.min(limit, 10000);
const paginatedTags = allTags.slice(offset, offset + effectiveLimit);
return {
tags: paginatedTags,
pagination: {
offset,
limit: effectiveLimit,
total,
hasMore: offset + effectiveLimit < total,
nextOffset: offset + effectiveLimit < total ? offset + effectiveLimit : null,
}
};
},
},
addTags: {
description: "Adds tags to existing notes without affecting existing tags (additive operation). Tags are space-separated in Anki but passed as a single string here. Use double quotes for tags with spaces. Hierarchical tags supported with '::'. Updates modification time. Does not validate tag names - be consistent with naming",
schema: z.object({
notes: z.array(z.union([z.number(), z.string()])).describe("Note IDs"),
tags: z.string().describe("Space-separated tags"),
}),
handler: async ({ notes, tags }) => ankiConnect("addTags", {
notes: notes.map((id) => typeof id === "string" ? parseInt(id, 10) : id),
tags
}),
},
removeTags: {
description: "Removes specific tags from notes while preserving other tags. Tags parameter is space-separated string. Only removes exact matches - won't remove child tags when removing parent. Updates modification time. Safe operation - removing non-existent tags has no effect",
schema: z.object({
notes: z.array(z.union([z.number(), z.string()])).describe("Note IDs"),
tags: z.string().describe("Space-separated tags"),
}),
handler: async ({ notes, tags }) => ankiConnect("removeTags", {
notes: notes.map((id) => typeof id === "string" ? parseInt(id, 10) : id),
tags
}),
},
// === CARD OPERATIONS ===
findCards: {
description: "Search for cards using Anki's query syntax. Returns card IDs (not note IDs). Common queries: 'deck:DeckName' (cards in deck), 'is:due' (due for review today), 'is:new' (never studied), 'is:learn' (in learning phase), 'is:suspended' (suspended cards), 'prop:due<=0' (overdue), 'rated:1:1' (reviewed today, answered Hard). Note: 'is:due' excludes learning cards - use getNextCards for actual review order. IMPORTANT: Deck names with '::' hierarchy need quotes: 'deck:\"Parent::Child\"'. Returns paginated results",
schema: z.object({
query: z.string().describe("Search query (e.g. 'deck:current', 'deck:Default is:due', 'tag:japanese')"),
offset: z.number().optional().default(0).describe("Starting position for pagination"),
limit: z.number().optional().default(100).describe("Maximum cards to return (default 100, max 1000)"),
}),
handler: async ({ query, offset = 0, limit = 100 }) => {
try {
const allCards = await ankiConnect("findCards", { query });
const total = Array.isArray(allCards) ? allCards.length : 0;
const effectiveLimit = Math.min(limit, 1000);
const paginatedCards = Array.isArray(allCards) ? allCards.slice(offset, offset + effectiveLimit) : [];
return {
cards: paginatedCards,
pagination: {
offset,
limit: effectiveLimit,
total,
hasMore: offset + effectiveLimit < total,
nextOffset: offset + effectiveLimit < total ? offset + effectiveLimit : null,
}
};
}
catch (_error) {
// Return empty result set on error
return {
cards: [],
pagination: {
offset,
limit: Math.min(limit, 1000),
total: 0,
hasMore: false,
nextOffset: null,
}
};
}
},
},
getNextCards: {
description: "Gets cards in the exact order they'll appear during review, following Anki's scheduling algorithm. Priority: 1) Cards in learning (red - failed or recently learned), 2) Review cards due today (green), 3) New cards up to daily limit (blue). Critical for simulating actual review session. Use deck:current for current deck or provide deck name. Includes suspended:false by default. Returns with scheduling metadata",
schema: z.object({
deck: z.string().describe("Deck name (or 'current' for current deck)").optional(),
limit: z.number().describe("Maximum cards to return (default 10, max 100)").default(10),
offset: z.number().describe("Starting position for pagination").default(0).optional(),
}),
handler: async ({ deck, limit, offset = 0 }) => {
// Build deck prefix for queries
const deckPrefix = deck === "current" ? "deck:current" :
deck ? `deck:"${deck}"` : "";
// Get learning cards first (queue=1 or queue=3)
const learningQuery = deckPrefix
? `${deckPrefix} (queue:1 OR queue:3)`
: "(queue:1 OR queue:3)";
const learningCards = await ankiConnect("findCards", { query: learningQuery });
// Get review cards (queue=2 and due)
const reviewQuery = deckPrefix
? `${deckPrefix} is:due`
: "is:due";
const reviewCards = await ankiConnect("findCards", { query: reviewQuery });
// Get new cards if needed (queue=0)
const newQuery = deckPrefix
? `${deckPrefix} is:new`
: "is:new";
const newCards = await ankiConnect("findCards", { query: newQuery });
// Combine in priority order
const allCards = [...learningCards, ...reviewCards, ...newCards];
const total = allCards.length;
const effectiveLimit = Math.min(limit, 100);
const paginatedCards = allCards.slice(offset, offset + effectiveLimit);
if (paginatedCards.length === 0) {
return {
cards: [],
message: "No cards due for review",
pagination: {
offset,
limit: effectiveLimit,
total,
hasMore: false
}
};
}
// Get detailed info for these cards
const cardInfo = await ankiConnect("cardsInfo", { cards: paginatedCards });
// Categorize by queue type
const categorized = {
learning: [],
review: [],
new: [],
};
for (const card of cardInfo) {
if (card.queue === 1 || card.queue === 3) {
categorized.learning.push(card);
}
else if (card.queue === 2) {
categorized.review.push(card);
}
else if (card.queue === 0) {
categorized.new.push(card);
}
}
return {
cards: cardInfo,
breakdown: {
learning: categorized.learning.length,
review: categorized.review.length,
new: categorized.new.length,
},
pagination: {
offset,
limit: effectiveLimit,
total,
hasMore: offset + effectiveLimit < total,
nextOffset: offset + effectiveLimit < total ? offset + effectiveLimit : null
},
queueOrder: "Learning cards shown first, then reviews, then new cards"
};
},
},
cardsInfo: {
description: "Gets comprehensive card information including: cardId, noteId, deckName, modelName, question/answer HTML, scheduling data (due date, interval, ease factor, reviews, lapses), queue status, modification time, and more. Essential for understanding card state and history. Automatically handles string/number ID conversion and paginates large requests. Returns detailed objects for each card",
schema: z.object({
cards: z.array(z.union([z.number(), z.string()])).describe("Card IDs (automatically batched if >100)"),
}),
handler: async ({ cards }) => {
const cardIds = cards.map((id) => typeof id === "string" ? parseInt(id, 10) : id);
// Batch process if more than 100 cards
if (cardIds.length <= 100) {
return ankiConnect("cardsInfo", { cards: cardIds });
}
// Process in batches of 100
const batchSize = 100;
const results = [];
for (let i = 0; i < cardIds.length; i += batchSize) {
const batch = cardIds.slice(i, i + batchSize);
const batchResult = await ankiConnect("cardsInfo", { cards: batch });
results.push(...batchResult);
}
return {
cards: results,
metadata: {
total: results.length,
batches: Math.ceil(cardIds.length / batchSize),
batchSize,
}
};
},
},
suspend: {
description: "Suspends cards, removing them from review queue while preserving all scheduling data. Suspended cards won't appear in reviews but remain in collection. Useful for temporarily hiding problematic or irrelevant cards. Can be reversed with unsuspend. Cards show yellow background in browser when suspended",
schema: z.object({
cards: z.array(z.union([z.number(), z.string()])).describe("Card IDs to suspend"),
}),
handler: async ({ cards }) => ankiConnect("suspend", {
cards: cards.map((id) => typeof id === "string" ? parseInt(id, 10) : id)
}),
},
unsuspend: {
description: "Restores suspended cards to active review queue with all scheduling data intact. Cards resume from where they left off - due cards become immediately due, learning cards continue learning phase. No scheduling information is lost during suspension period. Returns true on success",
schema: z.object({
cards: z.array(z.union([z.number(), z.string()])).describe("Card IDs to unsuspend"),
}),
handler: async ({ cards }) => {
const result = await ankiConnect("unsuspend", {
cards: cards.map((id) => typeof id === "string" ? parseInt(id, 10) : id)
});
return result === null ? true : result; // Normalize null to true
},
},
getEaseFactors: {
description: "Gets ease factors (difficulty multipliers) for cards. Ease affects interval growth: default 250% (2.5x), minimum 130%, Hard decreases by 15%, Easy increases by 15%. Lower ease = more frequent reviews. Useful for identifying difficult cards (ease < 200%) that may need reformulation. Returns array of ease values",
schema: z.object({
cards: z.array(z.union([z.number(), z.string()])).describe("Card IDs"),
}),
handler: async ({ cards }) => ankiConnect("getEaseFactors", {
cards: cards.map((id) => typeof id === "string" ? parseInt(id, 10) : id)
}),
},
setEaseFactors: {
description: "Manually sets ease factors for cards. Use with caution - can disrupt spaced repetition algorithm. Typical range: 130-300%. Setting ease to 250% resets to default. Lower values increase review frequency, higher values decrease it. Useful for manually adjusting difficult cards. Changes take effect on next review",
schema: z.object({
cards: z.array(z.union([z.number(), z.string()])).describe("Card IDs"),
easeFactors: z.array(z.number()).describe("Ease factors (1.3-2.5)"),
}),
handler: async ({ cards, easeFactors }) => ankiConnect("setEaseFactors", {
cards: cards.map((id) => typeof id === "string" ? parseInt(id, 10) : id),
easeFactors
}),
},
// === MODEL OPERATIONS ===
modelNames: {
description: "Lists all available note types (models) in the collection. Common built-in models: 'Basic' (Front/Back fields), 'Basic (and reversed card)', 'Cloze' (for cloze deletions), 'Basic (type in the answer)'. Custom models show user-defined names. Essential for addNote operations. Returns paginated results for collections with many models",
schema: z.object({
offset: z.number().optional().default(0).describe("Starting position for pagination"),
limit: z.number().optional().default(1000).describe("Maximum models to return (default 1000, max 10000)"),
}),
handler: async ({ offset = 0, limit = 1000 }) => {
const allModels = await ankiConnect("modelNames");
const total = allModels.length;
const effectiveLimit = Math.min(limit, 10000);
const paginatedModels = allModels.slice(offset, offset + effectiveLimit);
return {
models: paginatedModels,
pagination: {
offset,
limit: effectiveLimit,
total,
hasMore: offset + effectiveLimit < total,
nextOffset: offset + effectiveLimit < total ? offset + effectiveLimit : null,
}
};
},
},
modelFieldNames: {
description: "Gets ordered list of field names for a specific model. Field names are case-sensitive and must match exactly when creating/updating notes. Common fields: 'Front', 'Back' (Basic), 'Text', 'Extra' (Cloze). Order matters for some operations. Essential for validating note data before creation",
schema: z.object({
modelName: z.string().describe("Note type name"),
}),
handler: async ({ modelName }) => ankiConnect("modelFieldNames", { modelName }),
},
modelNamesAndIds: {
description: "Gets mapping of model names to their internal IDs. Model IDs are timestamps of creation and never change. Useful for operations requiring model IDs or checking if models exist. IDs are stable across syncs. Returns object with model names as keys, IDs as values. Paginated for large collections",
schema: z.object({
offset: z.number().optional().default(0).describe("Starting position for pagination"),
limit: z.number().optional().default(1000).describe("Maximum entries to return (default 1000, max 10000)"),
}),
handler: async ({ offset = 0, limit = 1000 }) => {
const allModels = await ankiConnect("modelNamesAndIds");
const entries = Object.entries(allModels);
const total = entries.length;
const effectiveLimit = Math.min(limit, 10000);
const paginatedEntries = entries.slice(offset, offset + effectiveLimit);
return {
models: Object.fromEntries(paginatedEntries),
pagination: {
offset,
limit: effectiveLimit,
total,
hasMore: offset + effectiveLimit < total,
nextOffset: offset + effectiveLimit < total ? offset + effectiveLimit : null,
}
};
},
},
createModel: {
description: "Creates a custom note type with specified fields and card templates. Requires careful template syntax: {{Field}} for replacements, {{#Field}}...{{/Field}} for conditionals. Templates generate cards from notes. CSS styling is shared across all templates. Model name must be unique. Returns created model object. Complex operation - consider cloning existing models instead",
schema: z.object({
modelName: z.string().describe("Model name"),
inOrderFields: z.array(z.string()).describe("Field names"),
css: z.string().optional().describe("Card CSS"),
isCloze: z.boolean().optional(),
cardTemplates: z.array(z.object({
Name: z.string().optional(),
Front: z.string(),
Back: z.string(),
})),
}),
handler: async (args) => ankiConnect("createModel", args),
},
// === STATISTICS ===
getNumCardsReviewedToday: {
description: "Get today's review count",
schema: z.object({}),
handler: async () => ankiConnect("getNumCardsReviewedToday"),
},
getDueCardsDetailed: {
description: "Get due cards with detailed categorization by queue type (learning vs review). Use 'current' for current deck",
schema: z.object({
deck: z.string().describe("Deck name (or 'current' for current deck)").optional(),
}),
handler: async ({ deck }) => {
// Query for different card states
const baseQuery = deck === "current" ? "deck:current" :
deck ? `deck:"${deck}"` : "";
// Learning cards (queue 1 or 3, due soon)
const learningQuery = baseQuery ? `${baseQuery} (queue:1 OR queue:3)` : "(queue:1 OR queue:3)";
const learningCards = await ankiConnect("findCards", { query: learningQuery });
// Review cards (queue 2, due today or overdue)
const reviewQuery = baseQuery ? `${baseQuery} is:due` : "is:due";
const reviewCards = await ankiConnect("findCards", { query: reviewQuery });
// Get info for all cards
const allCardIds = [...learningCards, ...reviewCards];
if (allCardIds.length === 0) {
return { learning: [], review: [], total: 0 };
}
const cardInfo = await ankiConnect("cardsInfo", { cards: allCardIds });
// Separate by actual queue type
const learning = [];
const review = [];
for (const card of cardInfo) {
// Queue meanings:
// 0 = new, 1 = learning, 2 = review, 3 = relearning
if (card.queue === 1 || card.queue === 3) {
learning.push({
cardId: card.cardId,
front: card.fields?.Front?.value || card.fields?.Simplified?.value || "N/A",
interval: card.interval,
due: card.due,
queue: card.queue === 1 ? "learning" : "relearning",
reps: card.reps,
});
}
else if (card.queue === 2 && card.due <= Math.floor(Date.now() / 1000 / 86400)) {
review.push({
cardId: card.cardId,
front: card.fields?.Front?.value || card.fields?.Simplified?.value || "N/A",
interval: card.interval,
due: card.due,
queue: "review",
ease: card.factor,
});
}
}
// Sort learning by due time (most urgent first)
learning.sort((a, b) => a.due - b.due);
// Sort review by due date
review.sort((a, b) => a.due - b.due);
return {
learning: learning,
review: review,
total: learning.length + review.length,
note: "Learning cards (including relearning) are shown before review cards in Anki"
};
},
},
getNumCardsReviewedByDay: {
description: "Gets number of reviews performed on a specific day. Date format: Unix timestamp (seconds since epoch). Returns total review count including new, learning, and review cards. Useful for tracking study patterns and consistency. Historical data available since collection creation",
schema: z.object({}),
handler: async () => ankiConnect("getNumCardsReviewedByDay"),
},
getCollectionStatsHTML: {
description: "Gets comprehensive collection statistics as formatted HTML including: total cards/notes, daily averages, retention rates, mature vs young cards, time spent studying, forecast, and more. Same statistics shown in Anki's Stats window. HTML includes embedded CSS for proper rendering. Useful for dashboards and reporting",
schema: z.object({
wholeCollection: z.boolean().optional().default(true),
}),
handler: async ({ wholeCollection }) => ankiConnect("getCollectionStatsHTML", { wholeCollection }),
},
// === MEDIA ===
storeMediaFile: {
description: "Stores a media file in Anki's media folder. REQUIRES one of: data (base64), path, or url. Media is automatically synced to AnkiWeb. Supported formats: images (jpg, png, gif, svg), audio (mp3, ogg, wav), video (mp4, webm). File is available immediately for use in cards with HTML tags like <img> or [sound:]. Example with base64: {filename: 'test.txt', data: 'SGVsbG8gV29ybGQ='}. Returns filename on success",
schema: z.object({
filename: z.string().describe("File name"),
data: z.string().optional().describe("Base64-encoded file content"),
url: z.string().optional().describe("URL to download from"),
path: z.string().optional().describe("Local file path to read from"),
deleteExisting: z.boolean().optional().default(true).describe("Replace if file exists"),
}),
handler: async (args) => {
// Validate that at least one data source is provided
if (!args.data && !args.url && !args.path) {
throw new Error("storeMediaFile requires one of: data (base64), url, or path");
}
return ankiConnect("storeMediaFile", args);
},
},
retrieveMediaFile: {
description: "Retrieves a media file from Anki's collection as base64-encoded data. Specify just the filename (not full path). Returns false if file doesn't exist. Useful for backing up media or transferring between collections. Large files may take time to encode",
schema: z.object({
filename: z.string().describe("File name"),
}),
handler: async ({ filename }) => ankiConnect("retrieveMediaFile", { filename }),
},
getMediaFilesNames: {
description: "Lists all media files in the collection including images, audio, and video files. Pattern supports wildcards (* and ?). Returns array of filenames (not paths). Useful for media management, finding unused files, or bulk operations. Large collections may have thousands of files",
schema: z.object({
pattern: z.string().optional().describe("File pattern"),
}),
handler: async ({ pattern }) => ankiConnect("getMediaFilesNames", { pattern }),
},
deleteMediaFile: {
description: "Permanently deletes a media file from the collection. CAUTION: Cannot be undone. File is removed immediately and will be deleted from AnkiWeb on next sync. Cards referencing the file will show broken media. Consider checking usage with findNotes before deletion",
schema: z.object({
filename: z.string().describe("File name"),
}),
handler: async ({ filename }) => ankiConnect("deleteMediaFile", { filename }),
},
// === MISCELLANEOUS ===
sync: {
description: "Performs full two-way sync with AnkiWeb. Requires AnkiWeb credentials configured in Anki. Uploads local changes and downloads remote changes. May take time for large collections. Resolves conflicts based on modification time. Network errors may require retry. Not available in some Anki configurations",
schema: z.object({}),
handler: async () => ankiConnect("sync"),
},
getProfiles: {
description: "Lists all available Anki user profiles. Each profile has separate collections, settings, and add-ons. Useful for multi-user setups or separating study topics. Returns array of profile names. Current profile marked in Anki interface. Returns paginated results for many profiles",
schema: z.object({
offset: z.number().optional().default(0).describe("Starting position for pagination"),
limit: z.number().optional().default(100).describe("Maximum profiles to return (default 100, max 1000)"),
}),
handler: async ({ offset = 0, limit = 100 }) => {
const allProfiles = await ankiConnect("getProfiles");
const total = allProfiles.length;
const effectiveLimit = Math.min(limit, 1000);
const paginatedProfiles = allProfiles.slice(offset, offset + effectiveLimit);
return {
profiles: paginatedProfiles,
pagination: {
offset,
limit: effectiveLimit,
total,
hasMore: offset + effectiveLimit < total,
nextOffset: offset + effectiveLimit < total ? offset + effectiveLimit : null,
}
};
},
},
loadProfile: {
description: "Switches to a different user profile. Closes current collection and opens the specified profile's collection. All subsequent operations affect the new profile. May fail if profile doesn't exist or is already loaded. Useful for automated multi-profile operations",
schema: z.object({
name: z.string().describe("Profile name"),
}),
handler: async ({ name }) => ankiConnect("loadProfile", { name }),
},
exportPackage: {
description: "Exports a deck to .apkg format for sharing or backup. Path must be absolute and include .apkg extension. Set includeSched=false to exclude review history (for sharing). MediaFiles included by default. Creates portable package that can be imported to any Anki installation. Large decks with media may take time",
schema: z.object({
deck: z.string().describe("Deck name"),
path: z.string().describe("Export path"),
includeSched: z.boolean().optional().default(false),
}),
handler: async ({ deck, path, includeSched }) => ankiConnect("exportPackage", { deck, path, includeSched }),
},
importPackage: {
description: "Imports an .apkg package file into the collection. Path must be absolute. Merges content with existing collection - doesn't overwrite. Handles duplicate detection based on note IDs. Media files are imported to media folder. May take significant time for large packages. Returns true on success",
schema: z.object({
path: z.string().describe("Import path"),
}),
handler: async ({ path }) => ankiConnect("importPackage", { path }),
},
// === GUI OPERATIONS ===
guiBrowse: {
description: "Opens Anki's Browse window with optional search query. Query uses same syntax as findCards/findNotes. Useful for complex manual review or bulk operations. Browser allows editing, tagging, suspending, and more. Returns array of note IDs initially shown. Requires Anki GUI running",
schema: z.object({
query: z.string().describe("Search query"),
reorderCards: z.object({
order: z.enum(["ascending", "descending"]).optional(),
columnId: z.string().optional(),
}).optional(),
}),
handler: async (args) => ankiConnect("guiBrowse", args),
},
guiAddCards: {
description: "Opens Add Cards dialog pre-filled with specified content. Allows user to review and modify before adding. Useful for semi-automated card creation where human review is needed. CloseAfterAdding option controls dialog behavior. Returns note ID if card was added (null if cancelled). Requires GUI",
schema: z.object({
note: z.object({
deckName: z.string(),
modelName: z.string(),
fields: z.record(z.string()),
tags: z.array(z.string()).optional(),
}),
}),
handler: async ({ note }) => ankiConnect("guiAddCards", { note }),
},
guiCurrentCard: {
description: "Gets information about the card currently being reviewed in the main window. Returns null if not in review mode. Includes card ID, question/answer content, buttons available, and more. Useful for integration with review session or automated review helpers. Requires active review session",
schema: z.object({}),
handler: async () => ankiConnect("guiCurrentCard"),
},
guiAnswerCard: {
description: "Answers the current review card programmatically. Ease values: 1=Again (fail), 2=Hard, 3=Good, 4=Easy. Affects scheduling based on chosen ease. Only works during active review session. Automatically shows next card. Useful for automated review or accessibility tools. Returns true on success",
schema: z.object({
ease: z.number().min(1).max(4).describe("1=Again, 2=Hard, 3=Good, 4=Easy"),
}),
handler: async ({ ease }) => ankiConnect("guiAnswerCard", { ease }),
},
guiDeckOverview: {
description: "Opens deck overview screen showing study options and statistics for specified deck. Displays new/learning/review counts and study buttons. User can start studying from this screen. Useful for navigating to specific deck programmatically. Requires GUI running",
schema: z.object({
name: z.string().describe("Deck name"),
}),
handler: async ({ name }) => ankiConnect("guiDeckOve