anki-mcp-server
Version:
A Model Context Protocol (MCP) server that enables LLMs to interact with Anki flashcard software through AnkiConnect
3 lines (2 loc) • 19.5 kB
JavaScript
"use strict";var I=require("@modelcontextprotocol/sdk/server/index.js"),P=require("@modelcontextprotocol/sdk/server/stdio.js"),a=require("@modelcontextprotocol/sdk/types.js");var d=require("@modelcontextprotocol/sdk/types.js");var x=require("yanki-connect"),l=require("@modelcontextprotocol/sdk/types.js"),y=class extends Error{constructor(e){super(e),this.name="AnkiConnectionError"}},g=class extends Error{constructor(e){super(e),this.name="AnkiTimeoutError"}},w=class extends Error{constructor(t,i){super(t);this.code=i;this.name="AnkiApiError"}},M={ankiConnectUrl:"http://localhost:8765",apiVersion:6,timeout:5e3,retryTimeout:1e4,defaultDeck:"Default"},p=class{constructor(e={}){this.config={...M,...e},this.client=new x.YankiConnect}async executeWithRetry(e,t=1){let i=null;for(let r=0;r<=t;r++)try{return await e()}catch(s){if(i=this.normalizeError(s),r<t){let c=Math.min(1e3*Math.pow(2,r),this.config.retryTimeout);await new Promise(o=>setTimeout(o,c))}}throw i||new y("Unknown error occurred")}normalizeError(e){return e instanceof Error?e.message.includes("ECONNREFUSED")?new y("Anki is not running. Please start Anki and ensure AnkiConnect plugin is enabled."):e.message.includes("timeout")||e.message.includes("ETIMEDOUT")?new g("Connection to Anki timed out. Please check if Anki is responsive."):e.message.includes("collection unavailable")?new w("Anki collection is unavailable. Please close any open dialogs in Anki."):e:new Error(String(e))}wrapError(e){return e instanceof y?new l.McpError(l.ErrorCode.InternalError,e.message):e instanceof g?new l.McpError(l.ErrorCode.InternalError,e.message):e instanceof w?new l.McpError(l.ErrorCode.InternalError,e.message):new l.McpError(l.ErrorCode.InternalError,`Anki error: ${e.message}`)}async checkConnection(){try{return await this.executeWithRetry(()=>this.client.invoke("version")),!0}catch(e){throw this.wrapError(e instanceof Error?e:new Error(String(e)))}}async getDeckNames(){try{return await this.executeWithRetry(()=>this.client.deck.deckNames())}catch(e){throw this.wrapError(e instanceof Error?e:new Error(String(e)))}}async createDeck(e){try{let t=await this.executeWithRetry(()=>this.client.deck.createDeck({deck:e}));return typeof t=="number"?t:0}catch(t){throw this.wrapError(t instanceof Error?t:new Error(String(t)))}}async getModelNames(){try{return await this.executeWithRetry(()=>this.client.model.modelNames())}catch(e){throw this.wrapError(e instanceof Error?e:new Error(String(e)))}}async getModelFieldNames(e){try{return await this.executeWithRetry(()=>this.client.model.modelFieldNames({modelName:e}))}catch(t){throw this.wrapError(t instanceof Error?t:new Error(String(t)))}}async getModelTemplates(e){try{return await this.executeWithRetry(()=>this.client.model.modelTemplates({modelName:e}))}catch(t){throw this.wrapError(t instanceof Error?t:new Error(String(t)))}}async getModelStyling(e){try{return await this.executeWithRetry(()=>this.client.model.modelStyling({modelName:e}))}catch(t){throw this.wrapError(t instanceof Error?t:new Error(String(t)))}}async addNote(e){try{return await this.executeWithRetry(()=>this.client.note.addNote({note:{deckName:e.deckName,modelName:e.modelName,fields:e.fields,tags:e.tags||[],options:{allowDuplicate:!1,duplicateScope:"deck"}}}))}catch(t){throw this.wrapError(t instanceof Error?t:new Error(String(t)))}}async addNotes(e){try{return await this.executeWithRetry(()=>this.client.note.addNotes({notes:e.map(t=>({deckName:t.deckName,modelName:t.modelName,fields:t.fields,tags:t.tags||[],options:{allowDuplicate:!1,duplicateScope:"deck"}}))}))}catch(t){throw this.wrapError(t instanceof Error?t:new Error(String(t)))}}async findNotes(e){try{let t=await this.executeWithRetry(()=>this.client.note.findNotes({query:e}));return Array.isArray(t)?t.filter(i=>typeof i=="number"):[]}catch(t){throw this.wrapError(t instanceof Error?t:new Error(String(t)))}}async notesInfo(e){try{let t=await this.executeWithRetry(()=>this.client.note.notesInfo({notes:e}));return Array.isArray(t)?t:[]}catch(t){throw this.wrapError(t instanceof Error?t:new Error(String(t)))}}async updateNoteFields(e){try{await this.executeWithRetry(()=>this.client.note.updateNoteFields({note:{id:e.id,fields:e.fields}}))}catch(t){throw this.wrapError(t instanceof Error?t:new Error(String(t)))}}async deleteNotes(e){try{await this.executeWithRetry(()=>this.client.note.deleteNotes({notes:e}))}catch(t){throw this.wrapError(t instanceof Error?t:new Error(String(t)))}}async createModel(e){try{let t=e.cardTemplates.map(i=>({name:i.name,Front:i.front,Back:i.back}));await this.executeWithRetry(()=>this.client.model.createModel({modelName:e.modelName,inOrderFields:e.inOrderFields,css:e.css,cardTemplates:t}))}catch(t){throw this.wrapError(t instanceof Error?t:new Error(String(t)))}}};var k=class{constructor(){this.ankiClient=new p,this.modelSchemaCache=new Map,this.allModelSchemasCache=null,this.cacheExpiry=5*60*1e3,this.lastCacheUpdate=0}async listResources(){return await this.ankiClient.checkConnection(),{resources:[{uri:"anki://decks/all",name:"All Decks",description:"List of all available decks in Anki",mimeType:"application/json"}]}}async listResourceTemplates(){return await this.ankiClient.checkConnection(),{resourceTemplates:[{uriTemplate:"anki://note-types/{modelName}",name:"Note Type Schema",description:"Detailed structure information for a specific note type",mimeType:"application/json"},{uriTemplate:"anki://note-types/all",name:"All Note Types",description:"List of all available note types",mimeType:"application/json"},{uriTemplate:"anki://note-types/all-with-schemas",name:"All Note Types with Schemas",description:"Detailed structure information for all note types",mimeType:"application/json"},{uriTemplate:"anki://decks/all",name:"All Decks",description:"Complete list of available decks",mimeType:"application/json"}]}}async readResource(e){if(await this.ankiClient.checkConnection(),e==="anki://decks/all"){let i=await this.ankiClient.getDeckNames();return{contents:[{uri:e,mimeType:"application/json",text:JSON.stringify({decks:i,count:i.length},null,2)}]}}if(e==="anki://note-types/all"){let i=await this.ankiClient.getModelNames();return{contents:[{uri:e,mimeType:"application/json",text:JSON.stringify({noteTypes:i,count:i.length},null,2)}]}}if(e==="anki://note-types/all-with-schemas"){let i=await this.getAllModelSchemas();return{contents:[{uri:e,mimeType:"application/json",text:JSON.stringify({noteTypes:i,count:i.length},null,2)}]}}let t=e.match(/^anki:\/\/note-types\/(.+)$/);if(t){let i=decodeURIComponent(t[1]);try{let r=await this.getModelSchema(i);return{contents:[{uri:e,mimeType:"application/json",text:JSON.stringify({modelName:r.modelName,fields:r.fields,templates:r.templates,css:r.css,createTool:`create_${i.replace(/\s+/g,"_")}_note`},null,2)}]}}catch{throw new d.McpError(d.ErrorCode.InvalidParams,`Note type '${i}' does not exist`)}}throw new d.McpError(d.ErrorCode.InvalidParams,`Unknown resource: ${e}`)}async getModelSchema(e){if(!e)throw new d.McpError(d.ErrorCode.InvalidParams,"Model name is required");let t=Date.now(),i=this.modelSchemaCache.get(e);if(i&&t-this.lastCacheUpdate<this.cacheExpiry)return i;if(!(await this.ankiClient.getModelNames()).includes(e))throw new d.McpError(d.ErrorCode.InvalidParams,`Note type not found: ${e}`);let[s,c,o]=await Promise.all([this.ankiClient.getModelFieldNames(e),this.ankiClient.getModelTemplates(e),this.ankiClient.getModelStyling(e)]),h={modelName:e,fields:s,templates:c,css:o.css};return this.modelSchemaCache.set(e,h),this.lastCacheUpdate=t,h}async getAllModelSchemas(){let e=Date.now();if(this.allModelSchemasCache&&e-this.lastCacheUpdate<this.cacheExpiry)return this.allModelSchemasCache;let t=await this.ankiClient.getModelNames(),i=await Promise.all(t.map(r=>this.getModelSchema(r)));return this.allModelSchemasCache=i,this.lastCacheUpdate=e,i}clearCache(){this.modelSchemaCache.clear(),this.allModelSchemasCache=null,this.lastCacheUpdate=0}};var n=require("@modelcontextprotocol/sdk/types.js");var N=class{constructor(){this.ankiClient=new p}async getToolSchema(){return{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:"get_note_type_info",description:"Get detailed structure of a note type",inputSchema:{type:"object",properties:{modelName:{type:"string",description:"Name of the note type/model"}},required:["modelName"]}},{name:"create_note",description:"Create a new note (LLM Should get note type info first)",inputSchema:{type:"object",properties:{type:{type:"string",description:"Note type"},deck:{type:"string",description:"Deck name"},fields:{type:"object",description:"Custom fields for the note(get note type info first)",additionalProperties:!0},tags:{type:"array",items:{type:"string"},description:"Tags for the note"}},required:["type","deck","fields"]}},{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"},fields:{type:"object",additionalProperties:!0},tags:{type:"array",items:{type:"string"}}},required:["type","deck","fields"]}},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"]}}]}}async executeTool(e,t){await this.ankiClient.checkConnection();try{switch(e){case"list_decks":return this.listDecks();case"create_deck":return this.createDeck(t);case"list_note_types":return this.listNoteTypes();case"create_note_type":return this.createNoteType(t);case"get_note_type_info":return this.getNoteTypeInfo(t);case"create_note":return this.createNote(t);case"batch_create_notes":return this.batchCreateNotes(t);case"search_notes":return this.searchNotes(t);case"get_note_info":return this.getNoteInfo(t);case"update_note":return this.updateNote(t);case"delete_note":return this.deleteNote(t);default:let i=e.match(/^create_(.+)_note$/);if(i){let r=i[1].replace(/_/g," ");return this.createModelSpecificNote(r,t)}throw new n.McpError(n.ErrorCode.MethodNotFound,`Unknown tool: ${e}`)}}catch(i){if(i instanceof n.McpError)throw i;return{content:[{type:"text",text:`Error: ${i instanceof Error?i.message:String(i)}`}],isError:!0}}}async listDecks(){let e=await this.ankiClient.getDeckNames();return{content:[{type:"text",text:JSON.stringify({decks:e,count:e.length},null,2)}]}}async createDeck(e){if(!e.name)throw new n.McpError(n.ErrorCode.InvalidParams,"Deck name is required");let t=await this.ankiClient.createDeck(e.name);return{content:[{type:"text",text:JSON.stringify({deckId:t,name:e.name},null,2)}]}}async listNoteTypes(){let e=await this.ankiClient.getModelNames();return{content:[{type:"text",text:JSON.stringify({noteTypes:e,count:e.length},null,2)}]}}async createNoteType(e){if(!e.name)throw new n.McpError(n.ErrorCode.InvalidParams,"Note type name is required");if(!e.fields||e.fields.length===0)throw new n.McpError(n.ErrorCode.InvalidParams,"Fields are required");if(!e.templates||e.templates.length===0)throw new n.McpError(n.ErrorCode.InvalidParams,"Templates are required");if((await this.ankiClient.getModelNames()).includes(e.name))throw new n.McpError(n.ErrorCode.InvalidParams,`Note type already exists: ${e.name}`);return await this.ankiClient.createModel({modelName:e.name,inOrderFields:e.fields,css:e.css||"",cardTemplates:e.templates}),{content:[{type:"text",text:JSON.stringify({success:!0,modelName:e.name,fields:e.fields,templates:e.templates.length},null,2)}]}}async getNoteTypeInfo(e){if(!e.modelName)throw new n.McpError(n.ErrorCode.InvalidParams,"Model name is required");if(!(await this.ankiClient.getModelNames()).includes(e.modelName))throw new n.McpError(n.ErrorCode.InvalidParams,`Note type not found: ${e.modelName}`);let[i,r,s]=await Promise.all([this.ankiClient.getModelFieldNames(e.modelName),this.ankiClient.getModelTemplates(e.modelName),this.ankiClient.getModelStyling(e.modelName)]);return{content:[{type:"text",text:JSON.stringify({modelName:e.modelName,fields:i,templates:r,css:s.css},null,2)}]}}async createNote(e){if(!e.type)throw new n.McpError(n.ErrorCode.InvalidParams,"Note type is required");if(!e.deck)throw new n.McpError(n.ErrorCode.InvalidParams,"Deck name is required");if(!e.fields||Object.keys(e.fields).length===0)throw new n.McpError(n.ErrorCode.InvalidParams,"Fields are required");if((await this.ankiClient.getDeckNames()).includes(e.deck)||await this.ankiClient.createDeck(e.deck),!(await this.ankiClient.getModelNames()).includes(e.type))throw new n.McpError(n.ErrorCode.InvalidParams,`Note type not found: ${e.type}`);let r=await this.ankiClient.getModelFieldNames(e.type);for(let o of r)if(!e.fields[o]&&!e.fields[o.toLowerCase()])throw new n.McpError(n.ErrorCode.InvalidParams,`Missing required field: ${o}`);let s={};for(let o of r)s[o]=e.fields[o]||e.fields[o.toLowerCase()]||"";let c=await this.ankiClient.addNote({deckName:e.deck,modelName:e.type,fields:s,tags:e.tags||[]});return{content:[{type:"text",text:JSON.stringify({noteId:c,deck:e.deck,modelName:e.type},null,2)}]}}async createModelSpecificNote(e,t){if(!t.deck)throw new n.McpError(n.ErrorCode.InvalidParams,"Deck name is required");if(!(await this.ankiClient.getModelNames()).includes(e))throw new n.McpError(n.ErrorCode.InvalidParams,`Note type not found: ${e}`);(await this.ankiClient.getDeckNames()).includes(t.deck)||await this.ankiClient.createDeck(t.deck);let s=await this.ankiClient.getModelFieldNames(e),c={};for(let u of s){let S=t[u.toLowerCase()]||t[u]||"";c[u]=S}let o=Array.isArray(t.tags)?t.tags:[],h=await this.ankiClient.addNote({deckName:t.deck,modelName:e,fields:c,tags:o});return{content:[{type:"text",text:JSON.stringify({noteId:h,deck:t.deck,modelName:e},null,2)}]}}async batchCreateNotes(e){if(!e.notes||!Array.isArray(e.notes)||e.notes.length===0)throw new n.McpError(n.ErrorCode.InvalidParams,"Notes array is required");let t=[],i=e.stopOnError!==!1;for(let r=0;r<e.notes.length;r++){let s=e.notes[r];try{if((await this.ankiClient.getDeckNames()).includes(s.deck)||await this.ankiClient.createDeck(s.deck),!(await this.ankiClient.getModelNames()).includes(s.type))throw new Error(`Note type not found: ${s.type}`);let h=await this.ankiClient.getModelFieldNames(s.type),u={};for(let v of h)u[v]=s.fields[v]||s.fields[v.toLowerCase()]||"";let S=await this.ankiClient.addNote({deckName:s.deck,modelName:s.type,fields:u,tags:s.tags||[]});t.push({success:!0,noteId:S,index:r})}catch(c){if(t.push({success:!1,error:c instanceof Error?c.message:String(c),index:r}),i)break}}return{content:[{type:"text",text:JSON.stringify({results:t,total:e.notes.length,successful:t.filter(r=>r.success).length,failed:t.filter(r=>!r.success).length},null,2)}]}}async searchNotes(e){if(!e.query)throw new n.McpError(n.ErrorCode.InvalidParams,"Search query is required");let t=await this.ankiClient.findNotes(e.query),i=[];if(t.length>0){let r=Math.min(t.length,50);i=await this.ankiClient.notesInfo(t.slice(0,r))}return{content:[{type:"text",text:JSON.stringify({query:e.query,total:t.length,notes:i,limitApplied:t.length>50},null,2)}]}}async getNoteInfo(e){if(!e.noteId)throw new n.McpError(n.ErrorCode.InvalidParams,"Note ID is required");let t=await this.ankiClient.notesInfo([e.noteId]);if(!t||t.length===0)throw new n.McpError(n.ErrorCode.InvalidParams,`Note not found: ${e.noteId}`);return{content:[{type:"text",text:JSON.stringify(t[0],null,2)}]}}async updateNote(e){if(!e.id)throw new n.McpError(n.ErrorCode.InvalidParams,"Note ID is required");if(!e.fields||Object.keys(e.fields).length===0)throw new n.McpError(n.ErrorCode.InvalidParams,"Fields are required");let t=await this.ankiClient.notesInfo([e.id]);if(!t||t.length===0)throw new n.McpError(n.ErrorCode.InvalidParams,`Note not found: ${e.id}`);return await this.ankiClient.updateNoteFields({id:e.id,fields:e.fields}),{content:[{type:"text",text:JSON.stringify({success:!0,noteId:e.id},null,2)}]}}async deleteNote(e){if(!e.noteId)throw new n.McpError(n.ErrorCode.InvalidParams,"Note ID is required");return await this.ankiClient.deleteNotes([e.noteId]),{content:[{type:"text",text:JSON.stringify({success:!0,noteId:e.noteId},null,2)}]}}};var E="0.1.4";var C=class{constructor(){this.server=new I.Server({name:"anki-connect-server",version:E},{capabilities:{tools:{},resources:{}}}),this.ankiClient=new p,this.resourceHandler=new k,this.toolHandler=new N,this.setupHandlers(),this.server.onerror=e=>console.error("[MCP Error]",e),process.on("SIGINT",async()=>{await this.server.close(),process.exit(0)})}setupHandlers(){this.server.setRequestHandler(a.ListResourcesRequestSchema,async()=>(await this.checkConnection(),this.resourceHandler.listResources())),this.server.setRequestHandler(a.ListResourceTemplatesRequestSchema,async()=>(await this.checkConnection(),this.resourceHandler.listResourceTemplates())),this.server.setRequestHandler(a.ReadResourceRequestSchema,async e=>(await this.checkConnection(),this.resourceHandler.readResource(e.params.uri))),this.server.setRequestHandler(a.ListToolsRequestSchema,async()=>(await this.checkConnection(),this.toolHandler.getToolSchema())),this.server.setRequestHandler(a.CallToolRequestSchema,async e=>(await this.checkConnection(),this.toolHandler.executeTool(e.params.name,e.params.arguments)))}async checkConnection(){try{await this.ankiClient.checkConnection()}catch{throw new a.McpError(a.ErrorCode.InternalError,"Failed to connect to Anki. Please make sure Anki is running and the AnkiConnect plugin is enabled.")}}async run(){let e=new P.StdioServerTransport;await this.server.connect(e),console.error("Anki MCP server running on stdio")}};async function b(){try{await new C().run()}catch(m){console.error("Failed to start Anki MCP Server:",m),process.exit(1)}}b().catch(console.error);