anki-mcp
Provides tools for managing an Anki collection: listing decks, searching and retrieving notes, creating and updating notes, managing decks, deleting notes, and syncing with AnkiWeb.
Used as a text-to-speech provider for synthesizing audio for notes when Wiktionary audio is unavailable.
Used as an image source (via Google Custom Search) for find_images, and as a text-to-speech provider for generating audio for notes.
Used as an image source for the find_images tool, letting users search Openverse for pictures to illustrate notes.
Used as an image source for the find_images tool, letting users search Pixabay for pictures to illustrate notes.
Click on "Deploy Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@anki-mcpAdd 'der Apfel' to my German deck with pronunciation and a picture."
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
anki-mcp
An MCP server that exposes an Anki collection to Claude clients as tools, over
Streamable HTTP. It opens collection.anki2 directly with the anki
Python package — there is no AnkiConnect and no Anki GUI anywhere in the stack.
The primary use case is capturing German vocabulary from a phone: talk to Claude, and the note lands in the deck. The card design (below) is opinionated towards learning German, but the server works with any deck and note type.
Quick start
You need Docker, an AnkiWeb account (without 2FA), and a way to reach the server over HTTPS from your Claude client -- the examples use Tailscale.
mkdir anki-mcp && cd anki-mcp
curl -fsSLO https://raw.githubusercontent.com/JasonSooter/anki-mcp/main/secrets.env.example
cp secrets.env.example secrets.env
openssl rand -hex 32 # paste into ANKI_MCP_BEARER_TOKEN in secrets.env
mkdir -p data config && sudo chown 1000:1000 data configcompose.yaml:
services:
anki-mcp:
image: ghcr.io/jasonsooter/anki-mcp:main
container_name: anki-mcp
env_file: secrets.env
environment:
- TZ=Europe/Berlin # drives Anki's daily rollover -- use yours
volumes:
- ./data:/data # collection.anki2 + collection.media
- ./config:/config # AnkiWeb session key, OAuth state
ports:
- "127.0.0.1:8770:8770" # localhost only; publish via Tailscale
restart: unless-stoppeddocker compose up -d
curl -s http://127.0.0.1:8770/healthz
# {"status":"ok","profile":"full","collection_open":true}Then seed the collection from AnkiWeb and
connect a Claude client. Images are published
for linux/amd64 and linux/arm64; :main tracks the main branch, and every
build is also tagged sha-<commit> if you want to pin one.
Related MCP server: Anki MCP
Configuration
All configuration is environment variables, normally in secrets.env.
Variable | Required | Default | Purpose |
| no |
|
|
| bearer mode | -- | At least 32 characters; the server refuses to start without it. |
| oauth mode | -- | Login page password, at least 12 characters. |
| oauth mode | -- | Base32 TOTP setup key (not the 6-digit code). |
| oauth mode |
| The externally reachable |
| no |
|
|
| first sync | -- | Exchanged for a cached session key on the first sync; blank them afterwards. |
| no | AnkiWeb | Custom sync server URL, for a self-hosted sync server. |
| no |
|
|
| no | -- | Free key; the best image relevance. |
| no | -- | Legacy Google Custom Search engine. |
| no |
|
|
| for ElevenLabs | -- | No default voice: every default is an English speaker. |
| for Google TTS | -- | Text-to-Speech API key. |
| no |
| Google TTS voice. |
| no |
| Re-synthesise all audio with TTS, for one consistent voice. |
| no | -- | Ship structured logs over OTLP. Unset = stdout only. |
| no |
| Tags every telemetry record. |
| no |
| Listen address. |
| no |
| Where the collection and the state live. |
The rule that makes this safe
This server is the single writer to the collection.
Claude client ──► anki-mcp (your server) ──► AnkiWeb ──► phone (review only)Anki's collection is a SQLite database with an exclusive lock and its own sync protocol. Two consequences you must not design around:
Never put the data directory in a Dropbox / Syncthing / iCloud path. File-sync tools copy SQLite files mid-write and corrupt them. The collection lives only on the server.
Never run two copies of this container against the same directory. The second one will fail to start (by design — see below), but don't try.
Edit on the phone only if you accept that a conflict will require a full sync in one direction, which discards the other side.
Tools
Read-only:
Tool | What it does |
| Deck names, card counts, and what's due today |
| Note types and their field names, in order |
| Anki search syntax; truncated field previews |
| One note in full, plus its cards |
| Cards due today, whole collection or one deck |
| Reviews, time, again-rate, mature retention over N days |
| Search every image library and return the pictures to look at (Pictures) |
Writes:
Tool | What it does |
| Create a deck (idempotent; |
| Delete a deck; refuses to destroy cards unless told (Decks) |
| Add a note to a deck (rejects duplicates, and refuses one it can't illustrate) |
| Edit fields and tags, move the note to another deck, replace the picture via |
| Delete every note matching a search; count and selection must match (Deleting notes) |
| Push to AnkiWeb — explicit, never automatic |
Adding or editing a note saves locally but does not sync. Call
sync_to_ankiweb when you want the phone to see it.
Profiles
ANKI_MCP_PROFILE decides which tools are registered:
full(default) — everything.public— the read-only tools (includingfind_images) plusadd_note.create_deck,delete_deck,update_note,delete_notesandsync_to_ankiwebare not registered, so they don't exist on the wire; a client calling one getsUnknown tool.
Run a second container with ANKI_MCP_PROFILE=public to expose a narrower
endpoint. Note that it needs its own collection directory — two containers
cannot share one (see the lock rule above).
First run: seeding from AnkiWeb
The collection directory starts empty. On first start the server creates a new, empty collection; you then pull your real one down from AnkiWeb.
Create the secrets file (see Quick start).
Put your AnkiWeb username and password in the same file. They are needed only for the first sync; the server caches a session key at
config/ankiweb_hkeyand you can blank the password afterwards. AnkiWeb accounts with 2FA enabled will not work — there's no interactive step available to satisfy the second factor.Start it:
docker compose up -d anki-mcp curl -s http://127.0.0.1:8770/healthz # {"status":"ok","profile":"full","collection_open":true}Seed the collection. The new empty collection and your AnkiWeb account have no shared history, so AnkiWeb will demand a full sync.
sync_to_ankiwebwill refuse and tell you so — that refusal is the safety feature. Do the full download deliberately, with the container stopped:docker compose stop anki-mcp docker compose run --rm --entrypoint python anki-mcp -c " from anki.collection import Collection col = Collection('/data/collection.anki2') auth = col.sync_login('YOUR_ANKIWEB_EMAIL', 'YOUR_PASSWORD', None) col.full_upload_or_download(auth=auth, server_usn=None, upload=False) print('downloaded') " docker compose up -d anki-mcpupload=Falsemeans AnkiWeb overwrites local, which is what you want when seeding. Getting this backwards would upload your empty collection over your real one — read the next section before you ever passupload=True.Alternatively, copy an existing
collection.anki2intodata/while the container is stopped. Copy thecollection.mediafolder too.
Full sync: what it means and how to resolve one
A full sync is not a merge. One side is discarded wholesale. AnkiWeb asks for
one when the two collections have diverged beyond reconciliation — different
schema versions, or a Check Database/restore on one side.
sync_to_ankiweb never resolves this on its own. It reports which direction
AnkiWeb wants and changes nothing. To resolve it, stop the container and run
the snippet above with:
upload=False— AnkiWeb wins. Local changes since the divergence are lost.upload=True— This server wins. Anything that reached AnkiWeb from another device is lost.
Because the server is the single writer, upload=True is usually right — but
confirm the phone has nothing unsynced first. Back up data/ before either.
Connecting a Claude client
The port is bound to 127.0.0.1 so nothing is exposed by simply starting the
container. Publish it on the tailnet with Tailscale, which also gives it a
real TLS certificate:
sudo tailscale serve --bg --https=8770 http://127.0.0.1:8770
sudo tailscale serve statusThe server is then at https://<your-host>.<your-tailnet>.ts.net:8770/mcp,
reachable from any device on the tailnet.
Claude Code:
claude mcp add --transport http anki \
https://<your-host>.<your-tailnet>.ts.net:8770/mcp \
--header "Authorization: Bearer <ANKI_MCP_BEARER_TOKEN>"Claude Desktop — in Settings → Connectors → Add custom connector, use the
same URL and add the Authorization: Bearer <token> header.
Verify with: "list my Anki decks".
Exposing it via Tailscale Funnel
Funnel puts the endpoint on the public internet. What changes:
sudo tailscale funnel --bg --https=443 http://127.0.0.1:8770Switch to the public profile. Set
ANKI_MCP_PROFILE=publicso the internet-facing endpoint cannot edit notes or trigger syncs.Set
ANKI_MCP_PUBLIC_URLto the Funnel hostname, so the OAuth protected-resource metadata the server publishes points at the right host.If you stay on the bearer token, understand what it is. On the tailnet it's defence in depth; on Funnel it would be the only thing between the internet and your collection, and it never rotates or expires. Treat a leak as total compromise, and rotate by changing
secrets.envand restarting.Use OAuth, not the bearer token. This is configuration, not code: set
ANKI_MCP_AUTH_MODE=oauthalong withANKI_MCP_LOGIN_PASSWORD,ANKI_MCP_TOTP_SECRETandANKI_MCP_PUBLIC_URL, and the server refuses to start unless all three are present. See Auth modes. It is also not optional for Claude custom connectors -- a static header is not something they can send, so the mobile apps need OAuth regardless.
Funnel also means every request is logged by Tailscale's infrastructure and the hostname is publicly resolvable. For a personal vocab collection that is probably fine; decide deliberately rather than by default.
Why port 8770
Not 8765 — that is AnkiConnect's default port. This server deliberately does not use AnkiConnect, but anyone running Anki desktop on the same machine would get a silent port collision, so the default sidesteps it.
Auth modes
ANKI_MCP_AUTH_MODE picks how callers authenticate.
bearer — one static token in ANKI_MCP_BEARER_TOKEN, checked with a
constant-time compare. Good enough on the tailnet, where the network is already
the perimeter, and it is what Claude Code and Claude Desktop use because they
run locally and can send an Authorization header.
If the deployment ever stops being tailnet-only, change this back to
oauthin the same commit. A bearer endpoint left on Funnel does not fail — it serves happily, protected by one token that never rotates. Every other misconfiguration here announces itself: the wrong port fails the connector dialog, a missing TOTP secret refuses to boot, a stale issuer breaks discovery. This one is silent, which is why it gets a warning rather than a test — the code is correct in both modes.
oauth — a full OAuth 2.1 authorization server: dynamic client
registration, PKCE, authorization codes, rotating refresh tokens, and a login
page that wants a password and a TOTP code. State lives in
config/oauth.db, so a container restart does not break the
connector.
You need oauth for Claude custom connectors — and therefore for Claude on
Android, which is the only way to reach this server from a phone. Anthropic's
servers make that connection, so the endpoint must be publicly reachable and a
static header is not on offer.
The login page is the only thing between the internet and your collection when Funnel is on. It fails closed: identical wording whether the password or the code was wrong, five attempts per source address per 15 minutes, and a TOTP code cannot be replayed inside its own window.
Set ANKI_MCP_TOTP_SECRET to the base32 setup key (what a password
manager's one-time-password field or an authenticator app is set up from), not
the 6-digit code.
Telemetry and the Grafana dashboard
Optional. Structured logs ship over OTLP (for example to Grafana Cloud), and every field is queryable as Loki structured metadata:
{service_name="anki-mcp", deployment_environment_name="production"}
| tool="add_note" | status="error"Set OTEL_EXPORTER_OTLP_ENDPOINT and OTEL_EXPORTER_OTLP_HEADERS to turn it
on. With no endpoint configured it is a no-op — the collection must stay
servable whether or not Grafana is reachable.
Import dashboards/anki-mcp.json (uid anki-mcp; it expects a Loki data
source with uid grafanacloud-logs, Grafana Cloud's default). It covers health and error
rate, per-tool latency, collection size, capture rate by deck and note type,
AnkiWeb sync outcomes — including the full-sync refusals — and a security row
tracking auth failures and login attempts by source address.
dashboards/alerts-ankiweb-sync.json holds two alert rules (full sync needed,
sync erroring). Replace REPLACE_WITH_YOUR_FOLDER_UID with a folder of yours,
then import them through Grafana's alert-rule provisioning API.
Decks
create_deck makes one; add_note refuses an unknown deck rather than
inventing it, because a typo would otherwise scatter cards into a deck nobody
reviews. "::" nests, and the parents are created with the child.
Fill in the description. list_decks shows it and add_note's own guidance is
to read it before choosing where a note belongs, so a deck without one is a deck
the next caller has to guess about.
Creating a deck twice is safe -- the existing one is reported back with
created: false. That matters because "make sure this deck exists" is what
every caller actually means, and a retry after a dropped connection should be
harmless.
delete_deck is the other direction. It and delete_notes are the two tools
here that destroy work rather than adding it.
Its intended use is tidying up: an empty deck made by a typo, or two decks that
turned out to be the same topic under different spellings. Two guards, because
Anki's own remove() has neither:
Cards are refused by default. A deck holding cards raises instead, saying how many are at stake;
delete_cards=Trueis required to go through. Those cards carry review history that no sync brings back.Subdecks count toward that total, because deleting a parent deletes them. Counting only the parent's own cards would call a tree holding hundreds "empty".
Note that deck names are case-sensitive and & is literal -- a client that
HTML-escapes it leaves you with German Time & Dates sitting next to
German Time & Dates as two separate decks.
Moving a note
update_note's deck argument moves the note's cards to another deck. It is
the repair for a note that went into the wrong one -- which is a real failure
mode here, since add_note takes the deck by name and a model choosing that
name can choose badly.
Moving preserves each card's scheduling and review history. That is the
reason this exists rather than leaving the job to delete_notes plus a fresh
add_note: deleting and re-adding resets every card to new and throws the
history away, and it re-fetches the audio and picture for a note that already
had good ones.
The deck must already exist. Like add_note, this refuses an unknown name and
lists the real ones rather than inventing a deck, because a typo would
otherwise file the note where nobody reviews it -- and & is literal, so
German Time & Dates is a different deck from German Time & Dates. Call
create_deck first if it does not exist yet.
The result reports moved: {from, to, cards}, with from read before the
move, so a caller who named the wrong note can see where its cards actually
came from.
Deleting notes
delete_notes takes an Anki search and deletes everything it matches. It is the
cleanup path for a batch that went in wrong -- a session that filed notes into
the wrong deck, an import that should not have happened.
Both expect_count and selection are required. Run search_notes with
the same query, pass its total_matches as expect_count and its selection
value through unchanged. Never construct a selection token; it is the proof
that you looked.
A search is the most dangerous way to choose what to destroy: tag:zeit,
tag:zeit* and a typo'd tag:ziet all look equally reasonable in a tool call,
and in a collection that has zeit::3-monate the first two differ by every
month note. The two arguments answer different questions, and fail differently
on purpose:
Refusal | Means |
count mismatch | the query is broader or narrower than you meant |
digest mismatch | notes were added, removed or edited since you looked |
token expired | your decision is older than the facts it rested on |
The age check is not belt-and-braces. A delete planned against this collection on one day was still a perfectly accurate query two days later -- same count, same note ids -- but by then the notes had been superseded and deleting them was wrong. Nothing about which notes matched could have caught that; only when they were looked at. Selections expire after 15 minutes, and re-running the search costs nothing.
The digest covers each matched note's fields and tags, not just its id. Ids alone were tried first and are not enough: Anki hands out note ids from a millisecond clock and reuses a freed one, so deleting the newest note and adding another in the same millisecond yields a different note wearing the same id -- exactly the swap the token exists to catch. Hashing contents also means an edit to a matched note invalidates the selection, which is the desired answer for a caller about to destroy something they reviewed in a different form.
The token covers every match, not the rendered page: search_notes
paginates and delete_notes does not, so a page-only token would quietly stop
protecting the remainder of a large cleanup.
Two smaller guards: an empty query is refused rather than matching the whole collection, and a query matching nothing is an error rather than a successful deletion of zero notes, because a misspelled tag matches nothing rather than erroring and "deleted 0" would read as "the cleanup happened".
If the count comes back wrong, look at what the query matches before re-issuing with the number from the error. The mismatch is usually telling you the query is wrong, not the count.
Deleting a note deletes its cards and their review history. Once synced it is gone from every device; AnkiWeb has no undo.
The three cards
Every note of German (Fluent Forever) makes three cards, and they are three
different questions rather than three views of one:
card | prompt | task |
1. Wort erkennen | word + IPA + audio | recall the meaning; then record and compare your own voice |
2. Wort produzieren | picture or digits + the sentence with the word blanked | type the word |
3. Schreibweise | audio + definition, the word hidden | type what you hear — dictation |
Card 1 cannot have a typing box: it shows the word, so there would be nothing to recall. That asymmetry is why only two of the three are typed.
Cards 2 and 3 look similar and are not. Card 2 gives you the meaning and asks for the form; card 3 gives you only the sound. That distinction depends entirely on the audio being right — while the synthesiser was mispronouncing isolated words, card 3 was worse than useless, because it drilled the wrong sound and then marked your correct spelling against it. It became worth having on the same day the audio did.
Card 3 is gated on Test Spelling. Fill it (any value) and the card
generates; clear it and the card stops being produced. Two things follow:
Clearing the field does not delete the card. Anki leaves it as an empty card until
col.get_empty_cards()is run, so a flag cleared in bulk quietly inflates deck counts until someone sweeps.It is the per-note escape hatch. Dictating a 25-character compound (
neunzehnhundertfünfundachtzig) is a different exercise from dictatingneun, and if it stops being worth the keystrokes, clearing the flag on those notes alone drops the card without touching the rest.
Speaking practice lives on card 1's answer, where the model audio plays:
Shift+V records, V replays. Never on card 2's prompt, for the same reason
the word audio never plays there — a card that asks you to produce a word must
not first say it.
{{type:}} renders only in the reviewer and the card-layout screen. The
Browse → Preview window never shows the input box no matter what the template
says. Check a template by reading it in Manage Note Types → Cards, or study it
in a filtered deck ("note:German (Fluent Forever)" card:3 is:new).
The English field
English is required on every note, and it renders on the answer side of both
cards behind {{hint:English}} -- never on a prompt, so retrieval still happens in
German. It holds one line per piece of German the answer shows, separated by
<br>:
pocket money
Money that children get regularly from their parents and may spend freely.
My daughter gets 25 euros pocket money every month.A bare gloss was the original rule and it left most of the answer side
unreadable: the German definition and the example sentence are written at a
level above where the learner is, which is the point of them as material but
useless as feedback. add_note counts the lines and refuses a note whose
gloss does not cover the Definition (DE) and Example (DE) it was given.
These are lines in one field rather than Definition (EN) and Example (EN)
fields on purpose: adding fields is a note type schema change, and AnkiWeb
answers a schema change by demanding a full sync in one direction -- which
sync_to_ankiweb refuses to do on its own.
Pronunciation
IPA and audio come from German Wiktionary; audio is re-synthesised by TTS when
ANKI_MCP_PREFER_TTS=true, so the deck has one voice rather than a mix of
volunteer recordings at different volumes. Nothing invents an IPA transcription
— for a phrase there simply is none, and a hand-assembled guess presented as
fact is worse than a blank field.
Word-final consonants
With ElevenLabs, a word's audio is not synthesised on its own. Said alone, the
word ends the utterance, and the voice says an utterance-final stop weakly: a
long closure, then a faint release. On der Berg the [k] was inaudible, which
is fatal on a deck that teaches final devoicing. So for the Word field:
the word is spoken inside a carrier,
„der Berg“, sagte sie., via/with-timestamps, and cut back out using the character alignment, keeping 120 ms past the last letter for the release and adding 300 ms of silence;for a word ending in an obstruent (b d g p t k s ß f v z x ch), up to 24 takes are made, three at a time (the plan's concurrency limit), and the one with the strongest final release is kept. The strength of the burst varies by 8 dB between identical requests, and no request setting moved it, so selection is the fix. The
pronunciationnote in the tool result says how many takes were made and the winner's score;a word ending in -ig (König, wenig, zwanzig) is not ranked by burst strength: standard German says [ɪç] there, not [k] (g-Spirantisierung), and ranking by burst could favour a [k] take. Instead each take is checked for a sustained final hiss (an unbroken run of at least 90 ms; calibrated on live takes, where [ç] held 110-170 ms and a real [k] 30-70 ms). Takes come in rounds of 3, up to 6; the longest hiss in the first round with a qualifying take is kept, or the longest overall if none qualifies.
ei+ g (Zweig, Teig) is ordinary devoicing and is ranked like any other stop;example sentences are synthesised as before.
scripts/tts_tail_diagnostic.py measures a clip's final release with the same
code the server selects on, against stored clips or a reference recording such
as Commons De-Berg2.ogg (a native [k] at -9.4 dB relative to the vowel).
Compounds
German builds compounds freely, so a word can be completely ordinary and still
have no Wiktionary entry. das Budgetgeld is real, is used by the DJI, and
returns nothing.
When the whole word misses, compound_ipa looks up its parts and joins
their real transcriptions:
das Budgetgeld -> Budget (byˈd͡ʒeː) + Geld -> byˈd͡ʒeːˌɡɛlt
das Kleidergeld -> Kleider (ˈklaɪ̯dɐ) + Geld -> ˈklaɪ̯dɐˌɡɛltMeasured against live Wiktionary: das Taschengeld and der Dauerauftrag are
answered directly and never reach the splitter, and die Entmystifizierung
resolves to nothing — it is a prefix/suffix derivation rather than a compound,
and no split of it has two real parts. A blank IPA field is the correct answer
there.
Three things keep this a derivation rather than a guess:
Every symbol comes from Wiktionary. Only the seam is ours, and the
pronunciationfield inadd_note's result names the parts, so a derived transcription is never mistaken for a dictionary one.Both parts must have real entries. A nonsense split finds nothing and is dropped, which is what stops the splitter from inventing a plausible-looking word boundary.
The stress is the actual rule, not a flourish. German compounds carry primary stress on the first element and secondary on the rest, which is how Wiktionary writes the compounds it does have —
Taschengeldisˈtaʃn̩ˌɡɛlt. So a later part's primary mark is demoted, and an unmarked monosyllable gains a secondary one.
The seam can differ slightly from a transcription the dictionary has lexicalised
— derived Tasche + Geld gives ˈtaʃəˌɡɛlt where Wiktionary's own entry reads
ˈtaʃn̩ˌɡɛlt, because the compound reduces the schwa. That only matters for
words Wiktionary already has, and those never reach the splitter.
Linking elements are stripped only when the unstripped head has no entry:
Taschen|geld needs the n peeled to reach Tasche, but Kleidergeld is
Kleider + Geld — the plural, not a linked Kleid — and peeling first would
transcribe the wrong word. The search runs longest-first-element first, because German heads are
usually the short tail — -geld, -zeit, -haus. Part lookups are capped per
word so an unsplittable compound cannot turn into a minute of throttled
requests.
Pictures
On this collection the picture is not decoration. Fluent Forever's argument --
and the line from the book that drove the design -- is that a card should carry
meaning without an English word on it, so the image is the definition for
anything concrete. That is why add_note refuses a note it cannot illustrate.
How to choose one
find_images(queries=[...]) -> look at the results
add_note(..., image_url=...) -> attach the one that actually depicts the wordfind_images runs every query against every image library, pools the results,
removes duplicates and returns the pictures themselves. Look at them. This
is the whole point of the tool, because word matching cannot judge a picture:
a green ceramic frog is tagged
winken, abschiedand is a flawless textual match for waving goodbyea photograph of paperclips is tagged
feierabend
Both were chosen automatically, and both were wrong. Scoring candidates by how well their tags matched the query was tried and measured: the paperclip photo carries both query words and scores 1.00 while the images genuinely about Feierabend score 0.50, and six candidates tied at 1.00 for another search. Tags are user-supplied and match promiscuously. The approach was abandoned rather than shipped.
Writing good queries
Pass several at once and vary them deliberately -- the thing itself, the scene it belongs to, and what a person does:
find_images(queries=["Hausschuh", "Pantoffel Filz", "Fuesse Hausschuhe Sofa"])Traps worth knowing, all learned the hard way:
Emotive nouns pull the wrong register. Searching
Abschiedreturns funerals and mourning roses, because that is what people tag with it. Search the concrete action instead --Frau winkt,Kinder winken.Compound queries invite spurious matches.
Buero Feierabendlet a picture of paperclips match on both words. One concrete noun is usually better.The first hit is rarely the best one. Providers rank by popularity.
The libraries
Provider | Key | Character |
Pixabay | free | Photographs taken on purpose: everyday objects and scenes, German tags. The best single source. |
Openverse | none | Flickr and friends. Slower and patchier, but holds candid photography the stock libraries do not -- it supplied the children waving and the theatre curtain. |
Wikimedia | none | Fast, but its |
Google CSE | key | Largely moot: Google withdrew "search the entire web" in March 2026, capping new engines at 50 listed domains. Kept for legacy engines. |
ANKI_MCP_IMAGE_PROVIDER=auto chains them best-first for the one-shot
image_query path. find_images ignores the chain and searches all of them,
because when something is going to look at the results, breadth beats speed.
When there is genuinely no picture
Some words cannot be depicted unambiguously. A photograph of vegetables does
not mean Gesundheit -- it means vegetables, and the card would accept several
different answers. Wyner splits Picture Words from the All-Purpose Card for
exactly this reason.
For those, add_note(abstract=true) skips the image and requires
Definition (DE) instead, so the German definition carries the meaning.
This is a last resort, not a first move. Try several genuinely different queries first. "Wir sehen uns!" survived three failed searches before a wide pooled scan found children waving -- giving up early would have cost a real picture.
A note on deleting media
store_image names files from the search query, and Anki reuses an existing
name when the bytes match, so two notes can point at the same file. The
server never deletes media, so this is latent — but anything that cleans up
superseded images must resolve which notes reference a file before unlinking
it, not after. Deleting by filename would silently blank the picture on every
other note sharing it.
Tools → Check Media in Anki reports both halves: files referenced by no note,
and notes referencing missing files.
Operations
Backup. The collection is a live SQLite database — copy it stopped, not hot:
docker compose stop anki-mcp
cp -a data ~/backups/anki-$(date +%F)
docker compose up -d anki-mcpThe lock. The server opens the collection once, on one dedicated thread, and holds it for its lifetime. If the file is already open, startup fails with exit code 3 and an explicit message — it does not retry, because a container that keeps restarting is visible while one that silently serves errors is not. Exit code 2 means a configuration problem (usually a missing or too-short bearer token). The server will not start unauthenticated.
Timezone. Set TZ on the container; it drives Anki's daily rollover. If it's wrong, due_counts disagrees with the phone.
Upgrading the anki package
anki is pinned exactly in pyproject.toml (currently 26.8.1, validated
against AnkiDroid as the downstream client).
This pin is not a routine dependency. The anki library owns the collection's
schema version: opening the collection with a newer version can upgrade the
schema, and a schema AnkiDroid doesn't understand forces a full sync on the
phone — or refuses to sync at all. Anki's Python API also has no stability
guarantee and moves between releases.
Before bumping:
Check whether the schema changed. Compare
SCHEMA_MAX_VERSIONinrslib/src/storage/upgrades/mod.rsandSYNC_VERSION_MAXinrslib/src/sync/version.rsbetween the two tags ofankitects/anki(the git tags are zero-padded:26.08.1, not26.8.1). If both are unchanged, the phone's AnkiDroid version doesn't matter; if either changed, AnkiDroid on the phone must support the new version first.Back up the data directory.
Bump, rebuild, run the tools once, then
sync_to_ankiweband confirm the phone still syncs normally rather than demanding a full sync.
Treat it as a migration, not an upgrade.
License
MIT -- see LICENSE.
This server cannot be deployed
Maintenance
Related MCP Connectors
Spaced-repetition flashcards your AI writes, quizzes you on by voice, and schedules with FSRS.
Run UX research from Claude — create card sort studies, list studies, pull headline stats.
- FlipnemOAuthcom.flipnem
Build and study spaced-repetition flashcards with your agent.
Make Anki, Quizlet, Mochi and Brainscape decks and review your vocabulary from the chat.
Related MCP Servers
- AlicenseBqualityCmaintenanceEnables AI assistants to manage Anki flashcard decks and cards through natural language, supporting deck creation, card additions (basic and cloze types), and review queue management.65 npm2MIT
- FlicenseNot gradedqualityNot gradedmaintenanceEnables AI assistants like Claude to interact with Anki flashcard decks through AnkiConnect. Supports creating and managing decks, basic and cloze deletion cards, searching existing cards, and organizing content with tags.-
- AlicenseNot gradedqualityNot gradedmaintenanceConnects Claude to Anki flashcard software via AnkiConnect, enabling users to review due cards, create flashcards, and manage spaced repetition learning through natural language conversations.MIT
- AlicenseAqualityDmaintenanceEnables AI assistants to manage Anki flashcard collections by creating, searching, and updating cards through a standardized interface. It supports media handling, batch operations, and review scheduling via the AnkiConnect add-on.62MIT