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 kB
JavaScript
import{Server as T}from"@modelcontextprotocol/sdk/server/index.js";import{StdioServerTransport as R}from"@modelcontextprotocol/sdk/server/stdio.js";import{CallToolRequestSchema as q,ErrorCode as A,ListResourcesRequestSchema as _,ListResourceTemplatesRequestSchema as D,ListToolsRequestSchema as F,McpError as O,ReadResourceRequestSchema as j}from"@modelcontextprotocol/sdk/types.js";import{ErrorCode as N,McpError as C}from"@modelcontextprotocol/sdk/types.js";import{YankiConnect as M}from"yanki-connect";import{ErrorCode as f,McpError as g}from"@modelcontextprotocol/sdk/types.js";var h=class extends Error{constructor(e){super(e),this.name="AnkiConnectionError"}},w=class extends Error{constructor(e){super(e),this.name="AnkiTimeoutError"}},k=class extends Error{constructor(t,n){super(t);this.code=n;this.name="AnkiApiError"}},b={ankiConnectUrl:"http://localhost:8765",apiVersion:6,timeout:5e3,retryTimeout:1e4,defaultDeck:"Default"},d=class{constructor(e={}){this.config={...b,...e},this.client=new M}async executeWithRetry(e,t=1){let n=null;for(let i=0;i<=t;i++)try{return await e()}catch(o){if(n=this.normalizeError(o),i<t){let c=Math.min(1e3*Math.pow(2,i),this.config.retryTimeout);await new Promise(a=>setTimeout(a,c))}}throw n||new h("Unknown error occurred")}normalizeError(e){return e instanceof Error?e.message.includes("ECONNREFUSED")?new h("Anki is not running. Please start Anki and ensure AnkiConnect plugin is enabled."):e.message.includes("timeout")||e.message.includes("ETIMEDOUT")?new w("Connection to Anki timed out. Please check if Anki is responsive."):e.message.includes("collection unavailable")?new k("Anki collection is unavailable. Please close any open dialogs in Anki."):e:new Error(String(e))}wrapError(e){return e instanceof h?new g(f.InternalError,e.message):e instanceof w?new g(f.InternalError,e.message):e instanceof k?new g(f.InternalError,e.message):new g(f.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(n=>typeof n=="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(n=>({name:n.name,Front:n.front,Back:n.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 S=class{constructor(){this.ankiClient=new d,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 n=await this.ankiClient.getDeckNames();return{contents:[{uri:e,mimeType:"application/json",text:JSON.stringify({decks:n,count:n.length},null,2)}]}}if(e==="anki://note-types/all"){let n=await this.ankiClient.getModelNames();return{contents:[{uri:e,mimeType:"application/json",text:JSON.stringify({noteTypes:n,count:n.length},null,2)}]}}if(e==="anki://note-types/all-with-schemas"){let n=await this.getAllModelSchemas();return{contents:[{uri:e,mimeType:"application/json",text:JSON.stringify({noteTypes:n,count:n.length},null,2)}]}}let t=e.match(/^anki:\/\/note-types\/(.+)$/);if(t){let n=decodeURIComponent(t[1]);try{let i=await this.getModelSchema(n);return{contents:[{uri:e,mimeType:"application/json",text:JSON.stringify({modelName:i.modelName,fields:i.fields,templates:i.templates,css:i.css,createTool:`create_${n.replace(/\s+/g,"_")}_note`},null,2)}]}}catch{throw new C(N.InvalidParams,`Note type '${n}' does not exist`)}}throw new C(N.InvalidParams,`Unknown resource: ${e}`)}async getModelSchema(e){if(!e)throw new C(N.InvalidParams,"Model name is required");let t=Date.now(),n=this.modelSchemaCache.get(e);if(n&&t-this.lastCacheUpdate<this.cacheExpiry)return n;if(!(await this.ankiClient.getModelNames()).includes(e))throw new C(N.InvalidParams,`Note type not found: ${e}`);let[o,c,a]=await Promise.all([this.ankiClient.getModelFieldNames(e),this.ankiClient.getModelTemplates(e),this.ankiClient.getModelStyling(e)]),m={modelName:e,fields:o,templates:c,css:a.css};return this.modelSchemaCache.set(e,m),this.lastCacheUpdate=t,m}async getAllModelSchemas(){let e=Date.now();if(this.allModelSchemasCache&&e-this.lastCacheUpdate<this.cacheExpiry)return this.allModelSchemasCache;let t=await this.ankiClient.getModelNames(),n=await Promise.all(t.map(i=>this.getModelSchema(i)));return this.allModelSchemasCache=n,this.lastCacheUpdate=e,n}clearCache(){this.modelSchemaCache.clear(),this.allModelSchemasCache=null,this.lastCacheUpdate=0}};import{ErrorCode as s,McpError as r}from"@modelcontextprotocol/sdk/types.js";var v=class{constructor(){this.ankiClient=new d}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 n=e.match(/^create_(.+)_note$/);if(n){let i=n[1].replace(/_/g," ");return this.createModelSpecificNote(i,t)}throw new r(s.MethodNotFound,`Unknown tool: ${e}`)}}catch(n){if(n instanceof r)throw n;return{content:[{type:"text",text:`Error: ${n instanceof Error?n.message:String(n)}`}],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 r(s.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 r(s.InvalidParams,"Note type name is required");if(!e.fields||e.fields.length===0)throw new r(s.InvalidParams,"Fields are required");if(!e.templates||e.templates.length===0)throw new r(s.InvalidParams,"Templates are required");if((await this.ankiClient.getModelNames()).includes(e.name))throw new r(s.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 r(s.InvalidParams,"Model name is required");if(!(await this.ankiClient.getModelNames()).includes(e.modelName))throw new r(s.InvalidParams,`Note type not found: ${e.modelName}`);let[n,i,o]=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:n,templates:i,css:o.css},null,2)}]}}async createNote(e){if(!e.type)throw new r(s.InvalidParams,"Note type is required");if(!e.deck)throw new r(s.InvalidParams,"Deck name is required");if(!e.fields||Object.keys(e.fields).length===0)throw new r(s.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 r(s.InvalidParams,`Note type not found: ${e.type}`);let i=await this.ankiClient.getModelFieldNames(e.type);for(let a of i)if(!e.fields[a]&&!e.fields[a.toLowerCase()])throw new r(s.InvalidParams,`Missing required field: ${a}`);let o={};for(let a of i)o[a]=e.fields[a]||e.fields[a.toLowerCase()]||"";let c=await this.ankiClient.addNote({deckName:e.deck,modelName:e.type,fields:o,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 r(s.InvalidParams,"Deck name is required");if(!(await this.ankiClient.getModelNames()).includes(e))throw new r(s.InvalidParams,`Note type not found: ${e}`);(await this.ankiClient.getDeckNames()).includes(t.deck)||await this.ankiClient.createDeck(t.deck);let o=await this.ankiClient.getModelFieldNames(e),c={};for(let p of o){let E=t[p.toLowerCase()]||t[p]||"";c[p]=E}let a=Array.isArray(t.tags)?t.tags:[],m=await this.ankiClient.addNote({deckName:t.deck,modelName:e,fields:c,tags:a});return{content:[{type:"text",text:JSON.stringify({noteId:m,deck:t.deck,modelName:e},null,2)}]}}async batchCreateNotes(e){if(!e.notes||!Array.isArray(e.notes)||e.notes.length===0)throw new r(s.InvalidParams,"Notes array is required");let t=[],n=e.stopOnError!==!1;for(let i=0;i<e.notes.length;i++){let o=e.notes[i];try{if((await this.ankiClient.getDeckNames()).includes(o.deck)||await this.ankiClient.createDeck(o.deck),!(await this.ankiClient.getModelNames()).includes(o.type))throw new Error(`Note type not found: ${o.type}`);let m=await this.ankiClient.getModelFieldNames(o.type),p={};for(let I of m)p[I]=o.fields[I]||o.fields[I.toLowerCase()]||"";let E=await this.ankiClient.addNote({deckName:o.deck,modelName:o.type,fields:p,tags:o.tags||[]});t.push({success:!0,noteId:E,index:i})}catch(c){if(t.push({success:!1,error:c instanceof Error?c.message:String(c),index:i}),n)break}}return{content:[{type:"text",text:JSON.stringify({results:t,total:e.notes.length,successful:t.filter(i=>i.success).length,failed:t.filter(i=>!i.success).length},null,2)}]}}async searchNotes(e){if(!e.query)throw new r(s.InvalidParams,"Search query is required");let t=await this.ankiClient.findNotes(e.query),n=[];if(t.length>0){let i=Math.min(t.length,50);n=await this.ankiClient.notesInfo(t.slice(0,i))}return{content:[{type:"text",text:JSON.stringify({query:e.query,total:t.length,notes:n,limitApplied:t.length>50},null,2)}]}}async getNoteInfo(e){if(!e.noteId)throw new r(s.InvalidParams,"Note ID is required");let t=await this.ankiClient.notesInfo([e.noteId]);if(!t||t.length===0)throw new r(s.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 r(s.InvalidParams,"Note ID is required");if(!e.fields||Object.keys(e.fields).length===0)throw new r(s.InvalidParams,"Fields are required");let t=await this.ankiClient.notesInfo([e.id]);if(!t||t.length===0)throw new r(s.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 r(s.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 P="0.1.4";var x=class{constructor(){this.server=new T({name:"anki-connect-server",version:P},{capabilities:{tools:{},resources:{}}}),this.ankiClient=new d,this.resourceHandler=new S,this.toolHandler=new v,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(_,async()=>(await this.checkConnection(),this.resourceHandler.listResources())),this.server.setRequestHandler(D,async()=>(await this.checkConnection(),this.resourceHandler.listResourceTemplates())),this.server.setRequestHandler(j,async e=>(await this.checkConnection(),this.resourceHandler.readResource(e.params.uri))),this.server.setRequestHandler(F,async()=>(await this.checkConnection(),this.toolHandler.getToolSchema())),this.server.setRequestHandler(q,async e=>(await this.checkConnection(),this.toolHandler.executeTool(e.params.name,e.params.arguments)))}async checkConnection(){try{await this.ankiClient.checkConnection()}catch{throw new O(A.InternalError,"Failed to connect to Anki. Please make sure Anki is running and the AnkiConnect plugin is enabled.")}}async run(){let e=new R;await this.server.connect(e),console.error("Anki MCP server running on stdio")}};async function H(){try{await new x().run()}catch(l){console.error("Failed to start Anki MCP Server:",l),process.exit(1)}}H().catch(console.error);