feat: initial implementation of anki-mcp MCP server
Implements a FastMCP server that wraps AnkiConnect (localhost:8765) to expose Anki flashcard management as Claude MCP tools. Tools provided: - list_decks: list all deck names - create_deck: create nested decks (:: separator) - add_card: add Basic flashcard with optional tags - get_due_cards: fetch today's due cards with optional deck filter - update_card: edit front/back of existing note - update_deck: rename deck (move-cards approach) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
1
.env.example
Normal file
1
.env.example
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ANKICONNECT_URL=http://localhost:8765
|
||||||
10
.gitignore
vendored
Normal file
10
.gitignore
vendored
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
.env
|
||||||
|
.venv/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
.pytest_cache/
|
||||||
|
*.egg-info/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
.DS_Store
|
||||||
21
pyproject.toml
Normal file
21
pyproject.toml
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=68"]
|
||||||
|
build-backend = "setuptools.backends.legacy:build"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "anki-mcp"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "MCP server for Anki flashcard management via AnkiConnect"
|
||||||
|
requires-python = ">=3.11"
|
||||||
|
dependencies = [
|
||||||
|
"mcp[cli]",
|
||||||
|
"httpx",
|
||||||
|
"python-dotenv",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
anki-mcp = "src.server:main"
|
||||||
|
|
||||||
|
[tool.setuptools.packages.find]
|
||||||
|
where = ["."]
|
||||||
|
include = ["src*"]
|
||||||
3
requirements.txt
Normal file
3
requirements.txt
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
mcp[cli]
|
||||||
|
httpx
|
||||||
|
python-dotenv
|
||||||
0
src/__init__.py
Normal file
0
src/__init__.py
Normal file
177
src/anki.py
Normal file
177
src/anki.py
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
"""AnkiConnect HTTP client.
|
||||||
|
|
||||||
|
All calls use a single POST pattern to http://localhost:8765:
|
||||||
|
{"action": "actionName", "version": 6, "params": {...}}
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
ANKICONNECT_URL = os.getenv("ANKICONNECT_URL", "http://localhost:8765")
|
||||||
|
_VERSION = 6
|
||||||
|
|
||||||
|
|
||||||
|
def invoke(action: str, **params: Any) -> Any:
|
||||||
|
"""Send a request to AnkiConnect and return the result.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
action: AnkiConnect action name.
|
||||||
|
**params: Action parameters.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The result field from the AnkiConnect response.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ConnectionError: If Anki is not running or AnkiConnect is unreachable.
|
||||||
|
ValueError: If AnkiConnect returns an error in the response.
|
||||||
|
"""
|
||||||
|
payload: dict[str, Any] = {"action": action, "version": _VERSION}
|
||||||
|
if params:
|
||||||
|
payload["params"] = params
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = httpx.post(ANKICONNECT_URL, json=payload, timeout=10.0)
|
||||||
|
response.raise_for_status()
|
||||||
|
except httpx.ConnectError as exc:
|
||||||
|
raise ConnectionError(
|
||||||
|
"Cannot connect to AnkiConnect. Make sure Anki is running with the "
|
||||||
|
"AnkiConnect add-on installed (code: 2055492159)."
|
||||||
|
) from exc
|
||||||
|
except httpx.HTTPStatusError as exc:
|
||||||
|
raise ConnectionError(f"AnkiConnect returned HTTP {exc.response.status_code}") from exc
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
if data.get("error") is not None:
|
||||||
|
raise ValueError(f"AnkiConnect error: {data['error']}")
|
||||||
|
return data["result"]
|
||||||
|
|
||||||
|
|
||||||
|
def list_decks() -> list[str]:
|
||||||
|
"""Return all deck names sorted alphabetically.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of deck name strings.
|
||||||
|
"""
|
||||||
|
result: list[str] = invoke("deckNames")
|
||||||
|
return sorted(result)
|
||||||
|
|
||||||
|
|
||||||
|
def create_deck(deck_name: str) -> int:
|
||||||
|
"""Create a new deck (or return existing deck ID).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
deck_name: Name of the deck to create. Use '::' for nested decks.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The deck ID as an integer.
|
||||||
|
"""
|
||||||
|
deck_id: int = invoke("createDeck", deck=deck_name)
|
||||||
|
return deck_id
|
||||||
|
|
||||||
|
|
||||||
|
def add_note(
|
||||||
|
deck_name: str,
|
||||||
|
front: str,
|
||||||
|
back: str,
|
||||||
|
tags: list[str] | None = None,
|
||||||
|
) -> int:
|
||||||
|
"""Add a Basic card to a deck.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
deck_name: Target deck name.
|
||||||
|
front: Front (question) side of the card.
|
||||||
|
back: Back (answer) side of the card.
|
||||||
|
tags: Optional list of tags to attach.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The new note ID.
|
||||||
|
"""
|
||||||
|
note: dict[str, Any] = {
|
||||||
|
"deckName": deck_name,
|
||||||
|
"modelName": "Basic",
|
||||||
|
"fields": {
|
||||||
|
"Front": front,
|
||||||
|
"Back": back,
|
||||||
|
},
|
||||||
|
"tags": tags or [],
|
||||||
|
"options": {
|
||||||
|
"allowDuplicate": False,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
note_id: int = invoke("addNote", note=note)
|
||||||
|
return note_id
|
||||||
|
|
||||||
|
|
||||||
|
def find_due_cards(deck_name: str = "", limit: int = 20) -> list[dict[str, Any]]:
|
||||||
|
"""Find today's due cards, optionally filtered by deck.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
deck_name: Deck to filter by. Empty string means all decks.
|
||||||
|
limit: Maximum number of cards to return.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of card info dicts with keys: cardId, noteId, deckName, front, back, due.
|
||||||
|
"""
|
||||||
|
query = "is:due"
|
||||||
|
if deck_name:
|
||||||
|
query = f'deck:"{deck_name}" {query}'
|
||||||
|
|
||||||
|
card_ids: list[int] = invoke("findCards", query=query)
|
||||||
|
if not card_ids:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Respect limit before fetching full card info to reduce payload size
|
||||||
|
card_ids = card_ids[:limit]
|
||||||
|
cards_info: list[dict[str, Any]] = invoke("cardsInfo", cards=card_ids)
|
||||||
|
|
||||||
|
result = []
|
||||||
|
for card in cards_info:
|
||||||
|
result.append(
|
||||||
|
{
|
||||||
|
"cardId": card.get("cardId"),
|
||||||
|
"noteId": card.get("note"),
|
||||||
|
"deckName": card.get("deckName"),
|
||||||
|
"front": card.get("fields", {}).get("Front", {}).get("value", ""),
|
||||||
|
"back": card.get("fields", {}).get("Back", {}).get("value", ""),
|
||||||
|
"due": card.get("due"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def update_note_fields(note_id: int, front: str = "", back: str = "") -> None:
|
||||||
|
"""Update the Front and/or Back fields of an existing note.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
note_id: The note ID to update.
|
||||||
|
front: New front text. If empty, field is left unchanged.
|
||||||
|
back: New back text. If empty, field is left unchanged.
|
||||||
|
"""
|
||||||
|
fields: dict[str, str] = {}
|
||||||
|
if front:
|
||||||
|
fields["Front"] = front
|
||||||
|
if back:
|
||||||
|
fields["Back"] = back
|
||||||
|
if not fields:
|
||||||
|
return
|
||||||
|
|
||||||
|
invoke("updateNoteFields", note={"id": note_id, "fields": fields})
|
||||||
|
|
||||||
|
|
||||||
|
def rename_deck(deck_name: str, new_name: str) -> None:
|
||||||
|
"""Rename a deck by creating the new deck and moving all cards into it.
|
||||||
|
|
||||||
|
AnkiConnect does not expose a direct renameDeck action.
|
||||||
|
The old deck is left empty (Anki does not allow deleting decks without also
|
||||||
|
deleting cards, so we leave the empty shell to avoid data loss).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
deck_name: Current deck name.
|
||||||
|
new_name: New deck name.
|
||||||
|
"""
|
||||||
|
invoke("createDeck", deck=new_name)
|
||||||
|
card_ids: list[int] = invoke("findCards", query=f'deck:"{deck_name}"')
|
||||||
|
if card_ids:
|
||||||
|
invoke("changeDeck", cards=card_ids, deck=new_name)
|
||||||
106
src/server.py
Normal file
106
src/server.py
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
"""Anki MCP server — manage Anki flashcards via AnkiConnect."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
# sys.path injection for `mcp dev` compatibility
|
||||||
|
_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
if _root not in sys.path:
|
||||||
|
sys.path.insert(0, _root)
|
||||||
|
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
load_dotenv(os.path.join(_root, ".env"))
|
||||||
|
|
||||||
|
from mcp.server.fastmcp import FastMCP
|
||||||
|
|
||||||
|
from src import anki
|
||||||
|
|
||||||
|
mcp = FastMCP(
|
||||||
|
name="anki",
|
||||||
|
instructions=(
|
||||||
|
"Anki flashcard manager via AnkiConnect. "
|
||||||
|
"Add cards to decks, review due cards, manage categories. "
|
||||||
|
"Requires Anki to be running with the AnkiConnect add-on (code: 2055492159)."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def list_decks() -> list[str]:
|
||||||
|
"""List all Anki deck names."""
|
||||||
|
return anki.list_decks()
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def create_deck(deck_name: str) -> dict:
|
||||||
|
"""Create a new Anki deck (or return existing deck ID).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
deck_name: Deck name. Use '::' separator for nested decks (e.g. 'Korean::Vocabulary').
|
||||||
|
"""
|
||||||
|
deck_id = anki.create_deck(deck_name)
|
||||||
|
return {"deck_name": deck_name, "deck_id": deck_id}
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def add_card(
|
||||||
|
deck_name: str,
|
||||||
|
front: str,
|
||||||
|
back: str,
|
||||||
|
tags: list[str] = [],
|
||||||
|
) -> dict:
|
||||||
|
"""Add a Basic flashcard to a deck.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
deck_name: Target deck name (must already exist or be created first).
|
||||||
|
front: Front (question) side of the card.
|
||||||
|
back: Back (answer) side of the card.
|
||||||
|
tags: Optional list of tags to attach to the card.
|
||||||
|
"""
|
||||||
|
note_id = anki.add_note(deck_name, front, back, tags)
|
||||||
|
return {"note_id": note_id, "deck_name": deck_name, "front": front, "back": back}
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def get_due_cards(deck_name: str = "", limit: int = 20) -> list[dict]:
|
||||||
|
"""Get today's due cards for review.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
deck_name: Filter by deck name. Leave empty to get due cards from all decks.
|
||||||
|
limit: Maximum number of cards to return (default 20).
|
||||||
|
"""
|
||||||
|
return anki.find_due_cards(deck_name, limit)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def update_card(note_id: int, front: str = "", back: str = "") -> dict:
|
||||||
|
"""Edit an existing card's Front and/or Back content.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
note_id: The note ID to update (obtained from add_card or get_due_cards).
|
||||||
|
front: New front text. Leave empty to keep existing.
|
||||||
|
back: New back text. Leave empty to keep existing.
|
||||||
|
"""
|
||||||
|
anki.update_note_fields(note_id, front, back)
|
||||||
|
return {"note_id": note_id, "updated": {"front": bool(front), "back": bool(back)}}
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def update_deck(deck_name: str, new_name: str) -> dict:
|
||||||
|
"""Rename an existing deck.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
deck_name: Current deck name.
|
||||||
|
new_name: New deck name.
|
||||||
|
"""
|
||||||
|
anki.rename_deck(deck_name, new_name)
|
||||||
|
return {"old_name": deck_name, "new_name": new_name}
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
mcp.run()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user