Anki MCP Server
by nailuoGG
- src
#!/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 from 'axios';
import { config } from './config.js';
// Tool argument types
interface ListDecksArgs {
[key: string]: never;
}
interface CreateDeckArgs {
name: string;
}
interface CreateNoteArgs {
type: 'Basic' | 'Cloze';
deck: string;
front?: string;
back?: string;
text?: string;
backExtra?: string;
fields?: Record<string, string>;
tags?: string[];
}
interface BatchCreateNotesArgs {
notes: {
type: 'Basic' | 'Cloze';
deck: string;
front?: string;
back?: string;
text?: string;
backExtra?: string;
fields?: Record<string, string>;
tags?: string[];
}[];
stopOnError?: boolean;
}
interface SearchNotesArgs {
query: string;
}
interface GetNoteInfoArgs {
noteId: number;
}
interface UpdateNoteArgs {
id: number;
fields: Record<string, string>;
tags?: string[];
}
interface DeleteNoteArgs {
noteId: number;
}
interface ListNoteTypesArgs {
[key: string]: never;
}
interface CreateNoteTypeArgs {
name: string;
fields: string[];
css?: string;
templates: {
name: string;
front: string;
back: string;
}[];
}
// Types for Anki operations
interface AnkiRequest {
action: string;
version: number;
params: Record<string, any>;
}
interface AnkiResponse {
result: any;
error: string | null;
}
interface BasicNote {
type: 'Basic';
front: string;
back: string;
tags?: string[];
deck: string;
}
interface ClozeNote {
type: 'Cloze';
text: string;
backExtra?: string;
tags?: string[];
deck: string;
}
interface NoteUpdate {
id: number;
fields: Record<string, string>;
tags?: string[];
}
interface BatchOperation<T> {
operations: T[];
stopOnError?: boolean;
results: {
success: boolean;
error?: string;
data?: any;
}[];
}
function validateArgs<T>(args: Record<string, unknown> | undefined, requiredFields: (keyof T)[]): T {
if (!args) {
throw new McpError(ErrorCode.InvalidParams, 'Arguments are required');
}
for (const field of requiredFields) {
if (!(field in args)) {
throw new McpError(
ErrorCode.InvalidParams,
`Missing required field: ${String(field)}`
);
}
}
return args as T;
}
class AnkiServer {
private server: Server;
private readonly ankiConnectUrl: string = config.ankiConnectUrl;
constructor() {
this.server = new Server(
{
name: 'anki-connect-server',
version: '0.1.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);
});
}
private async invokeAnki(action: string, params: Record<string, any> = {}): Promise<any> {
console.error(`[Anki] Sending request: ${action}`, params);
try {
const requestData = {
action,
version: config.apiVersion,
params,
};
console.error('[Anki] Request data:', JSON.stringify(requestData, null, 2));
const response = await axios.post<AnkiResponse>(this.ankiConnectUrl, requestData, {
headers: config.request.headers,
timeout: config.request.timeout,
validateStatus: null,
maxRedirects: 0,
httpAgent: new (await import('http')).Agent({
keepAlive: true,
maxSockets: 1
})
});
console.error(`[Anki] Response status: ${response.status}`);
console.error('[Anki] Response data:', JSON.stringify(response.data, null, 2));
if (response.status !== 200) {
throw new McpError(
ErrorCode.InternalError,
`Anki returned non-200 status: ${response.status}`
);
}
if (response.data.error) {
throw new McpError(ErrorCode.InternalError, `Anki error: ${response.data.error}`);
}
return response.data.result;
} catch (error) {
console.error('[Anki] Error:', error);
if (axios.isAxiosError(error)) {
if (error.code === 'ECONNREFUSED') {
throw new McpError(
ErrorCode.InternalError,
'Anki is not running. Please start Anki and ensure AnkiConnect plugin is enabled.'
);
}
if (error.code === 'ETIMEDOUT' || error.code === 'ECONNRESET') {
console.error('[Anki] Connection error:', error.code);
// Retry once on timeout or reset
console.error('[Anki] Retrying request...');
try {
// Recreate the request data
const retryRequestData = {
action,
version: config.apiVersion,
params
};
const retryResponse = await axios.post<AnkiResponse>(this.ankiConnectUrl, retryRequestData, {
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'Connection': 'keep-alive'
},
timeout: config.request.retryTimeout,
validateStatus: null,
maxRedirects: 0,
httpAgent: new (await import('http')).Agent({
keepAlive: true,
maxSockets: 1
})
});
console.error('[Anki] Retry successful');
return retryResponse.data.result;
} catch (retryError) {
console.error('[Anki] Retry failed:', retryError);
throw new McpError(
ErrorCode.InternalError,
'Connection to Anki failed after retry. Please check if Anki is running and responsive.'
);
}
}
throw new McpError(
ErrorCode.InternalError,
`Failed to connect to Anki: ${error.message} (${error.code})`
);
}
throw error;
}
}
private async checkAnkiConnection(): Promise<void> {
try {
await this.invokeAnki('version');
} catch (error) {
if (error instanceof McpError) {
throw error;
}
throw new McpError(
ErrorCode.InternalError,
'Failed to connect to Anki. Please ensure Anki is running and AnkiConnect plugin is enabled.'
);
}
}
private setupToolHandlers() {
this.server.setRequestHandler(ListToolsRequestSchema, async () => ({
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: 'create_note',
description: 'Create a new note (Basic or Cloze)',
inputSchema: {
type: 'object',
properties: {
type: {
type: 'string',
enum: ['Basic', 'Cloze'],
description: 'Type of note to create',
},
deck: {
type: 'string',
description: 'Deck name',
},
front: {
type: 'string',
description: 'Front content (for Basic notes)',
},
back: {
type: 'string',
description: 'Back content (for Basic notes)',
},
text: {
type: 'string',
description: 'Cloze text (for Cloze notes)',
},
backExtra: {
type: 'string',
description: 'Additional back content (for Cloze notes)',
},
fields: {
type: 'object',
description: 'Custom fields for the note',
additionalProperties: true
},
tags: {
type: 'array',
items: {
type: 'string',
},
description: 'Tags for the note',
},
},
required: ['type', 'deck'],
},
},
{
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',
},
front: {
type: 'string',
},
back: {
type: 'string',
},
text: {
type: 'string',
},
backExtra: {
type: 'string',
},
fields: {
type: 'object',
additionalProperties: true
},
tags: {
type: 'array',
items: {
type: 'string',
},
},
},
required: ['type', 'deck'],
},
},
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'],
},
},
],
}));
this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
await this.checkAnkiConnection();
switch (request.params.name) {
case 'list_decks':
return {
content: [
{
type: 'text',
text: JSON.stringify(await this.invokeAnki('deckNames'), null, 2),
},
],
};
case 'create_deck': {
const args = validateArgs<CreateDeckArgs>(request.params.arguments, ['name']);
await this.invokeAnki('createDeck', {
deck: args.name,
});
return {
content: [
{
type: 'text',
text: `Created deck: ${args.name}`,
},
],
};
}
case 'create_note': {
const args = validateArgs<CreateNoteArgs>(request.params.arguments, ['type', 'deck']);
const { type, deck, front, back, text, backExtra, fields, tags } = args;
let note;
if (type === 'Basic') {
if (fields) {
note = {
deckName: deck,
modelName: config.noteModels.basic.zh,
fields,
tags: tags || [],
};
} else if (front && back) {
note = {
deckName: deck,
modelName: config.noteModels.basic.zh,
fields: {
正面: front,
背面: back,
},
tags: tags || [],
};
} else {
throw new McpError(ErrorCode.InvalidParams, 'Basic notes require front and back content');
}
} else if (type === 'Cloze') {
if (fields) {
note = {
deckName: deck,
modelName: config.noteModels.cloze.zh,
fields,
tags: tags || [],
};
} else if (text) {
note = {
deckName: deck,
modelName: config.noteModels.cloze.zh,
fields: {
正面: text,
背面: backExtra || '',
},
tags: tags || [],
};
} else {
throw new McpError(ErrorCode.InvalidParams, 'Cloze notes require text content');
}
} else {
throw new McpError(ErrorCode.InvalidParams, 'Invalid note type');
}
const noteId = await this.invokeAnki('addNote', { note });
return {
content: [
{
type: 'text',
text: `Created note with ID: ${noteId}`,
},
],
};
}
case 'batch_create_notes': {
const args = validateArgs<BatchCreateNotesArgs>(request.params.arguments, ['notes']);
const { notes, stopOnError } = args;
const results: BatchOperation<any>['results'] = [];
for (const noteData of notes) {
try {
const note = {
deckName: noteData.deck,
modelName: noteData.type === 'Basic' ? config.noteModels.basic.zh : config.noteModels.cloze.zh,
fields: noteData.fields || (noteData.type === 'Basic'
? { 正面: noteData.front, 背面: noteData.back }
: { 正面: noteData.text, 背面: noteData.backExtra || '' }),
tags: noteData.tags || [],
};
const noteId = await this.invokeAnki('addNote', { note });
results.push({ success: true, data: noteId });
} catch (error) {
results.push({
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
});
if (stopOnError) {
break;
}
}
}
return {
content: [
{
type: 'text',
text: JSON.stringify({ results }, null, 2),
},
],
};
}
case 'search_notes': {
const args = validateArgs<SearchNotesArgs>(request.params.arguments, ['query']);
const noteIds = await this.invokeAnki('findNotes', {
query: args.query,
});
return {
content: [
{
type: 'text',
text: JSON.stringify(noteIds, null, 2),
},
],
};
}
case 'get_note_info': {
const args = validateArgs<GetNoteInfoArgs>(request.params.arguments, ['noteId']);
const info = await this.invokeAnki('notesInfo', {
notes: [args.noteId],
});
return {
content: [
{
type: 'text',
text: JSON.stringify(info[0], null, 2),
},
],
};
}
case 'update_note': {
const args = validateArgs<UpdateNoteArgs>(request.params.arguments, ['id', 'fields']);
const { id, fields, tags } = args;
const note = { id, fields };
if (tags) {
await this.invokeAnki('clearTags', { notes: [id] });
await this.invokeAnki('addTags', {
notes: [id],
tags: tags.join(' '),
});
}
await this.invokeAnki('updateNoteFields', { note });
return {
content: [
{
type: 'text',
text: `Updated note: ${id}`,
},
],
};
}
case 'delete_note': {
const args = validateArgs<DeleteNoteArgs>(request.params.arguments, ['noteId']);
await this.invokeAnki('deleteNotes', {
notes: [args.noteId],
});
return {
content: [
{
type: 'text',
text: `Deleted note: ${args.noteId}`,
},
],
};
}
case 'list_note_types':
return {
content: [
{
type: 'text',
text: JSON.stringify(await this.invokeAnki('modelNames'), null, 2),
},
],
};
case 'create_note_type': {
const args = validateArgs<CreateNoteTypeArgs>(request.params.arguments, ['name', 'fields', 'templates']);
const { name, fields, css, templates } = args;
await this.invokeAnki('createModel', {
modelName: name,
inOrderFields: fields,
css: css || '',
cardTemplates: templates,
});
return {
content: [
{
type: 'text',
text: `Created note type: ${name}`,
},
],
};
}
default:
throw new McpError(
ErrorCode.MethodNotFound,
`Unknown tool: ${request.params.name}`
);
}
});
}
async run() {
const transport = new StdioServerTransport();
await this.server.connect(transport);
console.error('Anki MCP server running on stdio');
}
}
const server = new AnkiServer();
server.run().catch(console.error);