monash-study-kit 1.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,713 @@
1
+ """本地 MCP 服务器(stdio):让 Claude Desktop / Claude Code 直接查 Moodle 和 Ed。
2
+
3
+ monash mcp # 由 Claude 启动,不用自己跑;monash setup 会帮你把它加进 Claude
4
+
5
+ 只读:不交作业、不做测验、不发帖。唯一会“动”的是 start_monash_login(在你电脑上打开登录窗口)
6
+ 和 sync_monash_data(在后台同步)。
7
+
8
+ 服务器开着的时候(也就是 Claude 开着的时候)在后台做两件事:
9
+ * 每 20 分钟给 Moodle 会话续一次期,因为 Moodle 空闲 4 小时就把你踢出去;
10
+ * 每小时同步一次 Ed 和 Moodle,再更新全文索引。
11
+ 后台的输出写进 mcp.log,绝不能写 stdout(stdout 是给 Claude 的协议通道)。
12
+
13
+ MCP 本身不消耗额度,只有工具返回的内容进上下文才算,所以每个工具的返回都有长度上限。
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import re
19
+ import sys
20
+ import threading
21
+ import time
22
+ import traceback
23
+ import zipfile
24
+ from datetime import datetime, timedelta, timezone
25
+
26
+ from . import __version__, edlib, edquery, help_menu, jobs, textextract
27
+ from . import features as F
28
+ from .edlib import EdAuthError
29
+ from .moodlelib import COOKIE_FILE, FILES_DIR, MoodleAuthError, MoodleClient, db_connect
30
+ from .paths import HOME, LOG_FILE, ensure_private_dir, load_settings
31
+ from .syncer import course_code, course_folder
32
+
33
+ PROTOCOL = "2025-06-18"
34
+ MAX_TEXT = 20000
35
+ MAX_OUT = 60000
36
+ ED_CURSOR = "mcp" # 和命令行的游标(cli)分开记
37
+ TEXT_EXT = {".txt", ".md", ".py", ".hs", ".ts", ".js", ".java", ".c", ".cpp", ".h", ".sh", ".sql", ".csv",
38
+ ".json", ".mzn", ".dzn", ".r", ".rmd", ".html", ".css", ".yaml", ".yml", ".toml", ".ipynb"}
39
+
40
+ INSTRUCTIONS = (
41
+ "Monash 学习助手(Moodle + Ed,只读,不能交作业、做测验、发帖)。“这周要做什么”先用 get_study_todo;"
42
+ "知识点在哪份资料用 search_content 再 read_file;Ed 新消息用 list_ed_updates。数据每小时后台同步。"
43
+ "报“Moodle 登录已过期”时问用户要不要登录,同意就调 start_monash_login;Ed 令牌失效请用户在终端运行 "
44
+ "monash login ed(令牌别贴进聊天)。用户问“你能做什么/help”时,按类别列出功能并各给一句示例问法,"
45
+ "并告诉他输入框“+”菜单里有 monash 的预设提示。")
46
+
47
+
48
+ # ---------------------------------------------------------------- 日志
49
+
50
+ def log(msg: str) -> None:
51
+ try:
52
+ LOG_FILE.parent.mkdir(parents=True, exist_ok=True)
53
+ if LOG_FILE.exists() and LOG_FILE.stat().st_size > 1_000_000:
54
+ LOG_FILE.replace(LOG_FILE.with_suffix(".log.1"))
55
+ with open(LOG_FILE, "a", encoding="utf-8") as f:
56
+ f.write(f"{time.strftime('%Y-%m-%d %H:%M:%S')} {msg}\n")
57
+ except OSError:
58
+ pass
59
+
60
+
61
+ # ---------------------------------------------------------------- Moodle 客户端
62
+
63
+ _client: MoodleClient | None = None
64
+ _client_stamp: float | None = None
65
+
66
+
67
+ def client() -> MoodleClient:
68
+ """重新登录后 cookie 文件会变,这时换一个新客户端,别拿着旧会话一直报过期。"""
69
+ global _client, _client_stamp
70
+ try:
71
+ stamp = COOKIE_FILE.stat().st_mtime
72
+ except OSError:
73
+ stamp = None
74
+ if _client is None or stamp != _client_stamp:
75
+ _client = MoodleClient()
76
+ _client_stamp = stamp
77
+ return _client
78
+
79
+
80
+ def _client_or_none() -> MoodleClient | None:
81
+ try:
82
+ return client()
83
+ except MoodleAuthError:
84
+ return None
85
+
86
+
87
+ def _courses() -> list[dict]:
88
+ return client().courses()
89
+
90
+
91
+ def _course(ref: str) -> dict:
92
+ return F.find_course(_courses(), ref)
93
+
94
+
95
+ def _tracked_or_current() -> list[dict]:
96
+ """跟踪的课;从没选过就是本学期的课(和 monash sync 用的同一套规则)。"""
97
+ con = db_connect()
98
+ return jobs.tracked_courses(con, jobs.load_courses(client(), con))
99
+
100
+
101
+ def _clip(text: str, limit: int, offset: int = 0) -> str:
102
+ part = text[offset:offset + limit]
103
+ if offset + limit < len(text):
104
+ part += f"\n\n[还有 {len(text) - offset - limit} 字符未显示,用 offset={offset + limit} 继续读]"
105
+ return part
106
+
107
+
108
+ # ---------------------------------------------------------------- 后台:登录、同步、续期
109
+
110
+ _login = {"running": False, "result": None, "error": None}
111
+ _sync_thread: threading.Thread | None = None
112
+
113
+
114
+ def _run_login():
115
+ from .browser_login import LoginFailed, ensure_login
116
+ try:
117
+ r = ensure_login(log=log)
118
+ _login.update(result=r, error=None)
119
+ log(f"登录成功,会话还剩 {r.get('time_remaining')} 秒")
120
+ start_sync()
121
+ except (LoginFailed, OSError) as e:
122
+ _login.update(error=str(e))
123
+ log(f"登录失败:{e}")
124
+ finally:
125
+ _login["running"] = False
126
+
127
+
128
+ def start_sync() -> bool:
129
+ global _sync_thread
130
+ if _sync_thread and _sync_thread.is_alive():
131
+ return False
132
+ _sync_thread = threading.Thread(target=_safe_sync, name="sync", daemon=True)
133
+ _sync_thread.start()
134
+ return True
135
+
136
+
137
+ def _safe_sync():
138
+ try:
139
+ st = jobs.sync_all(log=log)
140
+ log(f"同步完成:{json.dumps(st.get('errors') or {}, ensure_ascii=False)}")
141
+ except Exception: # noqa: BLE001
142
+ log("同步出错:\n" + traceback.format_exc())
143
+
144
+
145
+ def _update_hint() -> str:
146
+ from . import update_check
147
+ msg = update_check.notice()
148
+ return f"({msg},合适的时候提醒用户一次。)" if msg else ""
149
+
150
+
151
+ def _background():
152
+ """Claude 开着的时候:按设置定时同步、给 Moodle 续期。"""
153
+ settings = load_settings()
154
+ every_sync = max(0.25, float(settings.get("auto_sync_hours") or 1)) * 3600
155
+ every_touch = max(5, int(settings.get("keepalive_minutes") or 20)) * 60
156
+ time.sleep(5) # 先让 Claude 把握手做完
157
+ last_touch = 0.0
158
+ while True:
159
+ try:
160
+ last_touch = _background_tick(every_sync, every_touch, last_touch)
161
+ except Exception: # noqa: BLE001
162
+ # 这个线程一死,后台同步和续期就停到 Claude 重启为止,所以出什么错都只记日志
163
+ log("后台任务出错:\n" + traceback.format_exc())
164
+ time.sleep(60)
165
+
166
+
167
+ def _background_tick(every_sync: float, every_touch: float, last_touch: float) -> float:
168
+ """后台循环的一轮。返回更新后的“上次续期时间”。"""
169
+ from . import update_check
170
+ if update_check.enabled():
171
+ update_check.refresh() # 自己控制频率:一天最多真查一次
172
+ if jobs.hours_since_sync() * 3600 >= every_sync:
173
+ start_sync()
174
+ if time.time() - last_touch >= every_touch:
175
+ last_touch = time.time()
176
+ try:
177
+ jobs.keepalive()
178
+ except MoodleAuthError:
179
+ pass # 没登录或已过期:等用户登录,不自己弹窗
180
+ except Exception as e: # noqa: BLE001
181
+ log(f"续期失败:{e}")
182
+ return last_touch
183
+
184
+
185
+ # ---------------------------------------------------------------- 管理工具
186
+
187
+ def t_status(args):
188
+ if args.get("full"):
189
+ from . import doctor
190
+ # Claude Code 那项要起一个 claude 子进程,最多 20 秒,在这里没必要
191
+ return doctor.run(skip=("check_claude_code",))
192
+ st = jobs.read_status()
193
+ moodle = {"logged_in": False}
194
+ try:
195
+ c = client()
196
+ left = c.time_remaining()
197
+ moodle = {"logged_in": True, "time_remaining_min": (left or 0) // 60}
198
+ except MoodleAuthError as e:
199
+ moodle["problem"] = str(e)
200
+ moodle["calendar_fallback"] = bool(F.load_calendar_url())
201
+ ed = {"token_set": jobs.have_ed_token()}
202
+ try:
203
+ con = edlib.db_connect()
204
+ ed["tracked_courses"] = [edquery._short_code(r["code"]) for r in
205
+ con.execute("SELECT code FROM courses WHERE tracked=1")]
206
+ con.close()
207
+ except Exception: # noqa: BLE001
208
+ pass
209
+ from . import update_check
210
+ return {"moodle": moodle, "ed": ed, "last_sync": st.get("last_sync"), "sync_errors": st.get("errors"),
211
+ "update_available": update_check.available(),
212
+ "syncing": bool(_sync_thread and _sync_thread.is_alive()),
213
+ "login_window": {"running": _login["running"], "last_error": _login["error"]},
214
+ "files_dir": str(FILES_DIR), "version": __version__}
215
+
216
+
217
+ def t_login(args):
218
+ from .browser_login import session_ok
219
+ if _login["running"]:
220
+ return "登录窗口已经开着了,请用户在窗口里完成登录。"
221
+ if session_ok():
222
+ return "Moodle 已经是登录状态,不用重新登录。"
223
+ _login.update(running=True, error=None)
224
+ threading.Thread(target=_run_login, name="login", daemon=True).start()
225
+ return ("正在用户电脑上打开 Moodle 登录窗口(如果 Okta 还记得登录,会在后台自动完成、不弹窗)。"
226
+ "请用户在窗口里登录 Monash,完成后窗口会自动关闭,并开始同步。"
227
+ "告诉用户登录好之后说一声,再调用 get_monash_status 确认。")
228
+
229
+
230
+ def t_sync(args):
231
+ if start_sync():
232
+ return "已在后台开始同步(第一次可能要几分钟)。之后用 get_monash_status 看结果。"
233
+ return "已经在同步了,稍后用 get_monash_status 看结果。"
234
+
235
+
236
+ # ---------------------------------------------------------------- Moodle 工具
237
+
238
+ def t_todo(args):
239
+ from . import todo
240
+ c = _client_or_none()
241
+ try:
242
+ courses = _tracked_or_current() if c else []
243
+ except MoodleAuthError:
244
+ courses = []
245
+ from . import update_check
246
+ out = todo.build(c, courses, int(args.get("days", 7)))
247
+ if (msg := update_check.notice()):
248
+ out["notes"].append(msg)
249
+ return out
250
+
251
+
252
+ def t_courses(args):
253
+ """两边的课一起列。Moodle 没登录时用本地库里记下的课程表。"""
254
+ try:
255
+ moodle = [{"id": c["id"], "code": course_code(c), "name": course_folder(c)} for c in _courses()]
256
+ except MoodleAuthError:
257
+ con = db_connect()
258
+ moodle = [{"id": r["id"], "code": r["code"], "name": r["folder"], "tracked": bool(r["tracked"])}
259
+ for r in con.execute("SELECT id, code, folder, tracked FROM courses ORDER BY id DESC")]
260
+ return {"moodle": moodle, "ed": edquery.courses(_ed())}
261
+
262
+
263
+ def t_due(args):
264
+ course = _course(args["course"]) if args.get("course") else None
265
+ return [{k: d[k] for k in ("course", "activity", "name", "kind", "due", "overdue", "action", "url", "source")}
266
+ for d in F.due(_client_or_none(), int(args.get("days", 14)), course)]
267
+
268
+
269
+ def t_assignments(args):
270
+ cs = [_course(args["course"])] if args.get("course") else _tracked_or_current()
271
+ items = [a for c in cs for a in F.assignments(client(), c)]
272
+ return [a for a in items if a["missing"]] if args.get("missing_only") else items
273
+
274
+
275
+ def t_messages(args):
276
+ if args.get("conversation_id"):
277
+ return F.conversation_messages(client(), int(args["conversation_id"]), int(args.get("limit", 30)))
278
+ return F.conversations(client(), int(args.get("limit", 20)))
279
+
280
+
281
+ def t_search_content(args):
282
+ from . import content_index as CI
283
+ course = args.get("course")
284
+ code = None
285
+ if course:
286
+ m = re.search(r"[A-Za-z]{3}\d{4}", course)
287
+ code = m.group(0).upper() if m else course_code(_course(course))
288
+ return CI.search(db_connect(), args["query"], code, min(int(args.get("limit", 15)), 50))
289
+
290
+
291
+ def t_grades(args):
292
+ if not args.get("course"):
293
+ return F.grades_overview(client())
294
+ return F.grades(client(), _course(args["course"])["id"], bool(args.get("graded_only")))
295
+
296
+
297
+ def t_forum(args):
298
+ """不给 query:最近的课程公告(含正文);给了:搜 Moodle 论坛。"""
299
+ limit = int(args.get("limit", 5 if args.get("query") is None else 10))
300
+ if args.get("query"):
301
+ cid = _course(args["course"])["id"] if args.get("course") else 1
302
+ res = F.forum_search(client(), args["query"], cid, limit)
303
+ for r in res:
304
+ r["text"] = r["text"][:800]
305
+ return res
306
+ courses = [_course(args["course"])] if args.get("course") else _tracked_or_current()
307
+ out = []
308
+ for co in courses:
309
+ out += F.news(client(), co, min(limit, 5))
310
+ out.sort(key=lambda x: -(x.get("time_ts") or 0))
311
+ for n in out:
312
+ n["text"] = n["text"][:1500]
313
+ n.pop("time_ts", None)
314
+ return out[:limit * 2]
315
+
316
+
317
+ def _local_course_id(ref: str) -> int:
318
+ """list_files 只读本地库,会话过期时也能用:先按本地课程表找,找不到再问 Moodle。"""
319
+ con = db_connect()
320
+ ref_u = ref.strip().upper()
321
+ for r in con.execute("SELECT id, code, fullname FROM courses ORDER BY id DESC"):
322
+ if str(r["id"]) == ref.strip() or (r["code"] or "") == ref_u or ref.lower() in (r["fullname"] or "").lower():
323
+ return r["id"]
324
+ return _course(ref)["id"]
325
+
326
+
327
+ def t_list_files(args):
328
+ con = db_connect()
329
+ if args.get("links"):
330
+ if not args.get("course"):
331
+ raise ValueError("links=true 要同时给 course")
332
+ q, params = "SELECT folder, section, title, url FROM links WHERE course_id=?", [_local_course_id(args["course"])]
333
+ if args.get("week"):
334
+ q += " AND folder LIKE ?"
335
+ params.append(f"Week {int(args['week']):02d}%")
336
+ return [dict(r) for r in con.execute(q + " ORDER BY folder, rowid LIMIT 200", params)]
337
+ q = "SELECT f.path, f.size, f.title, f.synced_at FROM files f WHERE f.removed_at IS NULL"
338
+ params = []
339
+ if args.get("course"):
340
+ q += " AND f.course_id=?"
341
+ params.append(_local_course_id(args["course"]))
342
+ if args.get("week"):
343
+ q += " AND f.path LIKE ?"
344
+ params.append(f"%/Week {int(args['week']):02d}%")
345
+ if args.get("query"):
346
+ q += " AND (f.path LIKE ? OR f.title LIKE ?)"
347
+ params += [f"%{args['query']}%"] * 2
348
+ if args.get("since_days"):
349
+ # “这周新上了什么”:按同步时间过滤,新的排前面(老师换过文件也会重新同步,也算新的)
350
+ since = datetime.now(timezone.utc) - timedelta(days=int(args["since_days"]))
351
+ q += " AND f.synced_at >= ? AND f.path NOT LIKE '%.transcript.md'"
352
+ params.append(since.isoformat(timespec="seconds"))
353
+ rows = con.execute(q + " ORDER BY f.synced_at DESC LIMIT 200", params).fetchall()
354
+ return [{"path": r["path"], "bytes": r["size"], "activity": r["title"], "synced_at": r["synced_at"]}
355
+ for r in rows]
356
+ rows = con.execute(q + " ORDER BY f.path LIMIT 200", params).fetchall()
357
+ if not rows and not con.execute("SELECT 1 FROM files LIMIT 1").fetchone():
358
+ return {"files": [], "note": "还没同步过 Moodle 课件(没登录,或者第一次同步还没跑完;看 get_monash_status)"}
359
+ return [{"path": r["path"], "bytes": r["size"], "activity": r["title"]} for r in rows]
360
+
361
+
362
+ def t_read_file(args):
363
+ """读一个已同步的课件:文本直接给;PDF 按页抽文字;docx/pptx 抽文字;zip 先列清单。"""
364
+ from . import content_index as CI
365
+ rel = args["path"].replace("\\", "/")
366
+ path = CI.resolve_readable(rel) # 只允许课件目录和 Ed 课件目录里的文件
367
+ limit = min(int(args.get("max_chars", MAX_TEXT)), 100000)
368
+ offset = int(args.get("offset", 0))
369
+ ext = path.suffix.lower()
370
+ if ext in TEXT_EXT:
371
+ if path.stat().st_size > CI.MAX_FILE:
372
+ raise ValueError(f"文件太大(超过 {CI.MAX_FILE >> 20} MB),没法当文字读")
373
+ text = path.read_text(encoding="utf-8", errors="replace")
374
+ if ext == ".ipynb":
375
+ nb = json.loads(text)
376
+ text = "\n\n".join(("# [markdown]\n" if c.get("cell_type") == "markdown" else "# [code]\n")
377
+ + "".join(c.get("source", [])) for c in nb.get("cells", []))
378
+ return _clip(text, limit, offset)
379
+ if ext == ".pdf":
380
+ pages = textextract.pdf_pages(path)
381
+ if not any(p.strip() for p in pages):
382
+ raise ValueError("这个 PDF 抽不出文字(可能是扫描件或纯图片)")
383
+ return _clip("\n\n".join(f"--- 第 {i} 页 ---\n{p}" for i, p in enumerate(pages, 1)), limit, offset)
384
+ if ext == ".zip":
385
+ with zipfile.ZipFile(path) as z:
386
+ names = z.namelist()
387
+ inner = args.get("inner")
388
+ if not inner:
389
+ return {"zip": rel, "files": names[:300]}
390
+ if inner not in names:
391
+ raise ValueError(f"zip 里没有 {inner}")
392
+ # 按解压后的大小限制,压缩包里一个很小的条目也可能解出好几 GB
393
+ with z.open(inner) as f:
394
+ data = f.read(CI.MAX_FILE + 1)
395
+ if len(data) > CI.MAX_FILE:
396
+ raise ValueError(f"zip 里的 {inner} 太大(超过 {CI.MAX_FILE >> 20} MB),没法当文字读")
397
+ return _clip(data.decode(errors="replace"), limit, offset)
398
+ if ext in (".docx", ".pptx"):
399
+ parts = textextract.office_parts(path)
400
+ return _clip("\n\n".join(f"[{loc}] {t}" if loc else t for loc, t in parts), limit, offset)
401
+ raise ValueError(f"{ext} 这种文件读不了文字;文件在 {path}")
402
+
403
+
404
+ # ---------------------------------------------------------------- Ed 工具
405
+
406
+ def _ed():
407
+ return edlib.db_connect()
408
+
409
+
410
+ def _slim(rows: list[dict]) -> list[dict]:
411
+ keep = ("ref", "id", "course", "type", "title", "category", "subcategory", "author", "created_at",
412
+ "reply_count", "is_pinned", "is_private", "is_answered", "is_staff_answered",
413
+ "snippet", "match_in", "url", "is_starred", "is_watched", "is_mine", "new_reply_count")
414
+ return [{k: r[k] for k in keep if r.get(k) not in (None, False, "")} for r in rows]
415
+
416
+
417
+ def _with_replies(t: dict) -> dict:
418
+ return dict(_slim([t])[0], new_replies=[{**r, "text": (r["text"] or "")[:1200]} for r in t["new_replies"]])
419
+
420
+
421
+ def t_ed_updates(args):
422
+ """上次调用之后的新帖/新回复 + 我的帖子(发的/关注的/收藏的)有没看过的回复。"""
423
+ conn = _ed()
424
+ d = edquery.new_activity(conn, args.get("since"), args.get("course"), cursor=ED_CURSOR,
425
+ advance=not args.get("peek", False), limit=int(args.get("limit", 30)))
426
+ seen = {t["id"] for t in d["new_threads"]} | {t["id"] for t in d["threads_with_new_replies"]}
427
+ mine = [t for t in edquery.following(conn, args.get("course"), 20)
428
+ if (t["is_mine"] or t["is_watched"] or t["is_starred"]) and t["id"] not in seen]
429
+ return {"since": d["since"], "last_sync": d["last_sync"], "new_threads": _slim(d["new_threads"]),
430
+ "threads_with_new_replies": [_with_replies(t) for t in d["threads_with_new_replies"]],
431
+ "my_threads_with_unread_replies": [_with_replies(t) for t in mine]}
432
+
433
+
434
+ # only= 的每个选项对应帖子上的哪个字段(和 edquery.STATE_FILTERS 的 SQL 一一对应,搜索结果在 Python 里过滤)
435
+ ONLY_MATCH = {"starred": lambda r: r.get("is_starred"), "watching": lambda r: r.get("is_watched"),
436
+ "unseen": lambda r: not r.get("is_seen"), "mine": lambda r: r.get("is_mine"),
437
+ "unread_replies": lambda r: (r.get("new_reply_count") or 0) > 0}
438
+
439
+
440
+ def t_ed_threads(args):
441
+ only = args.get("only")
442
+ if only and only not in ONLY_MATCH:
443
+ raise ValueError(f"only 只能是 {'/'.join(edquery.STATE_FILTERS)}")
444
+ limit = min(int(args.get("limit", 15)), 50)
445
+ if args.get("query"):
446
+ rows = edquery.search(_ed(), args["query"], args.get("course"), args.get("since"),
447
+ args.get("category"), args.get("type"), limit)
448
+ return _slim([r for r in rows if not only or ONLY_MATCH[only](r)])
449
+ rows = edquery.list_threads(_ed(), args.get("course"), args.get("since"), args.get("category"),
450
+ args.get("type"), bool(args.get("unanswered")), limit, int(args.get("offset", 0)),
451
+ [only] if only else [])
452
+ return _slim(rows)
453
+
454
+
455
+ def t_ed_thread(args):
456
+ from . import edsync
457
+ conn = _ed()
458
+ note = ""
459
+ if args.get("live"):
460
+ try:
461
+ tid = edquery.resolve_thread(conn, args["thread"])
462
+ except edquery.NotFound:
463
+ if not str(args["thread"]).isdigit():
464
+ raise
465
+ tid = int(args["thread"])
466
+ try:
467
+ edsync.refresh_thread(conn, tid)
468
+ except edlib.EdForbidden:
469
+ note = "[注意:Ed 上已经看不到这个帖子了(被删除或改成私密),下面是库里的旧副本]\n\n"
470
+ args = dict(args, thread=str(tid))
471
+ t = edquery.get_thread(conn, args["thread"])
472
+ return _clip(note + edquery.thread_markdown(t), min(int(args.get("max_chars", 30000)), 100000),
473
+ int(args.get("offset", 0)))
474
+
475
+
476
+ def t_ed_lessons(args):
477
+ clip = lambda text: _clip(text, min(int(args.get("max_chars", 30000)), 100000), int(args.get("offset", 0))) # noqa: E731
478
+ if args.get("lesson"):
479
+ from . import lesson_reader
480
+ return clip(lesson_reader.lesson_markdown(_ed(), args["course"], args["lesson"])["markdown"])
481
+ if args.get("quiz"):
482
+ rows = edquery.quizzes(_ed(), args["course"], args.get("module"), args.get("status"))
483
+ return _clip(edquery.quiz_markdown(rows), min(int(args.get("max_chars", 30000)), 100000),
484
+ int(args.get("offset", 0)))
485
+ rows = edquery.lessons(_ed(), args["course"], args.get("module"), args.get("status"))
486
+ for r in rows:
487
+ for f in r["files"]:
488
+ f.pop("local_path", None)
489
+ return rows
490
+
491
+
492
+ # ---------------------------------------------------------------- 工具表
493
+ #
494
+ # 工具清单每次对话都要整份发给 Claude(不调用也算额度),所以:能合并的合并,说明写短,
495
+ # 只给容易误解的参数补短 schema 说明。改完跑 tests/test_mcp.py 里的体积测试。
496
+
497
+ S, I, B = "string", "integer", "boolean"
498
+ STATUS = ("completed", "attempted", "unattempted")
499
+
500
+ TOOLS = {
501
+ "get_study_todo": (t_todo, "综合查看 Moodle/Ed 待办:截止、漏交、Lesson、公告和未读回复;问“这周要做/交什么”用。只查截止用 list_moodle_due;查提交状态用 list_moodle_assignments。返回分类待办。",
502
+ {"days": (I, False)}),
503
+ "get_monash_status": (t_status, "诊断登录、同步、索引和错误状态;报错或“用不了”时用。只需刷新数据用 sync_monash_data;登录过期且用户同意后用 start_monash_login。返回状态摘要或完整体检。",
504
+ {"full": (B, False)}),
505
+ "start_monash_login": (t_login, "仅在用户明确同意重新登录后使用:打开本机 Moodle 登录窗口;已登录则不重复打开,成功后自动同步。诊断请用 get_monash_status。", {}, False),
506
+ "sync_monash_data": (t_sync, "刷新 Moodle/Ed 本地数据和全文索引;用户要求刷新或数据过旧时用。诊断错误用 get_monash_status;已在同步时不会重复启动。", {}, False),
507
+ "list_courses": (t_courses, "列出 Moodle/Ed 课程;问有哪些课或其他工具需要先确定课程时用。Moodle 未登录时回退本地课程表。返回课程代码、名称和标识。", {}),
508
+ "list_moodle_due": (t_due, "列 Moodle 未来截止和日历事件;需要提交状态/作业详情用 list_moodle_assignments,需要成绩用 get_moodle_grades。未登录可回退 iCal。返回截止事件和时间。",
509
+ {"course": (S, False), "days": (I, False)}),
510
+ "list_moodle_assignments": (t_assignments, "列 Moodle 作业及截止、提交和可用成绩状态;仅看日历截止用 list_moodle_due,仅看成绩/反馈用 get_moodle_grades。missing_only 可只看可能漏交。",
511
+ {"course": (S, False), "missing_only": (B, False)}),
512
+ "get_moodle_grades": (t_grades, "读取 Moodle 成绩;无 course 看总览,有 course 看评分项、得分和反馈。提交状态用 list_moodle_assignments,不用于截止日历。",
513
+ {"course": (S, False), "graded_only": (B, False)}),
514
+ "search_moodle_forum": (t_forum, "查看/搜索 Moodle 公告和论坛:无 query 看公告,有 query 搜索。Ed 讨论请用 search_ed_threads。返回公告或匹配帖子摘要、正文片段和链接。",
515
+ {"query": (S, False), "course": (S, False), "limit": (I, False)}),
516
+ "get_moodle_messages": (t_messages, "读取 Moodle 私信;无 conversation_id 列对话,有值则读该对话,且不会标已读。论坛内容用 search_moodle_forum。返回对话或消息。",
517
+ {"conversation_id": (I, False), "limit": (I, False)}),
518
+ "search_content": (t_search_content, "全文搜索已同步课件/PDF/笔记/字幕;只列文件用 list_files,拿到 path 后用 read_file。返回匹配片段、文件路径和定位信息。",
519
+ {"query": (S, True), "course": (S, False), "limit": (I, False)}),
520
+ "list_files": (t_list_files, "列已同步本地课件或外链,不搜索正文;搜正文用 search_content,读取 path 用 read_file。问“最近新上了什么课件”用 since_days。返回文件/外链和可供 read_file 使用的路径。",
521
+ {"course": (S, False), "week": (I, False), "query": (S, False), "links": (B, False),
522
+ "since_days": (I, False)}),
523
+ "read_file": (t_read_file, "读取 list_files/search_content 给出的课件路径;ZIP 用 inner,长内容用 offset/max_chars 分页。若要搜索未知文件先用 search_content。返回文本或 ZIP 清单。",
524
+ {"path": (S, True), "inner": (S, False), "offset": (I, False), "max_chars": (I, False)}),
525
+ "list_ed_updates": (t_ed_updates, "列 Ed 新帖、新回复和本人相关未读回复;浏览/搜索历史帖子用 search_ed_threads,读完整帖子用 read_ed_thread。返回更新分组和同步时间。",
526
+ {"course": (S, False), "since": (S, False), "peek": (B, False), "limit": (I, False)}),
527
+ "search_ed_threads": (t_ed_threads, "浏览或搜索 Ed 帖子;读完整正文/回复用 read_ed_thread,只看新动态用 list_ed_updates。返回帖子摘要;浏览模式支持 offset 分页。",
528
+ {"query": (S, False), "course": (S, False), "since": (S, False),
529
+ "type": (S, False, ("question", "post", "announcement")),
530
+ "unanswered": (B, False), "only": (S, False, tuple(edquery.STATE_FILTERS)),
531
+ "category": (S, False), "limit": (I, False), "offset": (I, False)}),
532
+ "read_ed_thread": (t_ed_thread, "读取单个 Ed 帖子正文和回复;找帖子用 search_ed_threads,新动态用 list_ed_updates;live=true 先刷新。返回 Markdown,长内容支持分页。",
533
+ {"thread": (S, True), "live": (B, False), "offset": (I, False), "max_chars": (I, False)}),
534
+ "get_ed_lessons": (t_ed_lessons, "读取 Ed Lessons:列进度、读单节或测验。Ed 讨论用 search_ed_threads;跨课件全文搜索用 search_content。返回 Lesson 列表/进度、正文或测验内容。",
535
+ {"course": (S, True), "module": (S, False), "lesson": (S, False),
536
+ "status": (S, False, STATUS), "quiz": (B, False),
537
+ "offset": (I, False), "max_chars": (I, False)}),
538
+ }
539
+
540
+ # Old public names still work for tools/call, but tools/list exposes only the consistent names above.
541
+ LEGACY_TOOL_ALIASES = {
542
+ "study_todo": "get_study_todo",
543
+ "monash_status": "get_monash_status",
544
+ "monash_login": "start_monash_login",
545
+ "monash_sync": "sync_monash_data",
546
+ "courses": "list_courses",
547
+ "moodle_due": "list_moodle_due",
548
+ "moodle_assignments": "list_moodle_assignments",
549
+ "moodle_grades": "get_moodle_grades",
550
+ "moodle_forum": "search_moodle_forum",
551
+ "moodle_messages": "get_moodle_messages",
552
+ "ed_updates": "list_ed_updates",
553
+ "ed_threads": "search_ed_threads",
554
+ "ed_thread": "read_ed_thread",
555
+ "ed_lessons": "get_ed_lessons",
556
+ }
557
+
558
+ # 每个参数都给短 schema 说明:让 agent 不必猜参数语义,同时控制上下文开销。
559
+ PARAM_HELP = {
560
+ ("get_study_todo", "days"): "未来天数,默认7",
561
+ ("get_monash_status", "full"): "true=完整诊断;false=简要状态",
562
+ ("list_moodle_due", "course"): "课程号,如 FIT2102;省略=全部",
563
+ ("list_moodle_due", "days"): "未来天数,默认14",
564
+ ("list_moodle_assignments", "course"): "课程号,如 FIT2102;省略=全部",
565
+ ("list_moodle_assignments", "missing_only"): "true=仅可能漏交的作业",
566
+ ("get_moodle_grades", "course"): "课程号;省略=成绩总览",
567
+ ("get_moodle_grades", "graded_only"): "true=仅已有成绩的项目",
568
+ ("search_moodle_forum", "query"): "搜索词;省略=最近公告",
569
+ ("search_moodle_forum", "course"): "课程号,如 FIT2102;省略=全部",
570
+ ("search_moodle_forum", "limit"): "数量参数;默认公告5、搜索10",
571
+ ("get_moodle_messages", "conversation_id"): "对话 ID;省略=列对话",
572
+ ("get_moodle_messages", "limit"): "数量上限;默认对话20、消息30",
573
+ ("search_content", "query"): "全文搜索关键词或短语",
574
+ ("search_content", "course"): "课程号,如 FIT2102;省略=全部",
575
+ ("search_content", "limit"): "命中上限,默认15,最高50",
576
+ ("list_files", "course"): "课程号,如 FIT2102;省略=全部",
577
+ ("list_files", "week"): "教学周编号,如 8",
578
+ ("list_files", "query"): "按文件名或路径筛选",
579
+ ("list_files", "links"): "true=列外链;须同时给 course",
580
+ ("list_files", "since_days"): "只看最近几天新同步的,新的排前面",
581
+ ("read_file", "path"): "list_files/search_content 返回的路径",
582
+ ("read_file", "inner"): "ZIP 内路径;省略=列 ZIP 清单",
583
+ ("read_file", "offset"): "字符起点,默认0",
584
+ ("read_file", "max_chars"): "最大字符数,默认20000,最高100000",
585
+ ("list_ed_updates", "course"): "课程号,如 FIT2102;省略=全部",
586
+ ("list_ed_updates", "since"): "起始时间:7d/12h/2w 或 ISO 日期时间",
587
+ ("list_ed_updates", "peek"): "true=查看但不推进更新游标",
588
+ ("list_ed_updates", "limit"): "活动上限,默认30",
589
+ ("search_ed_threads", "query"): "搜索标题/正文;省略=浏览帖子",
590
+ ("search_ed_threads", "course"): "课程号,如 FIT2102;省略=全部",
591
+ ("search_ed_threads", "since"): "起始时间:7d/12h/2w 或 ISO 日期时间",
592
+ ("search_ed_threads", "type"): "帖子类型",
593
+ ("search_ed_threads", "unanswered"): "true=仅未回答问题",
594
+ ("search_ed_threads", "only"): "状态筛选:收藏/关注/未看/本人/未读回复",
595
+ ("search_ed_threads", "category"): "Ed 分类",
596
+ ("search_ed_threads", "limit"): "条数上限,默认15,最高50",
597
+ ("search_ed_threads", "offset"): "浏览结果偏移;搜索时忽略",
598
+ ("read_ed_thread", "thread"): "帖子 ID、FIT2102#42 或 Ed 链接",
599
+ ("read_ed_thread", "live"): "true=先从 Ed 实时刷新该帖",
600
+ ("read_ed_thread", "offset"): "Markdown 字符起点,默认0",
601
+ ("read_ed_thread", "max_chars"): "最大字符数,默认30000,最高100000",
602
+ ("get_ed_lessons", "course"): "课程号,如 FIT2102",
603
+ ("get_ed_lessons", "module"): "可选模块名称或编号",
604
+ ("get_ed_lessons", "lesson"): "Lesson 名称或编号;有值=读正文",
605
+ ("get_ed_lessons", "status"): "完成状态筛选",
606
+ ("get_ed_lessons", "quiz"): "true=返回测验而非 Lesson 列表",
607
+ ("get_ed_lessons", "offset"): "正文字符起点,默认0",
608
+ ("get_ed_lessons", "max_chars"): "最大字符数,默认30000,最高100000",
609
+ }
610
+
611
+ def tool_list() -> list[dict]:
612
+ out = []
613
+ for name, spec in TOOLS.items():
614
+ _, desc, params = spec[:3]
615
+ read_only = spec[3] if len(spec) > 3 else True
616
+ props = {}
617
+ for k, p in params.items():
618
+ prop = {"type": p[0], **({"enum": list(p[2])} if len(p) > 2 else {})}
619
+ if (help_text := PARAM_HELP.get((name, k))):
620
+ prop["description"] = help_text
621
+ props[k] = prop
622
+ required = [k for k, p in params.items() if p[1]]
623
+ out.append({"name": name, "description": desc,
624
+ "inputSchema": {"type": "object", "properties": props, "required": required},
625
+ "annotations": {"readOnlyHint": read_only}})
626
+ return out
627
+
628
+
629
+ def call_tool(name: str, arguments: dict) -> dict:
630
+ try:
631
+ data = TOOLS[name][0](arguments or {})
632
+ text = data if isinstance(data, str) else json.dumps(data, ensure_ascii=False)
633
+ return {"content": [{"type": "text", "text": _clip(text, MAX_OUT)}]}
634
+ except MoodleAuthError as e:
635
+ hint = "截止日期还能先用 list_moodle_due 查(走日历订阅链接,不需要登录)。" \
636
+ if name != "list_moodle_due" and F.load_calendar_url() else ""
637
+ return {"isError": True, "content": [{"type": "text", "text":
638
+ f"Moodle 登录已过期或还没登录({e})。问用户要不要现在登录;同意的话调用 start_monash_login,"
639
+ f"会在用户电脑上打开登录窗口。{hint}"}]}
640
+ except EdAuthError as e:
641
+ return {"isError": True, "content": [{"type": "text", "text":
642
+ f"Ed 令牌没设置或失效了({e})。请用户在终端运行 `monash login ed`,"
643
+ "按提示在 Ed 设置页新建令牌后粘贴进去(不要把令牌发到聊天里)。"}]}
644
+ except (LookupError, ValueError) as e:
645
+ return {"isError": True, "content": [{"type": "text", "text": str(e)}]}
646
+
647
+
648
+ # ---------------------------------------------------------------- 协议
649
+
650
+ def handle(msg: dict) -> dict | None:
651
+ if not isinstance(msg, dict):
652
+ # 2025-06-18 版协议不再有批量请求;收到数组或别的东西就当无效请求,别让 .get 把主循环弄崩
653
+ return {"jsonrpc": "2.0", "id": None, "error": {"code": -32600, "message": "请求必须是一个 JSON 对象"}}
654
+ method, mid = msg.get("method"), msg.get("id")
655
+ if mid is None:
656
+ return None # 通知(notifications/initialized 等)不用回
657
+ try:
658
+ if method == "initialize":
659
+ result = {"protocolVersion": msg.get("params", {}).get("protocolVersion", PROTOCOL),
660
+ "capabilities": {"tools": {}, "prompts": {}},
661
+ "serverInfo": {"name": "monash", "version": __version__},
662
+ "instructions": INSTRUCTIONS + _update_hint()}
663
+ elif method == "ping":
664
+ result = {}
665
+ elif method == "tools/list":
666
+ result = {"tools": tool_list()}
667
+ elif method == "prompts/list":
668
+ result = {"prompts": help_menu.prompt_list()}
669
+ elif method == "prompts/get":
670
+ p = msg.get("params") or {}
671
+ try:
672
+ result = help_menu.get_prompt(p.get("name"), p.get("arguments"))
673
+ except KeyError:
674
+ return {"jsonrpc": "2.0", "id": mid, "error": {"code": -32602, "message": f"没有提示 {p.get('name')}"}}
675
+ except ValueError as e:
676
+ return {"jsonrpc": "2.0", "id": mid, "error": {"code": -32602, "message": str(e)}}
677
+ elif method == "tools/call":
678
+ p = msg.get("params") or {}
679
+ name = p.get("name")
680
+ canonical = LEGACY_TOOL_ALIASES.get(name, name)
681
+ if canonical not in TOOLS:
682
+ return {"jsonrpc": "2.0", "id": mid, "error": {"code": -32602, "message": f"没有工具 {name}"}}
683
+ result = call_tool(canonical, p.get("arguments") or {})
684
+ else:
685
+ return {"jsonrpc": "2.0", "id": mid, "error": {"code": -32601, "message": f"不支持 {method}"}}
686
+ return {"jsonrpc": "2.0", "id": mid, "result": result}
687
+ except Exception as e: # noqa: BLE001
688
+ log("工具出错:\n" + traceback.format_exc())
689
+ return {"jsonrpc": "2.0", "id": mid, "result": {"isError": True,
690
+ "content": [{"type": "text", "text": f"出错了:{e}"}]}}
691
+
692
+
693
+ def main(background: bool = True) -> None:
694
+ # Windows 上管道默认是本地代码页(cp1252/cp936),中文会乱码甚至报错;协议要求 UTF-8
695
+ sys.stdin.reconfigure(encoding="utf-8")
696
+ sys.stdout.reconfigure(encoding="utf-8", newline="\n")
697
+ ensure_private_dir(HOME)
698
+ log(f"MCP 启动({__version__})")
699
+ if background:
700
+ threading.Thread(target=_background, name="background", daemon=True).start()
701
+ for line in sys.stdin:
702
+ line = line.strip()
703
+ if not line:
704
+ continue
705
+ try:
706
+ msg = json.loads(line)
707
+ except json.JSONDecodeError:
708
+ reply = {"jsonrpc": "2.0", "id": None, "error": {"code": -32700, "message": "不是合法的 JSON"}}
709
+ else:
710
+ reply = handle(msg)
711
+ if reply is not None:
712
+ sys.stdout.write(json.dumps(reply, ensure_ascii=False) + "\n")
713
+ sys.stdout.flush()