anki-mcp-server 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,3 @@
1
+ """MCP server for Anki via AnkiConnect."""
2
+
3
+ __version__ = "0.2.0"
@@ -0,0 +1,3 @@
1
+ from .server import main
2
+
3
+ main()
@@ -0,0 +1,198 @@
1
+ """Clients for AnkiConnect and the optional Anki MCP Bridge add-on, plus auto-launching Anki."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import logging
7
+ import os
8
+ import shlex
9
+ import shutil
10
+ import subprocess
11
+ import sys
12
+ from typing import Any
13
+ from urllib.parse import urlparse
14
+
15
+ import httpx
16
+ from mcp.server.mcpserver.exceptions import ToolError
17
+
18
+ from .config import Settings
19
+
20
+ log = logging.getLogger("anki-mcp-server")
21
+
22
+ API_VERSION = 6
23
+ LAUNCH_WAIT_SECONDS = 90
24
+
25
+ # Everything this server may ask AnkiConnect to do. Notably absent: note-type/template/media/deck-config changes,
26
+ # removeEmptyNotes, collection-wide tag replaces, import/export of whole collections, GUI automation beyond Browse.
27
+ ALLOWED_ACTIONS = frozenset({
28
+ "version", "deckNames", "deckNamesAndIds", "modelNamesAndIds", "modelFieldNames", "getTags",
29
+ "findNotes", "findCards", "notesInfo", "cardsInfo", "guiBrowse", "sync",
30
+ "createDeck", "canAddNotesWithErrorDetail", "canAddNotes", "addNote", "multi",
31
+ "updateNoteFields", "addTags", "removeTags", "changeDeck", "deleteNotes", "deleteDecks", "exportPackage",
32
+ "storeMediaFile", "getDeckConfig", "setDeckConfigId",
33
+ })
34
+
35
+ SETUP_HINT = (
36
+ "Setup: 1) install Anki Desktop from https://apps.ankiweb.net and open it once; 2) in Anki choose "
37
+ "Tools > Add-ons > Get Add-ons..., enter code 2055492159 (AnkiConnect), click OK; 3) restart Anki."
38
+ )
39
+
40
+
41
+ async def port_open(url: str) -> bool:
42
+ u = urlparse(url)
43
+ try:
44
+ _, writer = await asyncio.wait_for(asyncio.open_connection(u.hostname, u.port), timeout=1.0)
45
+ except (OSError, asyncio.TimeoutError):
46
+ return False
47
+ writer.close()
48
+ return True
49
+
50
+
51
+ async def wait_for_port(url: str, seconds: float) -> bool:
52
+ loop = asyncio.get_running_loop()
53
+ deadline = loop.time() + seconds
54
+ while loop.time() < deadline:
55
+ if await port_open(url):
56
+ return True
57
+ await asyncio.sleep(1.0)
58
+ return False
59
+
60
+
61
+ def launch_command(settings: Settings) -> list[str] | None:
62
+ if settings.launch_command:
63
+ return shlex.split(settings.launch_command)
64
+ if sys.platform == "darwin":
65
+ return ["/usr/bin/open", "-g", "-a", "Anki"] # -g: don't steal focus
66
+ if sys.platform.startswith("win"):
67
+ for candidate in (
68
+ os.path.expandvars(r"%LOCALAPPDATA%\Programs\Anki\anki.exe"),
69
+ os.path.expandvars(r"%ProgramFiles%\Anki\anki.exe"),
70
+ ):
71
+ if os.path.isfile(candidate):
72
+ return [candidate]
73
+ found = shutil.which("anki")
74
+ return [found] if found else None
75
+
76
+
77
+ class Anki:
78
+ """AnkiConnect client. Starts Anki once if it isn't running (unless disabled)."""
79
+
80
+ def __init__(self, settings: Settings):
81
+ self.settings = settings
82
+ self._launch_lock = asyncio.Lock()
83
+
84
+ async def ensure_running(self) -> str | None:
85
+ """'running' if AnkiConnect answers, 'launched' if we started Anki, None if auto-launch is off/unavailable."""
86
+ if not self.settings.auto_launch:
87
+ return None
88
+ async with self._launch_lock:
89
+ if await port_open(self.settings.anki_connect_url):
90
+ return "running"
91
+ cmd = launch_command(self.settings)
92
+ if not cmd:
93
+ raise ToolError(
94
+ "Anki isn't running and couldn't be found on this computer. " + SETUP_HINT +
95
+ " If Anki is installed somewhere unusual, set ANKI_LAUNCH_COMMAND (e.g. 'flatpak run net.ankiweb.Anki')."
96
+ )
97
+ log.info("AnkiConnect not reachable; starting Anki: %s", cmd)
98
+ try:
99
+ if cmd[0] == "/usr/bin/open":
100
+ # macOS `open` returns at once; a non-zero exit means the app isn't installed.
101
+ done = subprocess.run(cmd, capture_output=True, timeout=30) # noqa: S603
102
+ if done.returncode != 0:
103
+ raise ToolError("Anki isn't installed (macOS couldn't find the Anki app). " + SETUP_HINT)
104
+ else:
105
+ kwargs: dict[str, Any] = {"stdin": subprocess.DEVNULL, "stdout": subprocess.DEVNULL,
106
+ "stderr": subprocess.DEVNULL}
107
+ if sys.platform.startswith("win"):
108
+ kwargs["creationflags"] = getattr(subprocess, "DETACHED_PROCESS", 0)
109
+ else:
110
+ kwargs["start_new_session"] = True
111
+ subprocess.Popen(cmd, **kwargs) # noqa: S603 - fixed command or user-configured one
112
+ except (OSError, subprocess.SubprocessError) as e:
113
+ raise ToolError(f"Couldn't start Anki with {cmd}: {e}. " + SETUP_HINT) from None
114
+ if not await wait_for_port(self.settings.anki_connect_url, LAUNCH_WAIT_SECONDS):
115
+ raise ToolError(
116
+ f"Started Anki, but AnkiConnect didn't answer within {LAUNCH_WAIT_SECONDS}s. Either Anki is waiting "
117
+ "at the profile picker or a dialog (check its window), or AnkiConnect isn't installed: in Anki "
118
+ "choose Tools > Add-ons > Get Add-ons..., enter 2055492159, then restart Anki."
119
+ )
120
+ await asyncio.sleep(2.0) # let the profile and add-ons finish loading
121
+ return "launched"
122
+
123
+ async def invoke(self, action: str, timeout: float = 30.0, _retry: bool = True, **params: Any) -> Any:
124
+ if action not in ALLOWED_ACTIONS:
125
+ raise ToolError(f"AnkiConnect action {action!r} is not allowed by this server.")
126
+ if action == "multi":
127
+ for sub in params.get("actions", []):
128
+ if sub.get("action") not in ALLOWED_ACTIONS - {"multi"}:
129
+ raise ToolError(f"AnkiConnect action {sub.get('action')!r} is not allowed by this server.")
130
+ payload: dict[str, Any] = {"action": action, "version": API_VERSION, "params": params}
131
+ if self.settings.anki_connect_api_key:
132
+ payload["key"] = self.settings.anki_connect_api_key
133
+ try:
134
+ # trust_env=False: never route localhost traffic through HTTP(S)_PROXY settings.
135
+ async with httpx.AsyncClient(trust_env=False) as client:
136
+ resp = await client.post(self.settings.anki_connect_url, json=payload, timeout=timeout)
137
+ except httpx.ConnectError:
138
+ if _retry and await self.ensure_running():
139
+ return await self.invoke(action, timeout, _retry=False, **params)
140
+ raise ToolError(
141
+ f"Can't connect to AnkiConnect at {self.settings.anki_connect_url}: Anki isn't running or the "
142
+ "AnkiConnect add-on (code 2055492159) isn't installed. " + SETUP_HINT
143
+ ) from None
144
+ except httpx.TimeoutException:
145
+ raise ToolError(
146
+ f"AnkiConnect didn't answer within {timeout:.0f}s for {action!r}. Anki may be busy or showing a dialog."
147
+ ) from None
148
+ except httpx.HTTPError as e:
149
+ raise ToolError(f"HTTP error talking to AnkiConnect: {e}") from None
150
+ try:
151
+ body = resp.json()
152
+ except ValueError:
153
+ raise ToolError(f"AnkiConnect returned a non-JSON response (HTTP {resp.status_code}).") from None
154
+ if not isinstance(body, dict) or set(body) != {"result", "error"}:
155
+ raise ToolError(f"Unexpected AnkiConnect response for {action!r}: {body!r}")
156
+ if body["error"] is not None:
157
+ raise ToolError(f"AnkiConnect {action} failed: {body['error']}")
158
+ return body["result"]
159
+
160
+
161
+ class Bridge:
162
+ """Client for the optional Anki MCP Bridge add-on (deck/profile rename, AwesomeTTS audio)."""
163
+
164
+ HEADER = "X-Anki-MCP"
165
+
166
+ def __init__(self, settings: Settings, anki: Anki):
167
+ self.settings = settings
168
+ self.anki = anki
169
+
170
+ async def call(self, action: str, timeout: float = 30.0, _retry: bool = True, **params: Any) -> Any:
171
+ try:
172
+ async with httpx.AsyncClient(trust_env=False) as client:
173
+ resp = await client.post(self.settings.bridge_url, json={"action": action, "params": params},
174
+ headers={self.HEADER: "1"}, timeout=timeout)
175
+ except httpx.ConnectError:
176
+ # Only worth waiting if we just started Anki; if Anki was already running, the add-on isn't installed.
177
+ if _retry and await self.anki.ensure_running() == "launched" and await wait_for_port(self.settings.bridge_url, 20):
178
+ return await self.call(action, timeout, _retry=False, **params)
179
+ raise ToolError(
180
+ f"This needs the 'Anki MCP Bridge' add-on, which isn't reachable at {self.settings.bridge_url}. "
181
+ "Install it with the anki_install_bridge tool (or run `anki-mcp-server install-bridge`), then restart Anki."
182
+ ) from None
183
+ except httpx.TimeoutException:
184
+ raise ToolError("The Anki MCP Bridge add-on didn't answer in time; Anki may still be working.") from None
185
+ try:
186
+ body = resp.json()
187
+ except ValueError:
188
+ raise ToolError(f"Bridge returned a non-JSON response (HTTP {resp.status_code}).") from None
189
+ if body.get("error"):
190
+ raise ToolError(f"Anki MCP Bridge: {body['error']}")
191
+ return body.get("result")
192
+
193
+ async def info(self) -> dict | None:
194
+ """Bridge info, or None if the add-on isn't available. Never launches Anki."""
195
+ try:
196
+ return await self.call("info", timeout=5.0, _retry=False)
197
+ except ToolError:
198
+ return None
@@ -0,0 +1,394 @@
1
+ """Anki MCP Bridge: companion add-on for anki-mcp-server (https://github.com/MeisamHakimi/anki-mcp-server).
2
+
3
+ Adds what AnkiConnect can't do: rename decks, rename the profile, and generate audio with the AwesomeTTS add-on
4
+ (if installed). Listens on 127.0.0.1 only (port from the add-on config, default 8766). Requests must carry the
5
+ X-Anki-MCP header and no Origin header, so web pages can't use it.
6
+
7
+ AwesomeTTS has no public API; audio generation drives its internal batch code (tested with AwesomeTTS 1.89.4),
8
+ fills only empty fields, and never returns AwesomeTTS options or API keys.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import importlib
14
+ import json
15
+ import os
16
+ import re
17
+ import threading
18
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
19
+ from typing import Any
20
+
21
+ import aqt
22
+ from aqt import gui_hooks, mw
23
+
24
+ BRIDGE_VERSION = "0.1.0"
25
+ HOST = "127.0.0.1"
26
+ DEFAULT_PORT = 8766
27
+ TESTED_AWESOMETTS_VERSION = "1.89.4"
28
+ MAX_NOTES = 300
29
+ REQUEST_TIMEOUT = 900 # seconds the HTTP thread waits for a batch to finish
30
+ REQUIRED_HEADER = "X-Anki-MCP"
31
+
32
+ _server: ThreadingHTTPServer | None = None
33
+ _busy = threading.Lock()
34
+ _profile_rename: dict[str, Any] = {"state": "none"} # survives the bridge restart during the profile reload
35
+ _BAD_PROFILE_CHARS = re.compile(r'[\\/:*?"<>|\x00-\x1f]')
36
+
37
+
38
+ class BridgeError(Exception):
39
+ pass
40
+
41
+
42
+ def _port() -> int:
43
+ try:
44
+ return int((mw.addonManager.getConfig(__name__) or {}).get("port", DEFAULT_PORT))
45
+ except (TypeError, ValueError):
46
+ return DEFAULT_PORT
47
+
48
+
49
+ def _find_awesometts():
50
+ """The loaded AwesomeTTS package (installed from AnkiWeb as 1436550454, or under another folder name), or None."""
51
+ addons_dir = mw.addonManager.addonsFolder()
52
+ for folder in sorted(os.listdir(addons_dir)):
53
+ if not os.path.isdir(os.path.join(addons_dir, folder, "awesometts")):
54
+ continue
55
+ if mw.addonManager.addonMeta(folder).get("disabled"):
56
+ continue
57
+ try:
58
+ pkg = importlib.import_module(f"{folder}.awesometts")
59
+ except Exception:
60
+ continue
61
+ if hasattr(pkg, "addon"):
62
+ return pkg
63
+ return None
64
+
65
+
66
+ def _awesometts():
67
+ pkg = _find_awesometts()
68
+ if pkg is None:
69
+ raise BridgeError("AwesomeTTS isn't installed/enabled in Anki (or this version isn't compatible).")
70
+ return pkg
71
+
72
+
73
+ def _on_main(fn, timeout: float = 30):
74
+ """Run fn() on Anki's main thread and return its result (blocking the calling worker thread)."""
75
+ box: dict[str, Any] = {}
76
+ done = threading.Event()
77
+
78
+ def run():
79
+ try:
80
+ box["result"] = fn()
81
+ except Exception as e:
82
+ box["error"] = e
83
+ finally:
84
+ done.set()
85
+
86
+ mw.taskman.run_on_main(run)
87
+ if not done.wait(timeout):
88
+ raise BridgeError("Timed out waiting for Anki's main thread (is a dialog open?)")
89
+ if "error" in box:
90
+ raise box["error"]
91
+ return box["result"]
92
+
93
+
94
+ def _info() -> dict:
95
+ att = _find_awesometts()
96
+ return {
97
+ "bridge_version": BRIDGE_VERSION,
98
+ "profile": mw.pm.name,
99
+ "awesometts": getattr(att, "VERSION", "unknown") if att else None,
100
+ "features": ["rename_deck", "rename_profile"] + (["generate_audio"] if att else []),
101
+ }
102
+
103
+
104
+ def _status() -> dict:
105
+ att = _awesometts()
106
+ cfg = att.addon.config
107
+ # Deliberately only non-secret values: service ids, preset/group names, field names. No options or keys.
108
+ return {
109
+ "awesometts_version": getattr(att, "VERSION", None),
110
+ "tested_with": TESTED_AWESOMETTS_VERSION,
111
+ "presets": sorted(cfg["presets"].keys()),
112
+ "groups": sorted(cfg["groups"].keys()),
113
+ "last_service": cfg["last_service"],
114
+ "last_batch_source_field": cfg["last_mass_source"],
115
+ "last_batch_dest_field": cfg["last_mass_dest"],
116
+ }
117
+
118
+
119
+ def _resolve_service(cfg, preset: str | None) -> tuple[str, dict | None, dict | None]:
120
+ """Returns (svc_id, options, group). preset None -> the service/options you last used in AwesomeTTS."""
121
+ if preset:
122
+ if preset.startswith("group:"):
123
+ name = preset[6:]
124
+ if name not in cfg["groups"]:
125
+ raise BridgeError(f"No AwesomeTTS group named {name!r}. Groups: {sorted(cfg['groups'])}")
126
+ return preset, None, cfg["groups"][name]
127
+ if preset not in cfg["presets"]:
128
+ raise BridgeError(f"No AwesomeTTS preset named {preset!r}. Presets: {sorted(cfg['presets'])}")
129
+ p = dict(cfg["presets"][preset])
130
+ svc_id = p.pop("service")
131
+ return svc_id, p, None
132
+ svc_id = cfg["last_service"]
133
+ if svc_id.startswith("group:"):
134
+ return svc_id, None, cfg["groups"].get(svc_id[6:])
135
+ return svc_id, dict(cfg["last_options"].get(svc_id, {})), None
136
+
137
+
138
+ def _generate(params: dict) -> dict:
139
+ note_ids = params.get("note_ids")
140
+ source = params.get("source_field")
141
+ dest = params.get("dest_field")
142
+ preset = params.get("preset")
143
+ if not isinstance(note_ids, list) or not note_ids or not all(isinstance(n, int) for n in note_ids):
144
+ raise BridgeError("note_ids must be a non-empty list of integers")
145
+ if len(note_ids) > MAX_NOTES:
146
+ raise BridgeError(f"Too many notes ({len(note_ids)}); max {MAX_NOTES} per call")
147
+ if not (isinstance(source, str) and source and isinstance(dest, str) and dest):
148
+ raise BridgeError("source_field and dest_field are required")
149
+ if source == dest:
150
+ raise BridgeError("source_field and dest_field must differ")
151
+ if preset is not None and not isinstance(preset, str):
152
+ raise BridgeError("preset must be a string")
153
+
154
+ if not _busy.acquire(blocking=False):
155
+ raise BridgeError("Another audio generation batch is already running")
156
+
157
+ finished = threading.Event()
158
+ result: dict[str, Any] = {"generated": [], "skipped": [], "failed": []}
159
+
160
+ def start(): # runs on the main thread
161
+ att = _awesometts()
162
+ addon = att.addon
163
+ cfg = addon.config
164
+ svc_id, options, group = _resolve_service(cfg, preset)
165
+ result["service"] = svc_id
166
+ result["preset"] = preset or "(last used in AwesomeTTS)"
167
+ want_human = (cfg["filenames_human"] or "{{text}}") if cfg["filenames"] == "human" else False
168
+ throttle = {"calls": {}, "sleep": cfg["throttle_sleep"], "threshold": cfg["throttle_threshold"]}
169
+
170
+ queue = []
171
+ for nid in note_ids:
172
+ try:
173
+ note = mw.col.get_note(nid)
174
+ except Exception:
175
+ result["failed"].append({"note_id": nid, "error": "note not found"})
176
+ continue
177
+ if source not in note or dest not in note:
178
+ result["skipped"].append({"note_id": nid, "reason": f"note type lacks {source!r} or {dest!r} field"})
179
+ continue
180
+ if note[dest].strip():
181
+ result["skipped"].append({"note_id": nid, "reason": f"{dest!r} is not empty; left unchanged"})
182
+ continue
183
+ text = addon.strip.from_note(note[source])
184
+ if not text:
185
+ result["skipped"].append({"note_id": nid, "reason": f"{source!r} has no speakable text"})
186
+ continue
187
+ queue.append((nid, text))
188
+
189
+ def finish():
190
+ _busy.release()
191
+ finished.set()
192
+
193
+ def next_note():
194
+ if not queue:
195
+ finish()
196
+ return
197
+ if throttle["calls"] and max(throttle["calls"].values()) >= throttle["threshold"]:
198
+ throttle["calls"].clear()
199
+ aqt.qt.QTimer.singleShot(throttle["sleep"] * 1000, next_note)
200
+ return
201
+ nid, text = queue.pop(0)
202
+ note = mw.col.get_note(nid)
203
+
204
+ def okay(path):
205
+ fresh = mw.col.get_note(nid)
206
+ if fresh[dest].strip(): # changed meanwhile; never overwrite
207
+ result["skipped"].append({"note_id": nid, "reason": f"{dest!r} filled meanwhile; left unchanged"})
208
+ return
209
+ filename = mw.col.media.add_file(path)
210
+ fresh[dest] = fresh[dest] + " [sound:%s]" % filename
211
+ mw.col.update_note(fresh)
212
+ result["generated"].append({"note_id": nid, "text": text, "file": filename})
213
+
214
+ def fail(exception, text_=None):
215
+ result["failed"].append({"note_id": nid, "text": text, "error": re.sub(r"\s+", " ", str(exception)).strip()})
216
+
217
+ def miss(svc, count):
218
+ throttle["calls"][svc] = throttle["calls"].get(svc, 0) + count
219
+
220
+ callbacks = dict(okay=okay, fail=fail, miss=miss,
221
+ then=lambda: aqt.qt.QTimer.singleShot(0, next_note))
222
+ try:
223
+ if group is not None:
224
+ addon.router.group(text=text, group=group, presets=cfg["presets"],
225
+ callbacks=callbacks, want_human=want_human, note=note)
226
+ else:
227
+ addon.router(svc_id=svc_id, text=text, options=options,
228
+ callbacks=callbacks, want_human=want_human, note=note)
229
+ except Exception as e: # synchronous failure: record and continue
230
+ fail(e)
231
+ aqt.qt.QTimer.singleShot(0, next_note)
232
+
233
+ next_note()
234
+
235
+ try:
236
+ _on_main(start)
237
+ except Exception:
238
+ _busy.release()
239
+ raise
240
+ if not finished.wait(REQUEST_TIMEOUT):
241
+ result["warning"] = "Timed out waiting; the batch is still running inside Anki. Check again later."
242
+ result["counts"] = {k: len(result[k]) for k in ("generated", "skipped", "failed")}
243
+ return result
244
+
245
+
246
+ def _rename_deck(params: dict) -> dict:
247
+ old, new = params.get("name"), params.get("new_name")
248
+ for label, v in (("name", old), ("new_name", new)):
249
+ if not isinstance(v, str) or not v.strip() or '"' in v or any(ord(c) < 32 for c in v):
250
+ raise BridgeError(f"{label} must be a non-empty deck name without quotes/control characters")
251
+ old, new = old.strip(), new.strip()
252
+
253
+ def run():
254
+ did = mw.col.decks.id_for_name(old)
255
+ if not did:
256
+ raise BridgeError(f"deck {old!r} not found")
257
+ existing = mw.col.decks.id_for_name(new)
258
+ if existing and existing != did: # same id = case-only rename, which is fine
259
+ raise BridgeError(f"a deck named {new!r} already exists")
260
+ mw.col.decks.rename(did, new)
261
+ mw.reset()
262
+ return {"deck_id": did, "old_name": old, "new_name": mw.col.decks.name(did)}
263
+
264
+ return _on_main(run)
265
+
266
+
267
+ def _profile_info() -> dict:
268
+ def run():
269
+ return {"current": mw.pm.name, "profiles": mw.pm.profiles(), "last_rename": dict(_profile_rename)}
270
+ return _on_main(run)
271
+
272
+
273
+ def _rename_profile(params: dict) -> dict:
274
+ """Validate, then (after replying) close the collection, rename the profile and reopen it."""
275
+ new = params.get("new_name")
276
+ expected = params.get("expected_current")
277
+ if not isinstance(new, str) or not new.strip():
278
+ raise BridgeError("new_name must be a non-empty string")
279
+ new = new.strip()
280
+ if len(new) > 60 or _BAD_PROFILE_CHARS.search(new) or new.startswith(".") or new.lower() == "addons21":
281
+ raise BridgeError('Invalid profile name: max 60 chars, no \\ / : * ? " < > | or control characters, not "addons21"')
282
+ if _busy.locked():
283
+ raise BridgeError("An audio generation batch is running; try again when it finishes")
284
+
285
+ def check():
286
+ cur = mw.pm.name
287
+ if expected and cur != expected:
288
+ raise BridgeError(f"Current profile is {cur!r}, not {expected!r}")
289
+ if new == cur:
290
+ raise BridgeError("That is already the profile name")
291
+ if any(p.lower() == new.lower() for p in mw.pm.profiles() if p != cur):
292
+ raise BridgeError(f"A profile named {new!r} already exists")
293
+ target = os.path.join(mw.pm.base, new)
294
+ if os.path.exists(target) and target.lower() != mw.pm.profileFolder(create=False).lower():
295
+ raise BridgeError(f"Folder {target!r} already exists")
296
+ return cur
297
+
298
+ old = _on_main(check)
299
+ _profile_rename.clear()
300
+ _profile_rename.update({"state": "pending", "old": old, "new": new})
301
+
302
+ def do_rename(): # main thread, after the HTTP reply has gone out
303
+ def after_unload():
304
+ try:
305
+ mw.pm.rename(new)
306
+ if mw.pm.name != new:
307
+ raise BridgeError("Anki refused the rename (see the Anki window)")
308
+ mw.pm.load(new)
309
+ _profile_rename.update(state="done")
310
+ except Exception as e:
311
+ _profile_rename.update(state="error", error=str(e))
312
+ # Anki's pm.rename doesn't restore pm.name if the folder rename raises; reopening under the new
313
+ # name would then create an empty profile. Go back to the old one.
314
+ if mw.pm.name != old:
315
+ mw.pm.name = old
316
+ mw.pm.load(old)
317
+ mw.loadProfile() # reopen (under the new name if the rename worked)
318
+
319
+ mw.unloadProfile(after_unload)
320
+
321
+ mw.taskman.run_on_main(lambda: aqt.qt.QTimer.singleShot(700, do_rename))
322
+ return {"scheduled": True, "old": old, "new": new}
323
+
324
+
325
+ class _Handler(BaseHTTPRequestHandler):
326
+ server_version = f"AnkiMCPBridge/{BRIDGE_VERSION}"
327
+
328
+ def log_message(self, *args):
329
+ pass
330
+
331
+ def _reply(self, code: int, body: dict):
332
+ data = json.dumps(body).encode()
333
+ self.send_response(code)
334
+ self.send_header("Content-Type", "application/json")
335
+ self.send_header("Content-Length", str(len(data)))
336
+ self.end_headers()
337
+ self.wfile.write(data)
338
+
339
+ def do_POST(self):
340
+ # Browsers always send Origin on cross-site POSTs and can't add custom headers without a CORS
341
+ # preflight (which we never answer), so these two checks keep web pages out.
342
+ if self.headers.get("Origin") or self.headers.get(REQUIRED_HEADER) != "1":
343
+ return self._reply(403, {"result": None, "error": "forbidden"})
344
+ try:
345
+ length = int(self.headers.get("Content-Length", "0"))
346
+ if length > 1_000_000:
347
+ raise BridgeError("request too large")
348
+ req = json.loads(self.rfile.read(length) or b"{}")
349
+ action, params = req.get("action"), req.get("params") or {}
350
+ if action == "info":
351
+ res = _on_main(_info)
352
+ elif action in ("tts_status", "status"): # "status": older client name
353
+ res = _on_main(_status)
354
+ elif action == "generate":
355
+ res = _generate(params)
356
+ elif action == "rename_deck":
357
+ res = _rename_deck(params)
358
+ elif action == "profile_info":
359
+ res = _profile_info()
360
+ elif action == "rename_profile":
361
+ res = _rename_profile(params)
362
+ else:
363
+ raise BridgeError(f"unknown action {action!r}")
364
+ self._reply(200, {"result": res, "error": None})
365
+ except Exception as e:
366
+ self._reply(200, {"result": None, "error": str(e)})
367
+
368
+ def do_GET(self):
369
+ self._reply(405, {"result": None, "error": "use POST"})
370
+
371
+
372
+ def _start():
373
+ global _server
374
+ if _server is not None:
375
+ return
376
+ try:
377
+ _server = ThreadingHTTPServer((HOST, _port()), _Handler)
378
+ except OSError as e:
379
+ print(f"[anki_mcp_bridge] could not bind {HOST}:{_port()}: {e}")
380
+ return
381
+ _server.daemon_threads = True
382
+ threading.Thread(target=_server.serve_forever, name="anki-mcp-bridge", daemon=True).start()
383
+
384
+
385
+ def _stop():
386
+ global _server
387
+ if _server is not None:
388
+ _server.shutdown()
389
+ _server.server_close()
390
+ _server = None
391
+
392
+
393
+ gui_hooks.profile_did_open.append(_start)
394
+ gui_hooks.profile_will_close.append(_stop)
@@ -0,0 +1,3 @@
1
+ {
2
+ "port": 8766
3
+ }
@@ -0,0 +1 @@
1
+ **port**: TCP port the bridge listens on (127.0.0.1 only). Default `8766`. If you change it, set `ANKI_MCP_BRIDGE_URL=http://127.0.0.1:<port>` for anki-mcp-server too. Restart Anki after changing.
@@ -0,0 +1,8 @@
1
+ {
2
+ "package": "anki_mcp_bridge",
3
+ "name": "Anki MCP Bridge",
4
+ "conflicts": [],
5
+ "mod": 0,
6
+ "min_point_version": 231000,
7
+ "homepage": "https://github.com/MeisamHakimi/anki-mcp-server"
8
+ }