Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions FEATURE_SUGGESTIONS/FEATURE_MCP_CLAUDE_INTEGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 של הוובאפ.

Expand Down
6 changes: 6 additions & 0 deletions docs/environment-variables.rst
Original file line number Diff line number Diff line change
Expand Up @@ -2108,6 +2108,12 @@
- ``CodeKeeper``
- ``CodeKeeper``
- MCP
* - ``MCP_DOCS_REPO``
- רשימת הריפואים המותרים (CSV allowlist) שכלי ``codekeeper_docs_get_section`` הציבורי רשאי לקרוא מהם קבצי RST. גבול אבטחה — ארגומנט ``repo`` שאינו ברשימה נדחה. ברירת מחדל ``CodeBot``.
- לא
- ``CodeBot``
- ``CodeBot``
- MCP
* - ``MCP_ALLOWED_HOSTS``
- רשימת Host מותרים לשרת ה-MCP (CSV; תומך wildcard כמו ``*.onrender.com``). ריק = הגנת DNS-rebinding כבויה (מתאים לשרת ציבורי מוגן-טוקן).
- לא
Expand Down
4 changes: 4 additions & 0 deletions docs/mcp-server.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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``

כלי אדמין (דפדפן הריפו — מוסתרים וחסומים לכל משתמש אחר):

Expand Down
1 change: 1 addition & 0 deletions mcp_server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |

### כלי אדמין — דפדפן הריפו (פאזה ד', קריאה בלבד)

Expand Down
179 changes: 179 additions & 0 deletions mcp_server/docs_handlers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
"""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
import posixpath
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 _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 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/ בלבד.

מחזיר None עבור traversal (``..``), נתיב מוחלט, או כל נתיב שאינו תחת ``docs/`` — כולל
קלט שמכיל slash (למשל ``webapp/x``). זו הגנת שכבה בנוסף לאימות ה-path של ה-backend.
"""
p = (path or "").strip().strip("/")
if not p or "\x00" in p:
return None
if not p.endswith(".rst"):
p = p + ".rst"
if "/" not in p:
p = "docs/" + 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]:
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)
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)

# קריאה — 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
50 changes: 49 additions & 1 deletion mcp_server/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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"})

Expand Down
9 changes: 9 additions & 0 deletions services/config_inspector_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -623,6 +623,15 @@ class ConfigService:
description="שם התצוגה של שרת ה-MCP (שם ה-Connector שמוצג ללקוח).",
category="mcp",
),
"MCP_DOCS_REPO": ConfigDefinition(
key="MCP_DOCS_REPO",
default="CodeBot",
description=(
"רשימת הריפואים המותרים (CSV allowlist) שכלי codekeeper_docs_get_section הציבורי "
"רשאי לקרוא מהם קבצי RST. גבול אבטחה — repo שאינו ברשימה נדחה. ברירת מחדל: CodeBot."
),
category="mcp",
),
"MCP_ALLOWED_HOSTS": ConfigDefinition(
key="MCP_ALLOWED_HOSTS",
services=("mcp",),
Expand Down
Loading
Loading