From 0c9ef7d8ebb1906dd73cff6e8d1aec3f7ed01e9f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 26 Jul 2026 15:10:51 +0000 Subject: [PATCH 1/2] =?UTF-8?q?feat(mcp):=20=D7=9B=D7=9C=D7=99=20docs=5Fge?= =?UTF-8?q?t=5Fsection=20=E2=80=94=20=D7=A1=D7=A7=D7=A9=D7=9F=20=D7=91?= =?UTF-8?q?=D7=95=D7=93=D7=93=20=D7=9E=D7=AA=D7=99=D7=A2=D7=95=D7=93=20RST?= =?UTF-8?q?=20=D7=91=D7=9E=D7=A7=D7=95=D7=9D=20=D7=A7=D7=95=D7=91=D7=A5=20?= =?UTF-8?q?=D7=A9=D7=9C=D7=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit כלי MCP חדש (public) שמחזיר סקשן בודד מקבצי ה-RST של אתר התיעוד, במקום למשוך קובץ שלם (environment-variables.rst הוא 77KB / 2242 שורות). מחזיר גם ניווט — עץ כותרות, breadcrumb, תת-סקשנים ושכנים — כדי שמודל שלא רואה את הקובץ לא יישאר בלי כלום. - services/rst_parser.py (מודול עצמאי): זיהוי כותרות דינמי פר-קובץ (underline ו-overline), עמידות ל-literal/code blocks (שורת === בתוך Development:: אינה כותרת), התאמה סלחנית לעברית, TOC, מועמדים לכפולות, והצעות ל-not-found. - mcp_server/docs_handlers.py: בונה מעל RepoBackend.get_file הקיים (reuse מלא של ref/policy/sync_in_progress), עם slug resolution ו-offset/max_chars לדפדוף. - server.py: רישום public (opt-in — בלי require_admin, לא ב-_ADMIN_TOOLS), תיאור שמסביר מתי להשתמש בו במקום get_repo_file. - ריפו ברירת מחדל CodeBot דרך MCP_DOCS_REPO (env var חדש) — תועד ב-rst וב-config_inspector. עקרון-על: לעולם לא רק "לא נמצא" — בלי section מחזיר TOC, section חסר מחזיר TOC+הצעות, כותרת כפולה מחזירה מועמדים עם breadcrumb. טסטים על RST אמיתי מהריפו (69 ירוקים): טבלה גדולה נחתכת עם offset תקין, literal block לא נחשב כותרת, כפול→מועמדים, לא-קיים→TOC, slug ⇄ נתיב מלא, ו-public לא ב-_ADMIN_TOOLS. הפארסר עצמאי לשימוש חוזר עתידי (docs_search / docs_lookup_config). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01WNFuSyshwpRcxozVZEui5K --- .../FEATURE_MCP_CLAUDE_INTEGRATION.md | 1 + docs/environment-variables.rst | 6 + docs/mcp-server.rst | 4 + mcp_server/README.md | 1 + mcp_server/docs_handlers.py | 156 +++++++++++ mcp_server/server.py | 50 +++- services/config_inspector_service.py | 9 + services/rst_parser.py | 257 ++++++++++++++++++ tests/test_mcp_docs_handlers.py | 110 ++++++++ tests/test_mcp_server_build.py | 11 + tests/test_rst_parser.py | 98 +++++++ 11 files changed, 702 insertions(+), 1 deletion(-) create mode 100644 mcp_server/docs_handlers.py create mode 100644 services/rst_parser.py create mode 100644 tests/test_mcp_docs_handlers.py create mode 100644 tests/test_rst_parser.py diff --git a/FEATURE_SUGGESTIONS/FEATURE_MCP_CLAUDE_INTEGRATION.md b/FEATURE_SUGGESTIONS/FEATURE_MCP_CLAUDE_INTEGRATION.md index faaebbbf7..4909f9278 100644 --- a/FEATURE_SUGGESTIONS/FEATURE_MCP_CLAUDE_INTEGRATION.md +++ b/FEATURE_SUGGESTIONS/FEATURE_MCP_CLAUDE_INTEGRATION.md @@ -147,6 +147,7 @@ db.delete_file(user_id, file_name) # מחיקה רכה (recycle | `list_notes` *(✅ מומש)* | `file_name` | פתקים דביקים של הקובץ (זהים ל‑UI) | `sticky_notes` + `make_scope_id` | | `create_note` *(✅ מומש)* | `file_name`, `content`, `line?`, `color?`, `anchor_text?` | פתק חדש (צף בלי `line`) | `sticky_notes` + `get_latest_version` | | `update_note` *(✅ מומש)* | `note_id`, שדות חלקיים | עדכון פתק במקום (ללא היסטוריה) | `sticky_notes` | +| `docs_get_section` *(✅ מומש, public)* | `path` (slug/מלא), `section?`, `include_subsections?`, `max_chars?`, `offset?`, `repo?`, `ref?` | סקשן RST בודד + ניווט (breadcrumb/תת‑סקשנים/שכנים); בלי `section` — עץ כותרות | `services/rst_parser` + `RepoBackend.get_file` | **Non‑goal לפתקים:** אין מחיקת פתקים ואין תזכורות (`note_reminders`) דרך ה‑MCP — מוחקים/מתזמנים ב‑UI של הוובאפ. diff --git a/docs/environment-variables.rst b/docs/environment-variables.rst index 91d1deab4..cb74dda87 100644 --- a/docs/environment-variables.rst +++ b/docs/environment-variables.rst @@ -2108,6 +2108,12 @@ - ``CodeKeeper`` - ``CodeKeeper`` - MCP + * - ``MCP_DOCS_REPO`` + - שם הריפו (``repo_name`` ב-mirror) שממנו כלי ``codekeeper_docs_get_section`` קורא קבצי RST של התיעוד. ברירת מחדל ``CodeBot``; ניתן לעקוף פר-קריאה בארגומנט ``repo``. + - לא + - ``CodeBot`` + - ``CodeBot`` + - MCP * - ``MCP_ALLOWED_HOSTS`` - רשימת Host מותרים לשרת ה-MCP (CSV; תומך wildcard כמו ``*.onrender.com``). ריק = הגנת DNS-rebinding כבויה (מתאים לשרת ציבורי מוגן-טוקן). - לא diff --git a/docs/mcp-server.rst b/docs/mcp-server.rst index 6e15334b8..086e073d8 100644 --- a/docs/mcp-server.rst +++ b/docs/mcp-server.rst @@ -75,6 +75,10 @@ OAuth 2.1) וגם מול **Claude Code / Claude Desktop** (טוקן אישי). — דורס במקום, אין היסטוריית גרסאות לפתקים. דורש ``write`` * - ``codekeeper_list_collections`` / ``codekeeper_get_collection`` / ``codekeeper_get_collection_items`` - האוספים והקבצים שבתוכם + * - ``codekeeper_docs_get_section`` + - סקשן בודד מקובץ RST של התיעוד (במקום קובץ שלם). בלי ``section`` — עץ + הכותרות של העמוד. מחזיר גם breadcrumb, תת-סקשנים ושכנים לניווט. עדיף + על ``codekeeper_get_repo_file`` עבור ``docs/*.rst`` כלי אדמין (דפדפן הריפו — מוסתרים וחסומים לכל משתמש אחר): diff --git a/mcp_server/README.md b/mcp_server/README.md index 187f7d75d..84540cb18 100644 --- a/mcp_server/README.md +++ b/mcp_server/README.md @@ -36,6 +36,7 @@ Claude Desktop** (טוקן אישי). קריאה זמינה תמיד; **כתיב | `codekeeper_list_collections` | האוספים של המשתמש | | `codekeeper_get_collection` | אוסף בודד לפי id | | `codekeeper_get_collection_items` | הקבצים בתוך אוסף (עם עימוד/סינון תיקייה) | +| `codekeeper_docs_get_section` | סקשן בודד מקובץ RST של התיעוד (במקום קובץ שלם); בלי `section` מחזיר עץ כותרות. כולל breadcrumb/תת‑סקשנים/שכנים לניווט. עדיף על `codekeeper_get_repo_file` ל‑`docs/*.rst` | ### כלי אדמין — דפדפן הריפו (פאזה ד', קריאה בלבד) diff --git a/mcp_server/docs_handlers.py b/mcp_server/docs_handlers.py new file mode 100644 index 000000000..c43c53304 --- /dev/null +++ b/mcp_server/docs_handlers.py @@ -0,0 +1,156 @@ +"""Pure handler for the public docs tool ``codekeeper_docs_get_section``. + +Same contract as ``repo_handlers.py``: a plain function, no MCP/Starlette imports, +clamped inputs, ``{"ok": False, "error": "..."}`` rejections. **Not** admin-gated — +this is a public tool; the tool body deliberately omits ``require_admin``. + +It reuses ``RepoBackend.get_file`` to read the raw RST text from the mirror (that already +handles ref-default, secrets policy, and the ``sync_in_progress`` retry contract), then +``services.rst_parser`` to extract a single section with navigation. Guiding rule: never +return only "not found" — no section ⇒ TOC; missing ⇒ TOC + suggestions; duplicate ⇒ +candidates with breadcrumb. +""" + +from __future__ import annotations + +import os +from typing import Any + +from services import rst_parser +from .handlers import _clamp + +MAX_CHARS_DEFAULT = 12_000 +MAX_CHARS_MAX = 100_000 +MAX_CHARS_MIN = 500 +DEFAULT_DOCS_REPO = "CodeBot" +_TOC_MAX = 400 # תקרת פריטי TOC בתשובה (הגנת גודל) + + +def _resolve_docs_repo(repo: str | None) -> str: + r = (repo or "").strip() + if r: + return r + return (os.getenv("MCP_DOCS_REPO", DEFAULT_DOCS_REPO) or DEFAULT_DOCS_REPO).strip() + + +def _resolve_docs_path(path: str) -> str | None: + """נתיב מלא (docs/x.rst) או slug קצר (x / x.rst) → נתיב מלא תחת docs/.""" + p = (path or "").strip().strip("/") + if not p: + return None + if not p.endswith(".rst"): + p = p + ".rst" + if "/" not in p: + p = "docs/" + p + return p + + +def _toc(doc: "rst_parser.Document") -> tuple[list, bool]: + items = rst_parser.build_toc(doc) + if len(items) > _TOC_MAX: + return items[:_TOC_MAX], True + return items, False + + +def _section_ref(sec: "rst_parser.Section") -> dict: + return {"title": sec.title, "level": sec.level, + "line_range": [sec.heading_line, sec.end_line]} + + +def docs_get_section( + backend: Any, + *, + path: str, + section: str | None = None, + include_subsections: bool = True, + max_chars: int = MAX_CHARS_DEFAULT, + offset: int = 0, + repo: str | None = None, + ref: str | None = None, +) -> dict[str, Any]: + file_path = _resolve_docs_path(path) + if not file_path: + return {"ok": False, "error": "missing_path"} + repo_name = _resolve_docs_repo(repo) + max_chars = _clamp(max_chars, MAX_CHARS_MIN, MAX_CHARS_MAX, MAX_CHARS_DEFAULT) + offset = _clamp(offset, 0, 10 ** 9, 0) + + # קריאה — reuse מלא של RepoBackend.get_file (ref default, מדיניות סודות, sync_in_progress) + res = backend.get_file(repo=repo_name, path=file_path, ref=((ref or "").strip() or None)) + if not res.get("ok"): + # not_found / invalid_input / sync_in_progress — מוסיפים הקשר ומעבירים הלאה + res.setdefault("repo", repo_name) + res.setdefault("path", file_path) + return res + if res.get("status") != "ok": + # binary / too_large — אין תוכן טקסט לפרסר + return {"ok": False, "error": f"unreadable_{res.get('status')}", + "repo": repo_name, "path": file_path, "file": res.get("file")} + + content = res.get("content") or "" + file_meta = res.get("file") or {} + doc = rst_parser.parse_document(content) + toc_items, toc_truncated = _toc(doc) + + base: dict[str, Any] = { + "ok": True, "repo": repo_name, "path": file_path, + "ref": file_meta.get("ref"), "resolved_commit": file_meta.get("resolved_commit"), + "includes": list(doc.includes), + } + + # בלי section → עץ כותרות (הכלי משמש גם לניווט) + if not (section or "").strip(): + base.update({"mode": "toc", "toc": toc_items, "toc_truncated": toc_truncated, + "section_count": len(doc.sections)}) + return base + + matches = rst_parser.find_sections(doc, section) + + # לא נמצא → TOC מלא + הצעות קרובות (לעולם לא רק "לא נמצא") + if not matches: + base.update({ + "ok": False, "error": "section_not_found", "requested": section, + "suggestions": rst_parser.suggest(doc, section), + "toc": toc_items, "toc_truncated": toc_truncated, + }) + return base + + # כותרת כפולה → כל המועמדים עם breadcrumb (בלי לנחש) + if len(matches) > 1: + base.update({ + "ok": False, "error": "ambiguous_section", "requested": section, + "candidates": [{ + "title": s.title, "breadcrumb": list(s.breadcrumb), + "level": s.level, "line_range": [s.heading_line, s.end_line], + } for s in matches], + }) + return base + + # התאמה יחידה → הסקשן + ניווט (שכנים ותת-סקשנים) + sec = matches[0] + full = rst_parser.section_text(doc, sec, include_subsections) + total = len(full) + chunk = full[offset:offset + max_chars] + truncated = (offset + len(chunk)) < total + prev, nxt = rst_parser.neighbors(doc, sec) + + base.update({ + "mode": "section", + "section": sec.title, + "breadcrumb": list(sec.breadcrumb), + "level": sec.level, + "line_range": [sec.heading_line, sec.end_line], + "include_subsections": bool(include_subsections), + "offset": offset, + "content": chunk, + "truncated": truncated, + "subsections": [_section_ref(s) for s in rst_parser.direct_subsections(doc, sec)], + "neighbors": { + "prev": _section_ref(prev) if prev else None, + "next": _section_ref(nxt) if nxt else None, + }, + }) + if truncated: + base["remaining_chars"] = total - (offset + len(chunk)) + base["next_offset"] = offset + len(chunk) + return base diff --git a/mcp_server/server.py b/mcp_server/server.py index c3ddabbbd..40e72d368 100644 --- a/mcp_server/server.py +++ b/mcp_server/server.py @@ -19,7 +19,7 @@ from starlette.responses import JSONResponse from starlette.routing import Route -from . import handlers, repo_handlers +from . import docs_handlers, handlers, repo_handlers from .auth import ( PATAuthMiddleware, current_user_id, @@ -373,6 +373,7 @@ def update_note( if repo_backend is not None: _register_repo_tools(mcp, repo_backend) + _register_docs_tools(mcp, repo_backend) return mcp @@ -452,6 +453,53 @@ def search_repo( ) +def _register_docs_tools(mcp: FastMCP, repo_backend: Any) -> None: + """Public, read-only docs tool — return ONE RST section instead of a whole file. + + NOT admin-gated: the body calls ``current_user_id`` (identity only, fail-closed on + no token), never ``require_admin`` / ``require_write``, and the name is deliberately + kept OUT of ``_ADMIN_TOOLS``. Reads via ``repo_backend`` (the mirror) like the repo + tools, but serves public documentation to any authenticated user. + """ + + @mcp.tool( + name="codekeeper_docs_get_section", + description=( + "Read ONE section from a CodeKeeper documentation RST file instead of the " + "whole file. Prefer this over codekeeper_get_repo_file for docs/*.rst: it " + "returns a single section with navigation (breadcrumb, direct subsections, " + "prev/next siblings) rather than a 77KB file. Call with NO `section` to get " + "the page's table of contents (heading tree) and pick one. Accepts a full " + "path (docs/x.rst) or short slug (x). `ref` is a git ref (default: repo " + "default branch). For large sections, page with `offset`/`max_chars`. Never " + "returns a bare 'not found': a missing section returns the full TOC + " + "suggestions; a duplicate heading returns candidates with breadcrumbs." + ), + annotations=_READ_ONLY_TOOL, + ) + def docs_get_section( + ctx: Context, + path: str, + section: str | None = None, + include_subsections: bool = True, + max_chars: int = 12000, + offset: int = 0, + repo: str | None = None, + ref: str | None = None, + ) -> dict: + current_user_id(ctx) # מזהה בלבד — public, בלי require_admin + return docs_handlers.docs_get_section( + repo_backend, + path=path, + section=section, + include_subsections=include_subsections, + max_chars=max_chars, + offset=offset, + repo=repo, + ref=ref, + ) + + async def _healthz(_request): return JSONResponse({"status": "ok", "service": "codekeeper-mcp"}) diff --git a/services/config_inspector_service.py b/services/config_inspector_service.py index 698fc59f0..9da4e6fcd 100644 --- a/services/config_inspector_service.py +++ b/services/config_inspector_service.py @@ -533,6 +533,15 @@ class ConfigService: description="שם התצוגה של שרת ה-MCP (שם ה-Connector שמוצג ללקוח).", category="mcp", ), + "MCP_DOCS_REPO": ConfigDefinition( + key="MCP_DOCS_REPO", + default="CodeBot", + description=( + "שם הריפו (repo_name ב-mirror) שממנו כלי codekeeper_docs_get_section של ה-MCP " + "קורא את קבצי ה-RST של התיעוד. ברירת מחדל: CodeBot." + ), + category="mcp", + ), "MCP_ALLOWED_HOSTS": ConfigDefinition( key="MCP_ALLOWED_HOSTS", default="", diff --git a/services/rst_parser.py b/services/rst_parser.py new file mode 100644 index 000000000..abaf78334 --- /dev/null +++ b/services/rst_parser.py @@ -0,0 +1,257 @@ +"""פארסר RST קליל וטהור — לחילוץ סקשנים לפי כותרות. + +מודול עצמאי (בלי תלות ב-MCP) שנועד לשמש כלים שקוראים מקבצי RST של התיעוד: +``docs_get_section`` (עכשיו) ובעתיד ``docs_search`` / ``docs_lookup_config``. + +עקרונות מנחים: +- היררכיית הכותרות ב-RST נקבעת **דינמית פר-קובץ** לפי סדר הופעת תווי ה-adornment + (אין הנחה קשיחה ש-``=`` היא רמה 1). תמיכה ב-underline-only וב-overline+underline. +- כותרות מזוהות אך ורק ב-column 0. תוכן מוזח (list-table, note, code-block, literal blocks) + שייך לסקשן שמעליו ואינו מפורסר פנימה — חיתוך לפי טווח שורות מטפל בזה נכון. +- שורת ``===``/``---`` בתוך literal block (אחרי ``::``) או ``.. code-block::`` אינה כותרת. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass, field +from difflib import get_close_matches +from typing import List, Optional, Tuple + +# תווי adornment חוקיים לכותרות RST (לא כולל אלפאנומרי/רווח) +_ADORNMENT_CHARS = set("=-`:.'\"~^_*+#<>!$%&()?@[\\]{|}/") + +_DIRECTIVE_RE = re.compile(r"^\.\.[ \t]+\S") # ".. something::" וכו' +_CODE_DIRECTIVE_RE = re.compile(r"^\.\.[ \t]+(code-block|code|sourcecode|parsed-literal)::") +_INCLUDE_RE = re.compile(r"^\.\.[ \t]+include::[ \t]*(\S.*)$") + + +@dataclass +class Section: + """סקשן בודד בעץ הכותרות.""" + title: str + level: int + title_line: int # 1-based — שורת הטקסט של הכותרת + heading_line: int # 1-based — שורת ה-overline (או == title_line אם אין overline) + end_line: int # 1-based inclusive — סוף התוכן (מחושב ב-_finalize) + adornment: str + over: bool = False + parent: Optional[int] = None + children: List[int] = field(default_factory=list) + breadcrumb: List[str] = field(default_factory=list) + + +@dataclass +class Document: + lines: List[str] + sections: List[Section] + includes: List[str] = field(default_factory=list) # יעדי .. include:: (לא מורחבים) + + +def _is_indented(line: str) -> bool: + return line[:1] in (" ", "\t") + + +def _adornment_char(line: str) -> Optional[str]: + """מחזיר את תו ה-adornment אם השורה כולה תו-חזרה בודד (בלי הזחה), אחרת None.""" + if not line or _is_indented(line): + return None + s = line.rstrip() + if len(s) < 2: # לפחות 2 תווים — מונע false-positive על תו בודד + return None + ch = s[0] + if ch not in _ADORNMENT_CHARS: + return None + return ch if all(c == ch for c in s) else None + + +def _is_title_text(line: str) -> bool: + """שורה שיכולה להיות טקסט של כותרת: לא ריקה, לא מוזחת, לא adornment-only.""" + if not line or _is_indented(line): + return False + return _adornment_char(line) is None + + +def _opens_literal(stripped: str) -> bool: + """שורה (לא מוזחת) שפותחת literal/code block שבו אין לזהות כותרות.""" + if _CODE_DIRECTIVE_RE.match(stripped): + return True + # paragraph המסתיים ב-'::' שאינו דירקטיבה (למשל 'Development::') + return stripped.endswith("::") and not _DIRECTIVE_RE.match(stripped) + + +def parse_document(text: str) -> Document: + """מפרסר טקסט RST לעץ סקשנים. עמיד ל-literal/code blocks ולדירקטיבות מוזחות.""" + lines = (text or "").split("\n") + n = len(lines) + sections: List[Section] = [] + includes: List[str] = [] + order: List[str] = [] # סדר הופעת תווי ה-adornment → קובע את הרמות + + def level_for(ch: str) -> int: + if ch not in order: + order.append(ch) + return order.index(ch) + 1 + + i = 0 + while i < n: + raw = lines[i] + if not _is_indented(raw): + stripped = raw.rstrip() + # .. include:: — נאסף בלי הרחבה + m_inc = _INCLUDE_RE.match(stripped) + if m_inc: + includes.append(m_inc.group(1).strip()) + i += 1 + continue + # literal/code block — דלג על הבלוק המוזח שאחרי השורה הפותחת + if _opens_literal(stripped): + i += 1 + while i < n and lines[i].strip() == "": + i += 1 + if i < n and _is_indented(lines[i]): + block_indent = len(lines[i]) - len(lines[i].lstrip()) + while i < n: + ln = lines[i] + if ln.strip() == "": + i += 1 + continue + if (len(ln) - len(ln.lstrip())) < block_indent: + break + i += 1 + continue + + # overline + underline (adornment / title / adornment זהה) + over = _adornment_char(raw) + if over is not None and i + 2 < n: + title_line = lines[i + 1] + under = _adornment_char(lines[i + 2]) + if _is_title_text(title_line) and under == over: + lvl = level_for(over) + sections.append(Section( + title=title_line.strip(), level=lvl, + title_line=i + 2, heading_line=i + 1, end_line=n, + adornment=over, over=True, + )) + i += 3 + continue + + # underline-only (title / adornment באורך >= הכותרת) + if _is_title_text(raw) and i + 1 < n: + under = _adornment_char(lines[i + 1]) + if under is not None and len(lines[i + 1].rstrip()) >= len(raw.rstrip()): + lvl = level_for(under) + sections.append(Section( + title=raw.strip(), level=lvl, + title_line=i + 1, heading_line=i + 1, end_line=n, + adornment=under, over=False, + )) + i += 2 + continue + + i += 1 + + _finalize(sections, n) + return Document(lines=lines, sections=sections, includes=includes) + + +def _finalize(sections: List[Section], total_lines: int) -> None: + """מחשב end_line, parent/children, ו-breadcrumb לכל סקשן.""" + for idx, sec in enumerate(sections): + end = total_lines + for j in range(idx + 1, len(sections)): + if sections[j].level <= sec.level: + end = sections[j].heading_line - 1 + break + sec.end_line = end + + stack: List[int] = [] # אינדקסים של אבות פתוחים + for idx, sec in enumerate(sections): + while stack and sections[stack[-1]].level >= sec.level: + stack.pop() + if stack: + parent = stack[-1] + sec.parent = parent + sections[parent].children.append(idx) + sec.breadcrumb = sections[parent].breadcrumb + [sec.title] + else: + sec.parent = None + sec.breadcrumb = [sec.title] + stack.append(idx) + + +def normalize_title(s: str) -> str: + """נרמול סלחני להשוואת כותרות: רווחים, מקפים, ו-case לחלק האנגלי.""" + s = (s or "").strip() + s = re.sub(r"[‐-―\-]", "-", s) # מקף/מקף ארוך → מקף אחיד + s = re.sub(r"\s+", " ", s) # רווחים כפולים → יחיד + return s.casefold() # case-insensitive (עברית לא מושפעת) + + +def find_sections(doc: Document, title: str) -> List[Section]: + """כל הסקשנים שכותרתם תואמת (סלחני). ריק/יחיד/מרובה — המתקשר מחליט.""" + target = normalize_title(title) + return [s for s in doc.sections if normalize_title(s.title) == target] + + +def section_bounds(doc: Document, sec: Section, include_subsections: bool) -> Tuple[int, int]: + """טווח שורות (1-based inclusive) של תוכן הסקשן — עם או בלי תת-סקשנים.""" + start = sec.heading_line + if include_subsections or not sec.children: + return start, sec.end_line + first_child = doc.sections[sec.children[0]] + return start, first_child.heading_line - 1 + + +def section_text(doc: Document, sec: Section, include_subsections: bool) -> str: + start, end = section_bounds(doc, sec, include_subsections) + return "\n".join(doc.lines[start - 1:end]) + + +def direct_subsections(doc: Document, sec: Section) -> List[Section]: + return [doc.sections[c] for c in sec.children] + + +def neighbors(doc: Document, sec: Section) -> Tuple[Optional[Section], Optional[Section]]: + """הסקשן הקודם והבא באותה רמה תחת אותו אב (לניווט בלי TOC).""" + siblings = ([doc.sections[c] for c in doc.sections[sec.parent].children] + if sec.parent is not None + else [s for s in doc.sections if s.parent is None]) + ids = [s.title_line for s in siblings] + try: + pos = ids.index(sec.title_line) + except ValueError: + return None, None + prev = siblings[pos - 1] if pos > 0 else None + nxt = siblings[pos + 1] if pos + 1 < len(siblings) else None + return prev, nxt + + +def build_toc(doc: Document) -> List[dict]: + """עץ כותרות: כותרת, רמה, טווח שורות, גודל משוער (bytes) — בלי תוכן.""" + toc = [] + for sec in doc.sections: + approx = len("\n".join(doc.lines[sec.heading_line - 1:sec.end_line]).encode("utf-8")) + toc.append({ + "title": sec.title, + "level": sec.level, + "breadcrumb": list(sec.breadcrumb), + "line_range": [sec.heading_line, sec.end_line], + "approx_bytes": approx, + }) + return toc + + +def suggest(doc: Document, query: str, n: int = 5) -> List[str]: + """כותרות קרובות לשאילתה שלא נמצאה (difflib), לשילוב ב-not-found.""" + titles = [s.title for s in doc.sections] + norm_map = {normalize_title(t): t for t in titles} + close = get_close_matches(normalize_title(query), list(norm_map.keys()), n=n, cutoff=0.5) + # שמור על סדר ייחודי + out, seen = [], set() + for c in close: + t = norm_map[c] + if t not in seen: + seen.add(t) + out.append(t) + return out diff --git a/tests/test_mcp_docs_handlers.py b/tests/test_mcp_docs_handlers.py new file mode 100644 index 000000000..ff27959e8 --- /dev/null +++ b/tests/test_mcp_docs_handlers.py @@ -0,0 +1,110 @@ +"""טסטים ל-handler של docs_get_section — עם fake backend שקורא RST אמיתי מ-docs/.""" + +from pathlib import Path + +from mcp_server import docs_handlers + +_ROOT = Path(__file__).resolve().parents[1] + + +class _FsBackend: + """מחקה RepoBackend.get_file: קורא קובץ אמיתי מהריפו (filesystem, לא mirror).""" + + def __init__(self, ref="refs/heads/main"): + self._ref = ref + self.calls = [] + + def get_file(self, *, repo, path, ref=None): + self.calls.append((repo, path, ref)) + f = _ROOT / path + if not f.exists(): + return {"ok": False, "error": "not_found"} + text = f.read_text(encoding="utf-8") + return { + "ok": True, "status": "ok", + "file": {"path": path, "ref": ref or self._ref, + "resolved_commit": "deadbeef", "lines": text.count("\n") + 1}, + "content": text, + } + + +class _TextBackend: + """מחזיר טקסט RST נתון (למקרי קצה סינתטיים).""" + + def __init__(self, text): + self._text = text + + def get_file(self, *, repo, path, ref=None): + return {"ok": True, "status": "ok", + "file": {"path": path, "ref": "HEAD", "resolved_commit": "c0ffee"}, + "content": self._text} + + +def test_no_section_returns_toc_without_content(): + out = docs_handlers.docs_get_section(_FsBackend(), path="environment-variables") + assert out["ok"] and out["mode"] == "toc" + assert "טבלה מרכזית" in [t["title"] for t in out["toc"]] + assert "content" not in out + + +def test_default_repo_is_codebot(): + be = _FsBackend() + docs_handlers.docs_get_section(be, path="environment-variables") + assert be.calls[0][0] == "CodeBot" + assert be.calls[0][1] == "docs/environment-variables.rst" + + +def test_slug_and_full_path_equivalent(): + a = docs_handlers.docs_get_section(_FsBackend(), path="environment-variables", section="טבלה מרכזית") + b = docs_handlers.docs_get_section(_FsBackend(), path="docs/environment-variables.rst", section="טבלה מרכזית") + assert a["ok"] and b["ok"] + assert a["content"] == b["content"] + assert a["path"] == b["path"] == "docs/environment-variables.rst" + + +def test_big_table_truncates_with_valid_offset(): + """הטבלה המרכזית גדולה — נחתכת עם truncated + next_offset תקין, וההמשך שונה.""" + be = _FsBackend() + out = docs_handlers.docs_get_section(be, path="environment-variables", + section="טבלה מרכזית", max_chars=4000) + assert out["ok"] and out["mode"] == "section" + assert out["truncated"] is True + assert out["next_offset"] == len(out["content"]) > 0 + assert out["remaining_chars"] > 0 + assert ".. list-table:: Environment Variables" in out["content"] + out2 = docs_handlers.docs_get_section(be, path="environment-variables", + section="טבלה מרכזית", max_chars=4000, + offset=out["next_offset"]) + assert out2["content"] and out2["content"] != out["content"] + + +def test_section_not_found_returns_toc_and_suggestions(): + out = docs_handlers.docs_get_section(_FsBackend(), path="environment-variables", + section="סקשן שלא קיים בכלל 123") + assert out["ok"] is False and out["error"] == "section_not_found" + assert out["toc"] # TOC מלא, לא רק שגיאה + assert "suggestions" in out + + +def test_ambiguous_section_returns_candidates_with_breadcrumb(): + rst = "Doc\n===\n\nAlpha\n-----\n\nמטרה\n~~~~\n\nx\n\nBeta\n----\n\nמטרה\n~~~~\n\ny\n" + out = docs_handlers.docs_get_section(_TextBackend(rst), path="x", section="מטרה") + assert out["ok"] is False and out["error"] == "ambiguous_section" + assert len(out["candidates"]) == 2 + crumbs = [c["breadcrumb"] for c in out["candidates"]] + assert crumbs[0] != crumbs[1] # Alpha vs Beta + + +def test_include_subsections_false_smaller_than_true(): + full = docs_handlers.docs_get_section(_FsBackend(), path="environment-variables", + section="משתני סביבה - רפרנס", include_subsections=True) + partial = docs_handlers.docs_get_section(_FsBackend(), path="environment-variables", + section="משתני סביבה - רפרנס", include_subsections=False) + assert full["ok"] and partial["ok"] + assert len(partial["content"]) < len(full["content"]) + + +def test_missing_file_propagates_not_found(): + out = docs_handlers.docs_get_section(_FsBackend(), path="no-such-doc-xyz") + assert out["ok"] is False and out["error"] == "not_found" + assert out["repo"] == "CodeBot" diff --git a/tests/test_mcp_server_build.py b/tests/test_mcp_server_build.py index 829964b8b..7efe6d586 100644 --- a/tests/test_mcp_server_build.py +++ b/tests/test_mcp_server_build.py @@ -95,6 +95,17 @@ async def test_repo_tools_hidden_from_non_admin_tools_list(): assert not (names & _ADMIN_TOOLS) +async def test_docs_tool_is_public_and_not_admin_gated(): + """codekeeper_docs_get_section ציבורי: מופיע ל-non-admin ואינו ב-_ADMIN_TOOLS.""" + from mcp_server.server import _ADMIN_TOOLS + + mcp = build_mcp(_FakeBackend(), repo_backend=_FakeRepoBackend()) + # תצוגת non-admin (בלי request context → fail-closed non-admin) + names = {t.name for t in await mcp.list_tools()} + assert "codekeeper_docs_get_section" in names + assert "codekeeper_docs_get_section" not in _ADMIN_TOOLS + + async def test_repo_tools_visible_to_admin(): from mcp_server.server import _ADMIN_TOOLS diff --git a/tests/test_rst_parser.py b/tests/test_rst_parser.py new file mode 100644 index 000000000..8900a449a --- /dev/null +++ b/tests/test_rst_parser.py @@ -0,0 +1,98 @@ +"""טסטים לפארסר ה-RST — על קבצי RST אמיתיים מהריפו + מקרי קצה סינתטיים ממוקדים.""" + +from pathlib import Path + +from services import rst_parser + +_ENV_RST = Path(__file__).resolve().parents[1] / "docs" / "environment-variables.rst" + + +def _env_doc(): + return rst_parser.parse_document(_ENV_RST.read_text(encoding="utf-8")) + + +# ---- על RST אמיתי (environment-variables.rst) ---- + +def test_literal_block_lines_not_parsed_as_headings(): + """הבאג הכי צפוי: שורות בתוך literal block (Development:: ...) אינן כותרות.""" + doc = _env_doc() + titles = [s.title for s in doc.sections] + assert not [t for t in titles if "=" in t or "BOT_TOKEN" in t or t != t.strip()] + assert "דוגמאות קונפיגורציה" in titles # הכותרת עצמה כן, התוכן המוזח לא + + +def test_dynamic_hierarchy_is_per_file(): + """= → רמה 1, - → רמה 2 — נקבע דינמית לפי סדר ההופעה, לא קשיח.""" + doc = _env_doc() + h1 = [s for s in doc.sections if s.level == 1] + assert len(h1) == 1 and h1[0].title == "משתני סביבה - רפרנס" and h1[0].adornment == "=" + h2 = [s for s in doc.sections if s.level == 2] + assert h2 and all(s.adornment == "-" for s in h2) + assert "טבלה מרכזית" in [s.title for s in h2] + + +def test_main_table_bounds_cover_whole_table(): + """'טבלה מרכזית' מכסה את כל ה-list-table (חיתוך לפי טווח שורות מטפל בדירקטיבה המוזחת).""" + doc = _env_doc() + secs = rst_parser.find_sections(doc, "טבלה מרכזית") + assert len(secs) == 1 + text = rst_parser.section_text(doc, secs[0], include_subsections=True) + assert ".. list-table:: Environment Variables" in text + assert "BOT_TOKEN" in text # שורה מתוך הטבלה + assert secs[0].breadcrumb == ["משתני סביבה - רפרנס", "טבלה מרכזית"] + + +def test_forgiving_match_spaces_and_hyphen(): + doc = _env_doc() + # מקף רגיל, רווחים כפולים ורווחי קצה — כולם צריכים להתאים + assert len(rst_parser.find_sections(doc, " טבלה מרכזית ")) == 1 + + +# ---- מקרי קצה סינתטיים ---- + +def test_overline_underline_heading(): + doc = rst_parser.parse_document("======\nTitle\n======\n\nbody\n") + assert len(doc.sections) == 1 + assert doc.sections[0].title == "Title" and doc.sections[0].over is True + + +def test_duplicate_headings_return_all(): + rst = "Doc\n===\n\nמטרה\n----\n\naaa\n\nחלק\n----\n\nbbb\n\nמטרה\n----\n\nccc\n" + doc = rst_parser.parse_document(rst) + assert len(rst_parser.find_sections(doc, "מטרה")) == 2 + + +def test_case_insensitive_english(): + doc = rst_parser.parse_document("Title\n=====\n\nMy Section\n----------\n\nx\n") + assert len(rst_parser.find_sections(doc, "my section")) == 1 + + +def test_no_headings_returns_empty(): + doc = rst_parser.parse_document("just text\nmore text without headings\n") + assert doc.sections == [] + assert rst_parser.build_toc(doc) == [] + + +def test_include_collected_not_expanded(): + doc = rst_parser.parse_document("Doc\n===\n\n.. include:: other.rst\n\nbody\n") + assert doc.includes == ["other.rst"] + + +def test_code_block_dashes_not_heading(): + """שורת ---- בתוך code-block אינה כותרת.""" + rst = "Doc\n===\n\n.. code-block:: text\n\n ----\n inner line\n\nReal\n----\n\nx\n" + doc = rst_parser.parse_document(rst) + titles = [s.title for s in doc.sections] + assert "Real" in titles + assert "inner line" not in titles + + +def test_include_subsections_false_stops_before_child(): + rst = "Parent\n======\n\nintro\n\nChild\n-----\n\nchild body\n" + doc = rst_parser.parse_document(rst) + sec = rst_parser.find_sections(doc, "Parent")[0] + full = rst_parser.section_text(doc, sec, include_subsections=True) + partial = rst_parser.section_text(doc, sec, include_subsections=False) + assert "child body" in full + assert "child body" not in partial + assert "intro" in partial From dea3b5da5170258f864d87795f698f8e716eca54 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 26 Jul 2026 15:46:34 +0000 Subject: [PATCH 2/2] =?UTF-8?q?fix(mcp):=20=D7=94=D7=A7=D7=A9=D7=97=D7=AA?= =?UTF-8?q?=20docs=5Fget=5Fsection=20=E2=80=94=20allowlist=20=D7=9C=D7=A8?= =?UTF-8?q?=D7=99=D7=A4=D7=95=20+=20=D7=94=D7=92=D7=91=D7=9C=D7=AA=20?= =?UTF-8?q?=D7=A0=D7=AA=D7=99=D7=91=20=D7=9C-docs/?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit סבב review על הכלי הציבורי codekeeper_docs_get_section: - IDOR: repo מפורש מאומת מול allowlist (MCP_DOCS_REPO כ-CSV) לפני קריאה ל-backend. בלי זה כל משתמש מאומת יכול לקרוא .rst מכל ריפו ב-mirror (עוקף את ה-admin-gate). - Path: _resolve_docs_path מנרמל עם posixpath ודוחה traversal, נתיב מוחלט, וכל נתיב מחוץ ל-docs/ (כולל קלט עם slash כמו webapp/x) — הגנת שכבה מעל ה-backend. - תיאור MCP_DOCS_REPO עודכן (rst + config_inspector) כגבול אבטחה (CSV allowlist). טסטים: repo לא-מותר → repo_not_allowed (backend לא נקרא), נתיב מחוץ ל-docs/ ו-traversal → missing_path. 54 טסטים ירוקים. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01WNFuSyshwpRcxozVZEui5K --- docs/environment-variables.rst | 2 +- mcp_server/docs_handlers.py | 37 ++++++++++++++++++++++------ services/config_inspector_service.py | 4 +-- tests/test_mcp_docs_handlers.py | 31 +++++++++++++++++++++++ 4 files changed, 64 insertions(+), 10 deletions(-) diff --git a/docs/environment-variables.rst b/docs/environment-variables.rst index cb74dda87..fdd8d72f1 100644 --- a/docs/environment-variables.rst +++ b/docs/environment-variables.rst @@ -2109,7 +2109,7 @@ - ``CodeKeeper`` - MCP * - ``MCP_DOCS_REPO`` - - שם הריפו (``repo_name`` ב-mirror) שממנו כלי ``codekeeper_docs_get_section`` קורא קבצי RST של התיעוד. ברירת מחדל ``CodeBot``; ניתן לעקוף פר-קריאה בארגומנט ``repo``. + - רשימת הריפואים המותרים (CSV allowlist) שכלי ``codekeeper_docs_get_section`` הציבורי רשאי לקרוא מהם קבצי RST. גבול אבטחה — ארגומנט ``repo`` שאינו ברשימה נדחה. ברירת מחדל ``CodeBot``. - לא - ``CodeBot`` - ``CodeBot`` diff --git a/mcp_server/docs_handlers.py b/mcp_server/docs_handlers.py index c43c53304..d3889120d 100644 --- a/mcp_server/docs_handlers.py +++ b/mcp_server/docs_handlers.py @@ -14,6 +14,7 @@ from __future__ import annotations import os +import posixpath from typing import Any from services import rst_parser @@ -26,23 +27,43 @@ _TOC_MAX = 400 # תקרת פריטי TOC בתשובה (הגנת גודל) -def _resolve_docs_repo(repo: str | None) -> str: +def _allowed_docs_repos() -> list[str]: + """רשימת הריפואים המותרים לכלי (CSV ב-MCP_DOCS_REPO), עם fallback ל-CodeBot.""" + raw = os.getenv("MCP_DOCS_REPO", DEFAULT_DOCS_REPO) or DEFAULT_DOCS_REPO + repos = [r.strip() for r in raw.split(",") if r.strip()] + return repos or [DEFAULT_DOCS_REPO] + + +def _resolve_docs_repo(repo: str | None) -> str | None: + """ברירת מחדל = הריפו הראשון ב-allowlist; repo מפורש מותר רק אם ב-allowlist (אחרת None). + + הכלי ציבורי — בלי האכיפה הזו כל משתמש מאומת יכול לקרוא .rst מכל ריפו ב-mirror (IDOR). + """ + allowed = _allowed_docs_repos() r = (repo or "").strip() - if r: - return r - return (os.getenv("MCP_DOCS_REPO", DEFAULT_DOCS_REPO) or DEFAULT_DOCS_REPO).strip() + if not r: + return allowed[0] + return r if r in allowed else None def _resolve_docs_path(path: str) -> str | None: - """נתיב מלא (docs/x.rst) או slug קצר (x / x.rst) → נתיב מלא תחת docs/.""" + """נתיב מלא (docs/x.rst) או slug קצר (x / x.rst) → נתיב מנורמל תחת docs/ בלבד. + + מחזיר None עבור traversal (``..``), נתיב מוחלט, או כל נתיב שאינו תחת ``docs/`` — כולל + קלט שמכיל slash (למשל ``webapp/x``). זו הגנת שכבה בנוסף לאימות ה-path של ה-backend. + """ p = (path or "").strip().strip("/") - if not p: + if not p or "\x00" in p: return None if not p.endswith(".rst"): p = p + ".rst" if "/" not in p: p = "docs/" + p - return p + # נרמול וחסימה: traversal שיוצא מ-docs/ (docs/../x → x.rst), נתיב מוחלט, וכל דבר מחוץ ל-docs/ + norm = posixpath.normpath(p) + if not norm.startswith("docs/"): + return None + return norm def _toc(doc: "rst_parser.Document") -> tuple[list, bool]: @@ -72,6 +93,8 @@ def docs_get_section( if not file_path: return {"ok": False, "error": "missing_path"} repo_name = _resolve_docs_repo(repo) + if not repo_name: + return {"ok": False, "error": "repo_not_allowed", "requested_repo": repo} max_chars = _clamp(max_chars, MAX_CHARS_MIN, MAX_CHARS_MAX, MAX_CHARS_DEFAULT) offset = _clamp(offset, 0, 10 ** 9, 0) diff --git a/services/config_inspector_service.py b/services/config_inspector_service.py index 9da4e6fcd..443350217 100644 --- a/services/config_inspector_service.py +++ b/services/config_inspector_service.py @@ -537,8 +537,8 @@ class ConfigService: key="MCP_DOCS_REPO", default="CodeBot", description=( - "שם הריפו (repo_name ב-mirror) שממנו כלי codekeeper_docs_get_section של ה-MCP " - "קורא את קבצי ה-RST של התיעוד. ברירת מחדל: CodeBot." + "רשימת הריפואים המותרים (CSV allowlist) שכלי codekeeper_docs_get_section הציבורי " + "רשאי לקרוא מהם קבצי RST. גבול אבטחה — repo שאינו ברשימה נדחה. ברירת מחדל: CodeBot." ), category="mcp", ), diff --git a/tests/test_mcp_docs_handlers.py b/tests/test_mcp_docs_handlers.py index ff27959e8..685ef3a37 100644 --- a/tests/test_mcp_docs_handlers.py +++ b/tests/test_mcp_docs_handlers.py @@ -2,6 +2,8 @@ from pathlib import Path +import pytest + from mcp_server import docs_handlers _ROOT = Path(__file__).resolve().parents[1] @@ -108,3 +110,32 @@ def test_missing_file_propagates_not_found(): out = docs_handlers.docs_get_section(_FsBackend(), path="no-such-doc-xyz") assert out["ok"] is False and out["error"] == "not_found" assert out["repo"] == "CodeBot" + + +# ---- הקשחת אבטחה (סבב review) ---- + +def test_explicit_repo_not_in_allowlist_rejected(monkeypatch): + monkeypatch.delenv("MCP_DOCS_REPO", raising=False) # allowlist = [CodeBot] + be = _FsBackend() + out = docs_handlers.docs_get_section(be, path="environment-variables", repo="SomeOtherRepo") + assert out["ok"] is False and out["error"] == "repo_not_allowed" + assert be.calls == [] # ה-backend לא נקרא כלל (IDOR נחסם) + + +def test_explicit_repo_in_allowlist_allowed(monkeypatch): + monkeypatch.setenv("MCP_DOCS_REPO", "CodeBot, other-docs") + out = docs_handlers.docs_get_section(_FsBackend(), path="environment-variables", repo="CodeBot") + assert out["ok"] and out["repo"] == "CodeBot" + + +@pytest.mark.parametrize("bad_path", [ + "webapp/config", # מחוץ ל-docs/ עם slash + "../../etc/passwd", # traversal + "docs/../secrets", # traversal שרזולב החוצה מ-docs/ + "/etc/hosts", # נתיב מוחלט +]) +def test_path_outside_docs_rejected(bad_path): + be = _FsBackend() + out = docs_handlers.docs_get_section(be, path=bad_path) + assert out["ok"] is False and out["error"] == "missing_path" + assert be.calls == [] # נחסם ב-handler לפני ה-backend