index.ts•15.1 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,
McpError,
} from "@modelcontextprotocol/sdk/types.js";
import axios from "axios";
/**
* Configuration for the Anki MCP Server
*/
const config = {
// Anki Connect API URL
ankiConnectUrl: "http://localhost:8765",
// API version for Anki Connect
apiVersion: 6,
// Default deck name if none is specified
defaultDeckName: "Default",
};
/**
* Type for Anki Connect API response
*/
type AnkiResponse<T> = {
result: T;
error: string | null;
};
/**
* Make a request to the Anki Connect API
* @param action - The action to perform
* @param params - Parameters for the action
* @returns Promise with the API response
*/
async function ankiRequest<T>(action: string, params: any = {}): Promise<T> {
try {
const response = await axios.post(config.ankiConnectUrl, {
action,
version: config.apiVersion,
params,
});
const data = response.data as AnkiResponse<T>;
if (data.error) {
throw new Error(`Anki Connect API error: ${data.error}`);
}
return data.result;
} catch (error) {
if (error instanceof Error) {
throw error;
} else {
throw new Error(`Unknown error: ${error}`);
}
}
}
/**
* Check if Anki is running and connected
* @returns Promise<boolean> - True if connected, false otherwise
*/
async function isAnkiConnected(): Promise<boolean> {
try {
await ankiRequest<string>("version");
return true;
} catch (error) {
return false;
}
}
/**
* Create the MCP server
*/
const server = new Server({
name: "anki-mcp",
version: "1.0.0",
capabilities: {
resources: {},
tools: {},
},
});
/**
* Definition of parameters for creating a note
*/
const noteParameters = {
type: "object",
properties: {
deckName: {
type: "string",
description: "Name of the deck to add note to (will be created if it doesn't exist)",
},
modelName: {
type: "string",
description: "Name of the note model/type to use",
},
fields: {
type: "object",
description: "Map of fields to the value in the note model being used",
},
tags: {
type: "array",
items: {
type: "string",
},
description: "Tags to apply to the note",
},
options: {
type: "object",
description: "Additional options for note creation",
properties: {
allowDuplicate: {
type: "boolean",
description: "Allow duplicate notes",
},
duplicateScope: {
type: "string",
description: "Scope for duplicate check",
},
duplicateScopeOptions: {
type: "object",
description: "Additional options for duplicate scope",
},
},
},
},
required: ["deckName", "modelName", "fields"],
};
/**
* Definition of parameters for creating a deck
*/
const deckParameters = {
type: "object",
properties: {
name: {
type: "string",
description: "Name of the deck to create",
},
options: {
type: "object",
description: "Additional options for deck creation",
},
},
required: ["name"],
};
/**
* Definition of parameters for adding multiple notes at once
*/
const bulkNotesParameters = {
type: "object",
properties: {
notes: {
type: "array",
items: noteParameters,
description: "Notes to create",
},
},
required: ["notes"],
};
/**
* Definition of parameters for retrieving a model
*/
const modelParameters = {
type: "object",
properties: {
modelName: {
type: "string",
description: "Name of the model to get",
},
},
required: ["modelName"],
};
/**
* Definition of parameters for searching notes
*/
const searchParameters = {
type: "object",
properties: {
query: {
type: "string",
description: "Search query in Anki format",
},
},
required: ["query"],
};
/**
* Handler for listing available resources
*/
server.setRequestHandler(ListResourcesRequestSchema, async () => {
// Check if Anki is connected
const connected = await isAnkiConnected();
if (!connected) {
throw new Error("Cannot connect to Anki. Is Anki running with AnkiConnect plugin installed?");
}
// Get all decks
const decks = await ankiRequest<Record<string, number>>("deckNamesAndIds");
// Get all note models
const models = await ankiRequest<Record<string, number>>("modelNamesAndIds");
// Format decks as resources
const deckResources = Object.entries(decks).map(([name, id]) => ({
uri: `anki://decks/${id}`,
name,
}));
// Format models as resources
const modelResources = Object.entries(models).map(([name, id]) => ({
uri: `anki://models/${id}`,
name,
}));
return {
resources: [...deckResources, ...modelResources],
};
});
/**
* Handler for reading a specific resource
*/
server.setRequestHandler(ReadResourceRequestSchema, async (resource) => {
const uri = resource.params.uri;
// Handle deck resources
if (uri.startsWith("anki://decks/")) {
const deckId = parseInt(uri.replace("anki://decks/", ""));
const decks = await ankiRequest<Record<string, number>>("deckNamesAndIds");
// Find the deck name from ID
const deckName = Object.entries(decks).find(([_, id]) => id === deckId)?.[0];
if (!deckName) {
throw new Error("Deck not found");
}
// Get cards in deck
const cardIds = await ankiRequest<number[]>("findCards", {
query: `deck:"${deckName}"`,
});
// Get notes for those cards
const noteIds = await ankiRequest<number[]>("cardsToNotes", {
cards: cardIds,
});
// Get info about the notes
const notes = await ankiRequest<any[]>("notesInfo", {
notes: noteIds,
});
return {
contents: [
{
uri,
mimeType: "application/json",
text: JSON.stringify({
name: deckName,
id: deckId,
cards: cardIds.length,
notes: notes.length,
sampleNotes: notes.slice(0, 5),
}),
},
],
};
}
// Handle model resources
else if (uri.startsWith("anki://models/")) {
const modelId = parseInt(uri.replace("anki://models/", ""));
const models = await ankiRequest<Record<string, number>>("modelNamesAndIds");
// Find the model name from ID
const modelName = Object.entries(models).find(([_, id]) => id === modelId)?.[0];
if (!modelName) {
throw new Error("Model not found");
}
// Get model details
const modelFields = await ankiRequest<string[]>("modelFieldNames", {
modelName,
});
const modelTemplates = await ankiRequest<string[]>("modelTemplates", {
modelName,
});
return {
contents: [
{
uri,
mimeType: "application/json",
text: JSON.stringify({
name: modelName,
id: modelId,
fields: modelFields,
templates: modelTemplates,
}),
},
],
};
}
throw new Error("Resource not found");
});
/**
* Handler for listing available tools
*/
server.setRequestHandler(ListToolsRequestSchema, async () => {
// Check if Anki is connected
const connected = await isAnkiConnected();
if (!connected) {
throw new Error("Cannot connect to Anki. Is Anki running with AnkiConnect plugin installed?");
}
return {
tools: [
{
name: "listDecks",
description: "Get a list of all decks in Anki",
inputSchema: { type: "object", properties: {} },
},
{
name: "listModels",
description: "Get a list of all note models/types in Anki",
inputSchema: { type: "object", properties: {} },
},
{
name: "createDeck",
description: "Create a new deck in Anki",
inputSchema: deckParameters,
},
{
name: "getModel",
description: "Get details about a specific note model/type",
inputSchema: modelParameters,
},
{
name: "addNote",
description: "Add a single note to a deck",
inputSchema: noteParameters,
},
{
name: "addNotes",
description: "Add multiple notes at once",
inputSchema: bulkNotesParameters,
},
{
name: "searchNotes",
description: "Search for notes using Anki's search syntax",
inputSchema: searchParameters,
},
],
};
});
/**
* Handler for tool execution
*/
server.setRequestHandler(CallToolRequestSchema, async (request) => {
switch (request.params.name) {
case "listDecks": {
const decks = await ankiRequest<string[]>("deckNames");
return {
toolResult: `Decks in Anki: ${decks.join(", ")}`,
};
}
case "listModels": {
const models = await ankiRequest<string[]>("modelNames");
return {
toolResult: `Note models in Anki: ${models.join(", ")}`,
};
}
case "createDeck": {
if (!request.params.arguments) {
throw new Error("Missing arguments for createDeck");
}
const { name } = request.params.arguments;
const deckId = await ankiRequest<number>("createDeck", {
deck: name,
});
return {
toolResult: `Created deck "${name}" with ID: ${deckId}`,
};
}
case "getModel": {
if (!request.params.arguments || !request.params.arguments.modelName) {
throw new Error("Model name is required");
}
const modelName = request.params.arguments.modelName;
// Get model fields
const fields = await ankiRequest<string[]>("modelFieldNames", {
modelName,
});
// Get model templates
const templates = await ankiRequest<Record<string, Record<string, string>>>("modelTemplates", {
modelName,
});
// Format the response
return {
toolResult: JSON.stringify({
name: modelName,
fields,
templates: Object.keys(templates),
}, null, 2),
};
}
case "addNote": {
if (!request.params.arguments) {
throw new Error("Missing arguments for addNote");
}
const { deckName = config.defaultDeckName, modelName, fields, tags = [], options = {} } = request.params.arguments;
// Ensure the deck exists
await ankiRequest<number>("createDeck", {
deck: deckName,
});
// Add the note
const noteId = await ankiRequest<number>("addNote", {
note: {
deckName,
modelName,
fields,
tags,
options,
},
});
return {
toolResult: `Created note with ID: ${noteId} in deck "${deckName}"`,
};
}
case "addNotes": {
if (!request.params.arguments || !request.params.arguments.notes) {
throw new Error("Notes array is required");
}
const { notes } = request.params.arguments;
// Format the notes array for Anki Connect
const formattedNotes = notes.map(note => ({
deckName: note.deckName || config.defaultDeckName,
modelName: note.modelName,
fields: note.fields,
tags: note.tags || [],
options: note.options || {}
}));
// Ensure all decks exist first
const uniqueDeckNames = [...new Set(formattedNotes.map(note => note.deckName))];
for (const deckName of uniqueDeckNames) {
await ankiRequest<number>("createDeck", {
deck: deckName,
});
}
// Add the notes
const noteIds = await ankiRequest<number[]>("addNotes", {
notes: formattedNotes,
});
// Count successful notes (non-null IDs)
const successCount = noteIds.filter(id => id !== null).length;
return {
toolResult: `Successfully added ${successCount} out of ${notes.length} notes`,
};
}
case "searchNotes": {
if (!request.params.arguments || !request.params.arguments.query) {
throw new Error("Search query is required");
}
const { query } = request.params.arguments;
// Search for notes
const noteIds = await ankiRequest<number[]>("findNotes", {
query,
});
if (noteIds.length === 0) {
return {
toolResult: "No notes found matching the search query",
};
}
// Get note info (limiting to first 10 to avoid overwhelming responses)
const displayLimit = Math.min(noteIds.length, 10);
const notesToDisplay = noteIds.slice(0, displayLimit);
const notesInfo = await ankiRequest<any[]>("notesInfo", {
notes: notesToDisplay,
});
// Format the notes for display
const formattedNotes = notesInfo.map(note => {
const { fields, tags, modelName, cards } = note;
// Format fields to be more readable
const formattedFields = Object.entries(fields).map(([name, value]) => {
// The field value is an object with text and other properties
const fieldValue = typeof value === 'object' ? value.value : value;
return `${name}: ${fieldValue}`;
}).join('\n');
return {
modelName,
fields: formattedFields,
tags,
cardCount: cards.length,
};
});
return {
toolResult: `Found ${noteIds.length} notes matching the query. ${displayLimit < noteIds.length ? `Showing first ${displayLimit}:` : 'Details:'}\n\n${JSON.stringify(formattedNotes, null, 2)}`,
};
}
default:
throw new McpError(`Unknown tool: ${request.params.name}`);
}
});
/**
* Initialize and start the server
*/
async function startServer() {
try {
// Check if Anki is connected
const connected = await isAnkiConnected();
if (!connected) {
console.error("Cannot connect to Anki. Is Anki running with AnkiConnect plugin installed?");
console.error(`Attempted to connect to: ${config.ankiConnectUrl}`);
process.exit(1);
}
// Log available decks and models on startup
const decks = await ankiRequest<string[]>("deckNames");
const models = await ankiRequest<string[]>("modelNames");
console.log("Connected to Anki successfully!");
console.log(`Available decks: ${decks.join(", ")}`);
console.log(`Available note models: ${models.join(", ")}`);
// Start server with stdio transport
const transport = new StdioServerTransport();
server.listen(transport);
console.log("Anki MCP Server is running...");
} catch (error) {
console.error("Failed to start Anki MCP Server:", error);
process.exit(1);
}
}
startServer();