UNPKG

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.8 kB
#!/usr/bin/env node "use strict";var E=require("@modelcontextprotocol/sdk/server/index.js"),b=require("@modelcontextprotocol/sdk/server/stdio.js"),a=require("@modelcontextprotocol/sdk/types.js");var d=require("@modelcontextprotocol/sdk/types.js");var v=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"}},P={ankiConnectUrl:"http://localhost:8765",apiVersion:6,timeout:5e3,retryTimeout:1e4,defaultDeck:"Default"},m=class{constructor(e={}){this.config={...P,...e},this.client=new v.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 o=Math.min(1e3*Math.pow(2,r),this.config.retryTimeout);await new Promise(c=>setTimeout(c,o))}}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(()=>{var t;return this.client.note.addNote({note:{deckName:e.deckName,modelName:e.modelName,fields:e.fields,tags:e.tags||[],options:{allowDuplicate:((t=e.options)==null?void 0:t.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 m,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,o,c]=await Promise.all([this.ankiClient.getModelFieldNames(e),this.ankiClient.getModelTemplates(e),this.ankiClient.getModelStyling(e)]),h={modelName:e,fields:s,templates:o,css:c.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 m}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"},includeCss:{type:"boolean",description:"Whether to include CSS information"}},required:["modelName"]}},{name:"create_note",description:"Create a new note (LLM Should call 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},allowDuplicate:{type:"boolean",description:"Whether to allow duplicate notes"},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 (llm should call get_note_type_info first )",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"]}},allowDuplicate:{type:"boolean",description:"Whether to allow duplicate notes"},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]=await Promise.all([this.ankiClient.getModelFieldNames(e.modelName),this.ankiClient.getModelTemplates(e.modelName)]),s={modelName:e.modelName,fields:i,templates:r};if(e.includeCss){let o=await this.ankiClient.getModelStyling(e.modelName);s.css=o.css}return{content:[{type:"text",text:JSON.stringify(s,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),s={};for(let c of r)s[c]=e.fields[c]||e.fields[c.toLowerCase()]||"";let o=await this.ankiClient.addNote({deckName:e.deck,modelName:e.type,fields:s,tags:e.tags||[],options:{allowDuplicate:e.allowDuplicate||!1}});return{content:[{type:"text",text:JSON.stringify({noteId:o,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),o={};for(let u of s)o[u]=t[u.toLowerCase()]||t[u]||"";let c=Array.isArray(t.tags)?t.tags:[],h=await this.ankiClient.addNote({deckName:t.deck,modelName:e,fields:o,tags:c});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 S of h)u[S]=s.fields[S]||s.fields[S.toLowerCase()]||"";let I=await this.ankiClient.addNote({deckName:s.deck,modelName:s.type,fields:u,tags:s.tags||[],options:{allowDuplicate:e.allowDuplicate||!1}});t.push({success:!0,noteId:I,index:r})}catch(o){if(t.push({success:!1,error:o instanceof Error?o.message:String(o),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 x="0.1.5";var C=class{constructor(){this.server=new E.Server({name:"anki-connect-server",version:x},{capabilities:{tools:{},resources:{}}}),this.ankiClient=new m,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 b.StdioServerTransport;await this.server.connect(e),console.error("Anki MCP server running on stdio")}};async function M(){try{await new C().run()}catch(p){console.error("Failed to start Anki MCP Server:",p),process.exit(1)}}M().catch(console.error);