Anki MCP Server
by nailuoGG
/**
* Utility functions and anti-corruption layer for yanki-connect
*/
import { YankiConnect } from "yanki-connect";
import { ErrorCode, McpError } from "@modelcontextprotocol/sdk/types.js";
/**
* Custom error types for Anki operations
*/
export class AnkiConnectionError extends Error {
constructor(message: string) {
super(message);
this.name = "AnkiConnectionError";
}
}
export class AnkiTimeoutError extends Error {
constructor(message: string) {
super(message);
this.name = "AnkiTimeoutError";
}
}
export class AnkiApiError extends Error {
constructor(
message: string,
public code?: string,
) {
super(message);
this.name = "AnkiApiError";
}
}
/**
* Configuration for the Anki client
*/
export interface AnkiConfig {
ankiConnectUrl: string;
apiVersion: number;
timeout: number;
retryTimeout: number;
defaultDeck: string;
}
/**
* Default configuration
*/
export const DEFAULT_CONFIG: AnkiConfig = {
ankiConnectUrl: "http://localhost:8765",
apiVersion: 6 as const,
timeout: 5000,
retryTimeout: 10000,
defaultDeck: "Default",
};
/**
* Anti-corruption layer for yanki-connect
* Provides a stable interface to interact with Anki
*/
export class AnkiClient {
private client: YankiConnect;
private config: AnkiConfig;
/**
* Create a new AnkiClient
*
* @param config Optional configuration
*/
constructor(config: Partial<AnkiConfig> = {}) {
this.config = { ...DEFAULT_CONFIG, ...config };
this.client = new YankiConnect();
}
/**
* Execute a request with retry logic
*
* @param operation Function to execute
* @param maxRetries Maximum number of retries
* @returns Promise with the result
*/
private async executeWithRetry<T>(
operation: () => Promise<T>,
maxRetries = 1,
): Promise<T> {
let lastError: Error | null = null;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
return await operation();
} catch (error) {
lastError = this.normalizeError(error);
// Don't wait on the last attempt
if (attempt < maxRetries) {
// Exponential backoff
const delay = Math.min(
1000 * Math.pow(2, attempt),
this.config.retryTimeout,
);
await new Promise((resolve) => setTimeout(resolve, delay));
}
}
}
// If we get here, all attempts failed
throw lastError || new AnkiConnectionError("Unknown error occurred");
}
/**
* Normalize errors from yanki-connect
*/
private normalizeError(error: unknown): Error {
if (error instanceof Error) {
// Connection errors
if (error.message.includes("ECONNREFUSED")) {
return new AnkiConnectionError(
"Anki is not running. Please start Anki and ensure AnkiConnect plugin is enabled.",
);
}
// Timeout errors
if (
error.message.includes("timeout") ||
error.message.includes("ETIMEDOUT")
) {
return new AnkiTimeoutError(
"Connection to Anki timed out. Please check if Anki is responsive.",
);
}
// API errors
if (error.message.includes("collection unavailable")) {
return new AnkiApiError(
"Anki collection is unavailable. Please close any open dialogs in Anki.",
);
}
return error;
}
return new Error(String(error));
}
/**
* Convert client errors to MCP errors
*/
private wrapError(error: Error): McpError {
if (error instanceof AnkiConnectionError) {
return new McpError(ErrorCode.InternalError, error.message);
}
if (error instanceof AnkiTimeoutError) {
return new McpError(ErrorCode.InternalError, error.message);
}
if (error instanceof AnkiApiError) {
return new McpError(ErrorCode.InternalError, error.message);
}
return new McpError(
ErrorCode.InternalError,
`Anki error: ${error.message}`,
);
}
/**
* Check if Anki is available
*/
async checkConnection(): Promise<boolean> {
try {
// Use a direct axios call to check connection since version() is private
await this.executeWithRetry(() =>
// @ts-ignore - yanki-connect type definitions are incomplete
this.client.invoke("version"),
);
return true;
} catch (error) {
throw this.wrapError(
error instanceof Error ? error : new Error(String(error)),
);
}
}
/**
* Get all deck names
*/
async getDeckNames(): Promise<string[]> {
try {
return await this.executeWithRetry(() => this.client.deck.deckNames());
} catch (error) {
throw this.wrapError(
error instanceof Error ? error : new Error(String(error)),
);
}
}
/**
* Create a new deck
*/
async createDeck(name: string): Promise<number> {
try {
const result = await this.executeWithRetry(() =>
this.client.deck.createDeck({ deck: name }),
);
// Convert to number if needed
return typeof result === "number" ? result : 0;
} catch (error) {
throw this.wrapError(
error instanceof Error ? error : new Error(String(error)),
);
}
}
/**
* Get all model names
*/
async getModelNames(): Promise<string[]> {
try {
return await this.executeWithRetry(() => this.client.model.modelNames());
} catch (error) {
throw this.wrapError(
error instanceof Error ? error : new Error(String(error)),
);
}
}
/**
* Get field names for a model
*/
async getModelFieldNames(modelName: string): Promise<string[]> {
try {
return await this.executeWithRetry(() =>
this.client.model.modelFieldNames({ modelName }),
);
} catch (error) {
throw this.wrapError(
error instanceof Error ? error : new Error(String(error)),
);
}
}
/**
* Get templates for a model
*/
async getModelTemplates(
modelName: string,
): Promise<Record<string, { Front: string; Back: string }>> {
try {
return await this.executeWithRetry(() =>
this.client.model.modelTemplates({ modelName }),
);
} catch (error) {
throw this.wrapError(
error instanceof Error ? error : new Error(String(error)),
);
}
}
/**
* Get styling for a model
*/
async getModelStyling(modelName: string): Promise<{ css: string }> {
try {
return await this.executeWithRetry(() =>
this.client.model.modelStyling({ modelName }),
);
} catch (error) {
throw this.wrapError(
error instanceof Error ? error : new Error(String(error)),
);
}
}
/**
* Create a new note
*/
async addNote(params: {
deckName: string;
modelName: string;
fields: Record<string, string>;
tags?: string[];
}): Promise<number | null> {
try {
return await this.executeWithRetry(() =>
this.client.note.addNote({
note: {
deckName: params.deckName,
modelName: params.modelName,
fields: params.fields,
tags: params.tags || [],
options: {
allowDuplicate: false,
duplicateScope: "deck",
},
},
}),
);
} catch (error) {
throw this.wrapError(
error instanceof Error ? error : new Error(String(error)),
);
}
}
/**
* Add multiple notes
*/
async addNotes(
notes: {
deckName: string;
modelName: string;
fields: Record<string, string>;
tags?: string[];
}[],
): Promise<(string | null)[] | null> {
try {
return await this.executeWithRetry(() =>
this.client.note.addNotes({
notes: notes.map((note) => ({
deckName: note.deckName,
modelName: note.modelName,
fields: note.fields,
tags: note.tags || [],
options: {
allowDuplicate: false,
duplicateScope: "deck",
},
})),
}),
);
} catch (error) {
throw this.wrapError(
error instanceof Error ? error : new Error(String(error)),
);
}
}
/**
* Find notes by query
*/
async findNotes(query: string): Promise<number[]> {
try {
const result = await this.executeWithRetry(() =>
this.client.note.findNotes({ query }),
);
// Ensure we return an array of numbers
return (
Array.isArray(result)
? result.filter((id) => typeof id === "number")
: []
) as number[];
} catch (error) {
throw this.wrapError(
error instanceof Error ? error : new Error(String(error)),
);
}
}
/**
* Get note info
*/
async notesInfo(ids: number[]): Promise<
{
noteId: number;
modelName: string;
tags: string[];
fields: Record<string, { value: string; order: number }>;
}[]
> {
try {
const result = await this.executeWithRetry(() =>
this.client.note.notesInfo({ notes: ids }),
);
// Ensure we return a valid array
return (Array.isArray(result) ? result : []) as {
noteId: number;
modelName: string;
tags: string[];
fields: Record<string, { value: string; order: number }>;
}[];
} catch (error) {
throw this.wrapError(
error instanceof Error ? error : new Error(String(error)),
);
}
}
/**
* Update note fields
*/
async updateNoteFields(params: {
id: number;
fields: Record<string, string>;
}): Promise<void> {
try {
await this.executeWithRetry(() =>
this.client.note.updateNoteFields({
note: {
id: params.id,
fields: params.fields,
},
}),
);
} catch (error) {
throw this.wrapError(
error instanceof Error ? error : new Error(String(error)),
);
}
}
/**
* Delete notes
*/
async deleteNotes(ids: number[]): Promise<void> {
try {
await this.executeWithRetry(() =>
this.client.note.deleteNotes({ notes: ids }),
);
} catch (error) {
throw this.wrapError(
error instanceof Error ? error : new Error(String(error)),
);
}
}
/**
* Create a new model
*/
async createModel(params: {
modelName: string;
inOrderFields: string[];
css: string;
cardTemplates: {
name: string;
front: string;
back: string;
}[];
}): Promise<void> {
try {
// Convert to the format expected by yanki-connect
const convertedTemplates = params.cardTemplates.map((template) => ({
name: template.name,
Front: template.front,
Back: template.back,
}));
await this.executeWithRetry(() =>
this.client.model.createModel({
modelName: params.modelName,
inOrderFields: params.inOrderFields,
css: params.css,
cardTemplates: convertedTemplates,
}),
);
} catch (error) {
throw this.wrapError(
error instanceof Error ? error : new Error(String(error)),
);
}
}
}