From 60f39df6ea5e7b20e03c5af1e2ee705d4bf3dc52 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Jul 2026 11:31:18 +0000 Subject: [PATCH 1/8] =?UTF-8?q?docs:=20add=20MCP=E2=80=93Claude.ai=20integ?= =?UTF-8?q?ration=20plan=20for=20CodeKeeper=20webapp?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit מסמך תכנון לחיבור Claude.ai לקבצי המשתמש ב-CodeKeeper דרך שרת MCP מרוחק. - ממפה את הקיים: שכבת DB in-process (database/repository.py), אימות סשן (webapp/app.py:3204), וגשרי טוקנים קיימים (webapp_tokens/remember_tokens). - מציע ארכיטקטורה: שירות MCP נפרד (ASGI) שניגש ישירות ל-database, מסונן user_id. - אימות בשתי פאזות: PAT (MVP) → OAuth 2.1 מעל זהות טלגרם הקיימת. - כלים: list_files/search_code/get_file/list_versions (+ save_file בהמשך). - אבטחה: read-only כברירת מחדל, בידוד user_id מהטוקן, Smart Projection. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01WNFuSyshwpRcxozVZEui5K --- .../FEATURE_MCP_CLAUDE_INTEGRATION.md | 223 ++++++++++++++++++ 1 file changed, 223 insertions(+) create mode 100644 FEATURE_SUGGESTIONS/FEATURE_MCP_CLAUDE_INTEGRATION.md diff --git a/FEATURE_SUGGESTIONS/FEATURE_MCP_CLAUDE_INTEGRATION.md b/FEATURE_SUGGESTIONS/FEATURE_MCP_CLAUDE_INTEGRATION.md new file mode 100644 index 000000000..e3a4ec419 --- /dev/null +++ b/FEATURE_SUGGESTIONS/FEATURE_MCP_CLAUDE_INTEGRATION.md @@ -0,0 +1,223 @@ +# חיבור Claude.ai ל‑CodeKeeper דרך MCP — מסמך תכנון + +> **סטטוס:** תכנון (Draft) — לפני כתיבת קוד. ממתין לאישור כיוון. +> **ענף פיתוח:** `claude/mcp-codekeeper-webapp-ldnzsg` +> **מתי להשתמש:** לפני מימוש חיבור MCP; מסמך זה הוא מקור האמת לתכנון. +> **ראו גם:** [CodeBot – Project Docs](https://amirbiron.github.io/CodeBot/), `CLAUDE.md` (מדיניות מחייבת). + +--- + +## 1. תקציר מנהלים (What / Why) + +**What:** לחשוף את הקבצים והקוד ששמורים ב‑CodeKeeper (מסד `code_snippets`) ל‑Claude.ai דרך **שרת MCP מרוחק** (Model Context Protocol). כך משתמש יוכל לחבר את החשבון שלו ב‑Claude, ולבקש מ‑Claude לקרוא/לחפש (ובהמשך גם לשמור) קבצים ישירות מהמאגר האישי שלו. + +**Why:** היום הקבצים נגישים רק דרך הבוט בטלגרם ודרך הוובאפ. חיבור MCP הופך את המאגר האישי למקור הקשר (context) חי בתוך Claude — בלי להעתיק‑להדביק, עם שמירה על בעלות והרשאות לכל משתמש. + +**הגישה המומלצת (פשוט ואמין קודם):** להתחיל מ‑MVP קריאה‑בלבד עם טוקן אישי (PAT) שנבדק מול Claude Code/Desktop, ורק אחר כך להוסיף OAuth מלא שהופך אותו ל‑Connector אמיתי של Claude.ai. כתיבה מגיעה בשלב האחרון, מאחורי הרשאה מפורשת. + +--- + +## 2. רקע — מה כבר קיים היום (וזה עוזר מאוד) + +### 2.1 שכבת נתונים נקייה בתוך התהליך +יש API סינכרוני נקי שאפשר לייבא ולהשתמש בו ישירות, בלי לעבור דרך ה‑HTTP של הוובאפ: + +```python +from database import db # DatabaseManager singleton (database/__init__.py:11) + +db.get_user_files(user_id, limit=50, skip=0) # רשימה, ללא שדות כבדים +db.get_latest_version(user_id, file_name) # תוכן מלא של קובץ +db.get_file_by_id(file_id) # תוכן מלא לפי _id +db.search_code(user_id, query, programming_language=None, tags=None, limit=20) +db.get_all_versions(user_id, file_name) # היסטוריית גרסאות +db.save_file(user_id, file_name, code, programming_language, extra_tags=None) +db.delete_file(user_id, file_name) # מחיקה רכה (recycle bin) +``` + +מקורות: `database/repository.py` (למשל `get_user_files:846`, `get_latest_version:781`, `search_code:925`, `save_file:682`, `save_code_snippet:201`), delegation ב‑`database/manager.py`. + +**נקודה קריטית:** אין אימות בשכבת ה‑DB — כל שיטה מסוננת לפי `user_id` אבל **סומכת על הקורא** שיעביר `user_id` נכון. לכן שרת ה‑MCP חייב לגזור את `user_id` מהטוקן בלבד, ואף פעם לא מקלט של הלקוח. + +### 2.2 מודל הנתונים של קובץ +קולקשן `code_snippets` (dataclass `CodeSnippet` ב‑`database/models.py:9`): + +| שדה | תיאור | +|------|--------| +| `user_id` (int) | מזהה טלגרם של הבעלים (מפתח הבידוד) | +| `file_name` | זהות לוגית; גרסאות חולקות שם | +| `code` | תוכן (שדה כבד) | +| `programming_language` | שפה | +| `tags`, `description` | מטא‑דאטה; תגית `repo:*` = קובץ שיובא מ‑GitHub | +| `version`, `is_active` | ניהול גרסאות (append‑only) + מחיקה רכה | +| `file_size`, `lines_count` | מחושבים בזמן שמירה (`repository.py:247,252`) | +| `is_favorite`, `is_pinned` | מועדפים/נעיצה | + +קבצים גדולים יושבים ב‑`large_files` (שדה `content` במקום `code`, replace‑on‑save). + +### 2.3 אימות וזהות (היום — סשן בלבד) +- הכל נשען על **cookie סשן חתום** בשם `session`, ומזהה יחיד `session['user_id']`. +- `login_required` — `webapp/app.py:3204`; `get_current_user_id` — `webapp/app.py:3249`. +- **אין** JWT, אין PAT, אין OAuth‑provider, אין CORS, אין CSRF framework (תלויות: `Flask==3.1.2`, בלי `flask-login`/`flask-cors`/`authlib`). + +**גשרי טוקנים שכבר עובדים (הבסיס להרחבה):** +1. `webapp_tokens` — טוקן התחברות **חד‑פעמי, 5 דקות**. הבוט מנפיק (`conversation_handlers.py:299` — `sha256(f"{user_id}:{time}:{secret}")[:32]`), הוובאפ ממיר לסשן ב‑`GET /auth/token` (`webapp/routes/auth_routes.py:202`). +2. `remember_tokens` — טוקן ארוך‑טווח (`secrets.token_urlsafe(32)`), עם cookie `remember_me` והתחברות‑מחדש ב‑`try_persistent_login()` (`webapp/app.py:3371`). +3. `DB_HEALTH_TOKEN` — סוד תפעולי משותף (Bearer), **לא לכל משתמש** (`webapp/app.py:4927`). + +הסודות המשותפים בין הבוט לוובאפ: `BOT_TOKEN`, `SECRET_KEY`, `WEBAPP_LOGIN_SECRET`, `MONGODB_URL`. + +### 2.4 פריסה +- הוובאפ רץ תחת **gunicorn + gevent** (WSGI), מודול `app:app`, worker יחיד כברירת מחדל (רגיש לזיכרון ב‑Render). ראו `scripts/start_webapp.sh`. +- בתלויות כבר יש `uvicorn==0.38.0` ו‑`asgiref==3.8.1` (מתאים ל‑ASGI/Streamable‑HTTP). +- הבוט הוא תהליך נפרד (`python main.py`); יש גם push‑worker (Node). + +--- + +## 3. מה Claude.ai דורש כדי להתחבר (Custom Connector = Remote MCP) + +- **תעבורה:** Streamable HTTP (לא stdio — stdio זה רק Claude Desktop מקומי). +- **אימות:** **OAuth 2.1 + PKCE (S256)** — חובה. עם רישום לקוח דינמי (**DCR**, RFC 7591) או CIMD / credentials מוחזקים ע"י Anthropic. +- **Discovery:** Protected Resource Metadata (RFC 9728) ב‑`/.well-known/oauth-protected-resource`, ו‑Authorization Server Metadata ב‑`/.well-known/oauth-authorization-server`. +- **Refresh tokens:** רוטציה עבור public clients. +- **רשת:** טווח ה‑egress של Anthropic חייב להגיע לשרת. +- **תוכניות:** Claude Pro/Max/Team/Enterprise. + +מקורות: [Claude Help Center — custom connectors](https://support.claude.com/en/articles/11175166-get-started-with-custom-connectors-using-remote-mcp), [Authentication for connectors](https://claude.com/docs/connectors/building/authentication). + +--- + +## 4. ארכיטקטורה מוצעת + +``` +┌────────────┐ OAuth 2.1 (PKCE) ┌──────────────────────────┐ +│ Claude.ai │ ───────────────────► │ שרת MCP חדש (שירות נפרד) │ +│ (Connector)│ Streamable HTTP │ Python + ASGI (uvicorn) │ +└────────────┘ │ - tools: list/search/get│ + │ - resources: file://… │ + │ - גוזר user_id מהטוקן │ + └───────────┬──────────────┘ + │ import ישיר + ▼ + ┌──────────────────────────┐ + │ database/ → MongoDB │ + │ code_snippets (per user) │ + └──────────────────────────┘ +``` + +**למה שירות נפרד ולא בתוך הוובאפ:** +- ה‑worker של הוובאפ יחיד ורגיש לזיכרון ב‑Render — לא רוצים להעמיס עליו SSE ארוך‑טווח. +- MCP נוח יותר כ‑ASGI (`uvicorn`), בעוד הוובאפ הוא WSGI. +- בידוד = פחות סיכון; אפשר לתת הרשאות קריאה בלבד ל‑DB בשלב א'. +- שיתוף פשוט: אותו `MONGODB_URL` ואותם סודות דרך ENV ב‑Render. + +**חלופה (לא מומלצת לפרודקשן):** stdio מקומי מול Claude Desktop שמתחבר ישר ל‑Mongo — טוב ל‑POC אבל חושף credentials במכונת המשתמש ולא עונה על "Claude.ai בוובאפ". + +--- + +## 5. הכלים והמשאבים (MCP surface) + +### 5.1 Tools +| כלי | קלט | פלט | נשען על | +|-----|-----|-----|---------| +| `list_files` | `limit`, `page`, `language?`, `tag?` | רשימת מטא‑דאטה (בלי `code`) | `get_user_files` | +| `search_code` | `query`, `language?`, `type?` | תוצאות + snippet קצר | `search_code` | +| `get_file` | `file_name` \| `file_id`, `version?` | תוכן מלא + מטא‑דאטה | `get_latest_version` / `get_file_by_id` | +| `list_versions` | `file_name` | היסטוריית גרסאות | `get_all_versions` | +| `get_version_diff` | `file_id`, `left?`, `right?` | diff בין גרסאות | `webapp/app.py:3264` (compare) | +| `list_collections` | — | אוספים של המשתמש | `collections_manager` | +| `save_file` *(פאזה 3)* | `file_name`, `code`, `language`, `tags?`, `description?` | גרסה חדשה | `save_file` | +| `delete_file` *(פאזה 3)* | `file_name` | מחיקה רכה | `delete_file` | + +### 5.2 Resources (אופציונלי, מומלץ) +לחשוף כל קובץ כ‑`codekeeper://file/{file_name}` כדי לאפשר תיוג `@` של קובץ בתוך Claude. רשימת המשאבים = `get_user_files` (בלי תוכן), והקריאה בפועל = `get_latest_version`. + +### 5.3 עקרונות תגובה +- לכבד את **חוק ה‑Smart Projection** (`CLAUDE.md`): ברשימות/חיפוש **לא** למשוך `code`/`content` — רק מטא‑דאטה + snippet. תוכן מלא רק ב‑`get_file` מפורש. +- לסנן `repo:*` בברירת מחדל (כמו `/api/files`), עם דגל אופציונלי לכלול. + +--- + +## 6. אימות — שתי פאזות + +### פאזה 0 — Personal Access Token (הכי פשוט ואמין; מתחילים כאן) +1. פקודת בוט חדשה `/connect_claude` שמנפיקה טוקן אקראי ארוך (`secrets.token_urlsafe(32)`), שומרת ב‑קולקשן חדש `mcp_tokens`: + ```json + { "token_hash": "sha256(...)", "user_id": 12345, + "scopes": ["read"], "created_at": "...", "last_used_at": "...", + "expires_at": null, "revoked": false, "label": "Claude.ai" } + ``` + (שומרים **hash** של הטוקן, לא את הטוקן עצמו — מציגים למשתמש פעם אחת בלבד.) +2. שרת ה‑MCP מקבל `Authorization: Bearer `, מאמת מול `mcp_tokens`, וגוזר `user_id`. +3. עובד **מיד** מול Claude Code / Claude Desktop (שתומכים בכותרות), ומוכיח את כל הצינור. + +זה הדפוס שכבר קיים ב‑`webapp_tokens`/`conversation_handlers.py:299` — רק ארוך‑טווח, מרובה‑שימוש, וניתן לביטול (revoke). + +### פאזה 1 — OAuth 2.1 (מה שהופך אותו ל‑Connector של Claude.ai) +- מכיוון שהזהות כבר קיימת דרך טלגרם, מסך ה‑`/authorize` **משתמש בסשן הטלגרם הקיים**: אם המשתמש כבר מחובר לוובאפ — מציגים מסך אישור (consent) ומנפיקים authorization code; אם לא — שולחים אותו לזרימת ההתחברות הקיימת ואז חוזרים. +- להנפיק access token (קצר) + refresh token (עם רוטציה), קשורים ל‑`user_id` ול‑`scopes`. +- להגיש `/.well-known/oauth-protected-resource` ו‑`/.well-known/oauth-authorization-server`, ולתמוך ב‑DCR. +- מימוש מומלץ: `authlib` (יש כבר `google-auth-oauthlib` באקוסיסטם) או שכבת ה‑Auth המובנית של MCP Python SDK / FastMCP. + +**חשוב:** access/refresh tokens נשמרים גם הם רק כ‑hash, וה‑`user_id` לעולם לא מגיע מהלקוח אלא מהטוקן שאומת. + +--- + +## 7. פערים ב‑API ה‑HTTP — ולמה ה‑DB בתוך התהליך פותר אותם +מיפוי הקוד העלה שני חורים ב‑REST הקיים: +1. **אין endpoint JSON שמחזיר תוכן מלא לפי id לבעלים** (רק `GET /download/` כ‑text/plain). +2. **אין endpoint JSON ליצירת קובץ** (עריכה נעשית דרך טופס HTML `POST /edit/`). + +מכיוון ששרת ה‑MCP ניגש ישירות ל‑`database` (`get_file_by_id`, `save_file`) — **שני הפערים נעלמים** בלי לתקן קודם את הוובאפ. זו עוד סיבה להעדיף גישת in‑process על פני עטיפת ה‑HTTP. + +--- + +## 8. אבטחה ופרטיות +- **בידוד לפי `user_id` בכל כלי** — נגזר מהטוקן בלבד; לעולם לא מקלט לקוח. +- **קריאה‑בלבד כברירת מחדל**; כתיבה/מחיקה מאחורי `scope` נפרד ואישור מפורש (פאזה 3). +- **טוקנים כ‑hash** ב‑DB; הצגה חד‑פעמית; אפשרות revoke ורשימת חיבורים פעילים. +- **Rate limiting** (יש כבר `Flask-Limiter`; לשירות ה‑MCP נגדיר מגבלות משלו). +- **בלי סודות/PII בלוגים** — השחרה, לפי מדיניות `CLAUDE.md`. +- לכבד Smart Projection — לא לשלוף `code` ברשימות (גם ביצועים וגם צמצום חשיפה). + +--- + +## 9. תשתית ופריסה +- **שירות Render חדש** (web, ASGI) — למשל `code-keeper-mcp`, `dockerfilePath`/`startCommand` = `uvicorn mcp_server.app:app`. +- **ENV משותף:** `MONGODB_URL`, `DATABASE_NAME`, `SECRET_KEY`/`WEBAPP_LOGIN_SECRET`, ו‑ENV ייעודי ל‑OAuth (issuer URL, טווח תוקף וכו'). +- **תלויות חדשות** (בקובצי `requirements/`): `mcp` (MCP Python SDK / FastMCP), ובפאזה 1 גם `authlib`. +- **תיקייה חדשה** מוצעת: `mcp_server/` בשורש (או תת‑שירות תחת `services/`), עם `app.py`, `auth.py`, `tools.py`, `resources.py`. + +--- + +## 10. מפת דרכים, בדיקות והערכת מאמץ + +| שלב | תוכן | הערכה | בדיקות | +|------|------|-------|--------| +| א' | MVP קריאה‑בלבד: שרת MCP + `list_files`/`search_code`/`get_file`/`list_versions` + `/connect_claude` + `mcp_tokens` | ~2–3 ימים | יחידה לכל כלי (mongomock/tmp), בדיקת בידוד `user_id`, ריצה מול Claude Code | +| ב' | OAuth 2.1: authorize/token/register + `.well-known` + מסך consent (מעל סשן טלגרם) | ~3–5 ימים | זרימת OAuth מקצה‑לקצה, רוטציית refresh, בדיקת discovery | +| ג' | כתיבה: `save_file`/`delete_file` מאחורי scope, rate limiting, מסך "חיבורים פעילים" + revoke | ~1–2 ימים | בדיקות כתיבה על tmp בלבד, אישור scope, revoke | + +**בדיקות — לפי `CLAUDE.md`:** לעבוד רק על תיקיות זמניות, בלי מחיקות ב‑root, בידוד לכל טסט. לפני תיקוני טסטים — לעיין ב‑[CodeBot Docs](https://amirbiron.github.io/CodeBot/). + +--- + +## 11. סיכונים ו‑Rollback +- **חשיפת נתונים אישיים** → מיטיגציה: read‑only כברירת מחדל, scopes, טוקנים ניתנים לביטול, בידוד `user_id` נבדק בטסטים. +- **עומס זיכרון** → שירות נפרד, לא בתוך worker הוובאפ. +- **מורכבות OAuth** → מתחילים מ‑PAT (פאזה 0) שמוכיח ערך בלי OAuth. +- **Rollback:** השירות נפרד — ביטול פריסה של `code-keeper-mcp` וביטול טוקנים (`mcp_tokens.revoked=true`) מנתק הכול בלי לגעת בבוט/וובאפ. + +--- + +## 12. שאלות פתוחות להחלטה +1. **מאיפה מתחילים** — MVP+PAT (מומלץ) / ישר ל‑OAuth / רק מסמך זה לאישור? +2. **קריאה‑בלבד או גם כתיבה** בשלב הראשון? (המלצה: קריאה‑בלבד). +3. **היקף** — רק `code_snippets`, או גם collections/bookmarks/large_files? +4. **שם ודומיין** לשירות ה‑MCP ב‑Render. + +--- + +## 13. מקורות +- [Claude Help Center — Get started with custom connectors using remote MCP](https://support.claude.com/en/articles/11175166-get-started-with-custom-connectors-using-remote-mcp) +- [Claude Docs — Authentication for connectors](https://claude.com/docs/connectors/building/authentication) +- [CodeBot – Project Docs](https://amirbiron.github.io/CodeBot/) From 603e12443c05cdd6a9a71a25b07a5255775c06a1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Jul 2026 12:37:02 +0000 Subject: [PATCH 2/8] feat(mcp): add read-only MCP server (Phase 0) with PAT auth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit מימוש פאזה 0 מהתוכנית: שרת MCP מרוחק (Streamable HTTP) קריאה-בלבד שחושף את קבצי הקוד והאוספים של המשתמש ל-Claude Code/Desktop. - mcp_server/: חבילת FastMCP עם 7 כלים (list_files, search_code, get_file, list_versions, list_collections, get_collection, get_collection_items). ניגש ישירות ל-database.db + CollectionsManager, מסונן user_id, מכבד את חוק ה-Smart Projection (בלי code ברשימות/חיפוש). - אימות PAT: token_store.py מנהל טוקנים (hash בלבד) בקולקשן mcp_tokens; auth.py מאמת Bearer ומזריק user_id ל-request.state; ה-user_id נגזר תמיד מהטוקן, לעולם לא מקלט הלקוח. get_file בודק בעלות גם לפי file_id. - scripts/mcp_issue_token.py: הנפקת טוקן ל-CLI/בדיקות. - tests/test_mcp_*.py: 27 טסטים (fakes ידניים, בלי MongoDB אמיתי) — token store, בעלות/סריאליזציה, clamping, middleware, ורישום כלים. - requirements/base.txt: mcp==1.28.1 (תואם לפינים הקיימים). - מסמך התכנון עודכן לסטטוס "פאזה 0 מומשה" + README ל-mcp_server. פאזות הבאות (בתכנון): פקודת בוט /connect_claude, OAuth 2.1 ל-Claude.ai, כתיבה. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01WNFuSyshwpRcxozVZEui5K --- .../FEATURE_MCP_CLAUDE_INTEGRATION.md | 11 +- mcp_server/README.md | 99 +++++++++++ mcp_server/__init__.py | 16 ++ mcp_server/app.py | 57 +++++++ mcp_server/auth.py | 79 +++++++++ mcp_server/backend.py | 159 ++++++++++++++++++ mcp_server/handlers.py | 96 +++++++++++ mcp_server/server.py | 104 ++++++++++++ mcp_server/token_store.py | 153 +++++++++++++++++ requirements/base.txt | 4 + scripts/mcp_issue_token.py | 75 +++++++++ tests/test_mcp_auth_middleware.py | 68 ++++++++ tests/test_mcp_backend.py | 119 +++++++++++++ tests/test_mcp_handlers.py | 79 +++++++++ tests/test_mcp_server_build.py | 59 +++++++ tests/test_mcp_token_store.py | 126 ++++++++++++++ 16 files changed, 1302 insertions(+), 2 deletions(-) create mode 100644 mcp_server/README.md create mode 100644 mcp_server/__init__.py create mode 100644 mcp_server/app.py create mode 100644 mcp_server/auth.py create mode 100644 mcp_server/backend.py create mode 100644 mcp_server/handlers.py create mode 100644 mcp_server/server.py create mode 100644 mcp_server/token_store.py create mode 100644 scripts/mcp_issue_token.py create mode 100644 tests/test_mcp_auth_middleware.py create mode 100644 tests/test_mcp_backend.py create mode 100644 tests/test_mcp_handlers.py create mode 100644 tests/test_mcp_server_build.py create mode 100644 tests/test_mcp_token_store.py diff --git a/FEATURE_SUGGESTIONS/FEATURE_MCP_CLAUDE_INTEGRATION.md b/FEATURE_SUGGESTIONS/FEATURE_MCP_CLAUDE_INTEGRATION.md index e3a4ec419..3bdadf05a 100644 --- a/FEATURE_SUGGESTIONS/FEATURE_MCP_CLAUDE_INTEGRATION.md +++ b/FEATURE_SUGGESTIONS/FEATURE_MCP_CLAUDE_INTEGRATION.md @@ -1,9 +1,16 @@ # חיבור Claude.ai ל‑CodeKeeper דרך MCP — מסמך תכנון -> **סטטוס:** תכנון (Draft) — לפני כתיבת קוד. ממתין לאישור כיוון. +> **סטטוס:** פאזה 0 (MVP קריאה‑בלבד + PAT) **מומשה** — ראו `mcp_server/`. פאזות 1–3 עדיין בתכנון. > **ענף פיתוח:** `claude/mcp-codekeeper-webapp-ldnzsg` > **מתי להשתמש:** לפני מימוש חיבור MCP; מסמך זה הוא מקור האמת לתכנון. -> **ראו גם:** [CodeBot – Project Docs](https://amirbiron.github.io/CodeBot/), `CLAUDE.md` (מדיניות מחייבת). +> **ראו גם:** `mcp_server/README.md` (שימוש), [CodeBot – Project Docs](https://amirbiron.github.io/CodeBot/), `CLAUDE.md` (מדיניות מחייבת). + +> **מצב מימוש (פאזה 0):** נכתבה חבילת `mcp_server/` (שרת FastMCP קריאה‑בלבד עם 7 כלים +> ל‑`code_snippets` + `collections`), אימות PAT מעל קולקשן `mcp_tokens`, סקריפט +> `scripts/mcp_issue_token.py`, וטסטים `tests/test_mcp_*.py`. **הערת סטייה מהמסמך:** +> מאגר הטוקנים נמצא ב‑`mcp_server/token_store.py` (ולא `database/mcp_tokens.py`), כדי +> שהמודולים יהיו נטולי תלויות כבדות וניתנים לבדיקה בבידוד. פקודת הבוט `/connect_claude` +> ו‑OAuth (Claude.ai) יגיעו בפאזות הבאות. --- diff --git a/mcp_server/README.md b/mcp_server/README.md new file mode 100644 index 000000000..35f80ac11 --- /dev/null +++ b/mcp_server/README.md @@ -0,0 +1,99 @@ +# CodeKeeper MCP Server (פאזה 0 — קריאה בלבד) + +שרת [MCP](https://modelcontextprotocol.io) קטן שחושף את **הקבצים והאוספים** +השמורים של המשתמש ל‑Claude (Claude Code / Claude Desktop כרגע; Claude.ai דרך +OAuth בפאזה הבאה). קריאה בלבד — אין כלי כתיבה/מחיקה. + +> תכנון מלא: `FEATURE_SUGGESTIONS/FEATURE_MCP_CLAUDE_INTEGRATION.md` + +--- + +## מה זה עושה + +ניגש ישירות לשכבת ה‑DB הקיימת (`database.db` + `CollectionsManager`), מסונן +תמיד לפי ה‑`user_id` שנגזר מהטוקן. מכבד את חוק ה‑Smart Projection: רשימות/חיפוש +לא מחזירים את שדה ה‑`code` הכבד — תוכן מלא רק ב‑`get_file`. + +### הכלים (Tools) +| כלי | תיאור | +|-----|-------| +| `list_files` | רשימת קבצים (מטא‑דאטה בלבד), עם עימוד | +| `search_code` | חיפוש טקסט בקוד → מטא‑דאטה של קבצים תואמים | +| `get_file` | תוכן מלא של קובץ לפי `file_name` או `file_id` (אופציונלי: גרסה) | +| `list_versions` | היסטוריית גרסאות של קובץ (מטא‑דאטה) | +| `list_collections` | האוספים של המשתמש | +| `get_collection` | אוסף בודד לפי id | +| `get_collection_items` | הקבצים בתוך אוסף (עם עימוד/סינון תיקייה) | + +--- + +## אימות (Personal Access Token) + +הזדהות היא `Authorization: Bearer `. הטוקן נשמר כ‑**hash בלבד** בקולקשן +`mcp_tokens`, קשור ל‑`user_id`, וניתן לביטול. ה‑`user_id` נגזר תמיד מהטוקן — +לעולם לא מקלט הלקוח. + +### הנפקת טוקן (לבדיקות/אופס) +```bash +MONGODB_URL="..." python scripts/mcp_issue_token.py --user-id --label "Claude Desktop" +``` +הפקודה מדפיסה את הטוקן **פעם אחת**. (בפאזה הבאה תתווסף פקודת בוט `/connect_claude` +שתעשה את זה מתוך טלגרם.) + +--- + +## הרצה מקומית +```bash +pip install -r requirements/development.txt +MONGODB_URL="..." uvicorn mcp_server.app:app --host 0.0.0.0 --port 8000 +``` +- נקודת הקצה של MCP: `POST/GET /mcp` (Streamable HTTP). +- בריאות (ללא אימות): `GET /healthz`. + +--- + +## חיבור מ‑Claude Code +```bash +claude mcp add --transport http codekeeper http://localhost:8000/mcp \ + --header "Authorization: Bearer " +``` + +## חיבור מ‑Claude Desktop (`claude_desktop_config.json`) +```json +{ + "mcpServers": { + "codekeeper": { + "type": "http", + "url": "https:///mcp", + "headers": { "Authorization": "Bearer " } + } + } +} +``` + +> **Claude.ai (וובאפ):** דורש OAuth 2.1 — זו פאזה 1, עדיין לא כאן. PAT עובד היום +> מול Claude Code / Claude Desktop. + +--- + +## פריסה (Render) +שירות web נפרד (ASGI), משתף את אותו `MONGODB_URL` ואת אותם סודות: +``` +Start command: uvicorn mcp_server.app:app --host 0.0.0.0 --port $PORT +Health check: /healthz +``` +מומלץ שירות נפרד (ולא בתוך הוובאפ) כי ה‑worker של הוובאפ יחיד ורגיש לזיכרון. + +--- + +## מבנה הקוד +| קובץ | תפקיד | +|------|-------| +| `token_store.py` | ניהול PAT (הנפקה/אימות/ביטול) מעל `mcp_tokens` | +| `backend.py` | גישה לנתונים + סריאליזציה (Smart Projection, בדיקת בעלות) | +| `handlers.py` | לוגיקת הכלים הטהורה (ולידציה/clamping) — יעד הטסטים | +| `auth.py` | middleware של Bearer + `current_user_id(ctx)` | +| `server.py` | חיווט FastMCP: כלים + אפליקציית ASGI מאומתת | +| `app.py` | נקודת כניסה: `app = create_app()` | + +טסטים: `tests/test_mcp_*.py` (fakes ידניים, בלי MongoDB אמיתי). diff --git a/mcp_server/__init__.py b/mcp_server/__init__.py new file mode 100644 index 000000000..b8c45a3ff --- /dev/null +++ b/mcp_server/__init__.py @@ -0,0 +1,16 @@ +"""CodeKeeper MCP server package. + +A small, self-contained Model Context Protocol (MCP) server that exposes a +user's saved code files and collections to MCP clients (Claude Code / Claude +Desktop today; Claude.ai via OAuth in a later phase). + +Design goals for this package: +- Read-only for now (Phase 0). No write/delete tools. +- Every module here imports only light dependencies at module top-level, so the + pieces can be unit-tested in isolation without pulling in the whole app. + The heavy ``database`` package is imported lazily inside ``backend`` / + ``app`` (only when actually serving), never at import time. +- Authentication is a Personal Access Token (Bearer) verified against the + ``mcp_tokens`` collection. The authenticated ``user_id`` is derived from the + token only, never from client input. +""" diff --git a/mcp_server/app.py b/mcp_server/app.py new file mode 100644 index 000000000..522d3156b --- /dev/null +++ b/mcp_server/app.py @@ -0,0 +1,57 @@ +"""ASGI entrypoint for the CodeKeeper MCP server. + +Run with:: + + uvicorn mcp_server.app:app --host 0.0.0.0 --port $PORT + +Importing this module connects to MongoDB (via the shared ``database`` layer), +so it is intentionally NOT imported by the unit tests — those exercise the +lighter ``handlers`` / ``backend`` / ``token_store`` modules directly. +""" + +from __future__ import annotations + +import os +from typing import Any + +from .backend import ProductionBackend +from .server import build_app +from .token_store import MCPTokenStore + + +def _resolve_mongo(db_manager: Any) -> Any: + """Return the pymongo Database handle, forcing a connection if needed.""" + mongo = getattr(db_manager, "db", None) + if mongo is not None: + return mongo + # Some managers connect lazily; nudge them, then re-read. + for attr in ("connect", "_get_repo", "ensure_connection"): + fn = getattr(db_manager, attr, None) + if callable(fn): + try: + fn() + except Exception: + pass + mongo = getattr(db_manager, "db", None) + if mongo is not None: + return mongo + return None + + +def create_app(): + from database import db as db_manager # lazy heavy import + + mongo = _resolve_mongo(db_manager) + if mongo is None: + raise RuntimeError( + "MongoDB is not available (database.db is None). " + "Set MONGODB_URL for the MCP service." + ) + + token_store = MCPTokenStore(mongo) + backend = ProductionBackend(db_manager=db_manager, mongo_db=mongo) + name = os.getenv("MCP_SERVER_NAME", "CodeKeeper") + return build_app(backend, token_store, name=name) + + +app = create_app() diff --git a/mcp_server/auth.py b/mcp_server/auth.py new file mode 100644 index 000000000..7a971cebf --- /dev/null +++ b/mcp_server/auth.py @@ -0,0 +1,79 @@ +"""PAT (Bearer) authentication for the MCP HTTP app. + +A Starlette middleware verifies ``Authorization: Bearer `` against the +token store and injects the authenticated ``user_id`` onto ``request.state``. +Tools then read it via :func:`current_user_id` (same request object — no +contextvar propagation to worry about). + +This is deliberately simple (Phase 0). Phase 1 replaces it with OAuth 2.1. +""" + +from __future__ import annotations + +from collections.abc import Iterable +from typing import Any + +import anyio +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.requests import Request +from starlette.responses import JSONResponse + +# Paths that must remain reachable without a token (health checks, etc.). +EXEMPT_PATHS = {"/healthz", "/health", "/"} + +_WWW_AUTH = 'Bearer realm="CodeKeeper MCP"' + + +def _unauthorized(code: str) -> JSONResponse: + return JSONResponse( + {"error": code}, + status_code=401, + headers={"WWW-Authenticate": _WWW_AUTH}, + ) + + +class PATAuthMiddleware(BaseHTTPMiddleware): + """Authenticate every non-exempt request with a Personal Access Token.""" + + def __init__( + self, app: Any, token_store: Any, *, exempt_paths: Iterable[str] = EXEMPT_PATHS + ) -> None: + super().__init__(app) + self._store = token_store + self._exempt = set(exempt_paths) + + async def dispatch(self, request: Request, call_next): + if request.url.path in self._exempt: + return await call_next(request) + + auth = request.headers.get("authorization", "") + if not auth.lower().startswith("bearer "): + return _unauthorized("missing_bearer_token") + + token = auth.split(" ", 1)[1].strip() + try: + # verify() is a sync DB call — keep it off the event loop. + principal = await anyio.to_thread.run_sync(self._store.verify, token) + except Exception: + principal = None + + if not principal: + return _unauthorized("invalid_token") + + request.state.user_id = int(principal["user_id"]) + request.state.scopes = list(principal.get("scopes") or []) + return await call_next(request) + + +def current_user_id(ctx: Any) -> int: + """Return the authenticated user's id from an MCP tool ``Context``. + + Raises ``PermissionError`` if the request was not authenticated (which + should be impossible once :class:`PATAuthMiddleware` is installed, but we + fail closed rather than leak data). + """ + request = getattr(getattr(ctx, "request_context", None), "request", None) + user_id = getattr(getattr(request, "state", None), "user_id", None) + if user_id is None: + raise PermissionError("unauthenticated") + return int(user_id) diff --git a/mcp_server/backend.py b/mcp_server/backend.py new file mode 100644 index 000000000..24d59a0c5 --- /dev/null +++ b/mcp_server/backend.py @@ -0,0 +1,159 @@ +"""Data-access layer for the MCP tools. + +Tools/handlers depend on a duck-typed "backend" (any object exposing the read +methods below), so they can be unit-tested with a fake. ``ProductionBackend`` +wraps the real in-process database layer (``database.db`` + +``CollectionsManager``) and is imported lazily so this module stays light. + +All read paths are ``user_id``-scoped. The one method that touches a +non-user-scoped DB call (``get_file_by_id``) re-checks ownership here. + +Per the project's "Smart Projection" rule, list/search results never carry the +heavy ``code``/``content`` fields — full content is returned only by +``get_file`` for an explicit single-file fetch. +""" + +from __future__ import annotations + +import datetime as _dt +from typing import Any + +_HEAVY_FIELDS = ("code", "content", "raw_data", "raw_content") + + +def _json_safe(value: Any) -> Any: + """Recursively convert Mongo/BSON types to JSON-friendly values.""" + if isinstance(value, (_dt.datetime, _dt.date)): + try: + return value.isoformat() + except Exception: + return str(value) + if type(value).__name__ == "ObjectId": # avoid importing bson + return str(value) + if isinstance(value, dict): + return {str(k): _json_safe(v) for k, v in value.items()} + if isinstance(value, (list, tuple)): + return [_json_safe(v) for v in value] + return value + + +def _clean(doc: dict[str, Any], *, include_code: bool = False) -> dict[str, Any]: + """Serialize a file document. Drops heavy fields unless ``include_code``.""" + out: dict[str, Any] = {} + for key, val in (doc or {}).items(): + if key == "_id": + out["id"] = str(val) + continue + if not include_code and key in _HEAVY_FIELDS: + continue + out[key] = _json_safe(val) + # Friendlier alias without dropping the original field. + if "programming_language" in out: + out.setdefault("language", out["programming_language"]) + return out + + +def _full(doc: dict[str, Any]) -> dict[str, Any]: + """Serialize a single file WITH content (regular ``code`` or large ``content``).""" + out = _clean(doc, include_code=True) + if not out.get("code") and out.get("content"): + out["code"] = out["content"] + return out + + +class ProductionBackend: + """Backend backed by the real in-process ``database`` layer. + + Heavy imports (``database``) happen lazily on first use so importing this + module never drags in the whole application. + """ + + def __init__( + self, db_manager: Any = None, mongo_db: Any = None, collections_manager: Any = None + ) -> None: + self._dbm = db_manager + self._mongo = mongo_db + self._cm = collections_manager + + # -- lazy wiring ------------------------------------------------------- + def _require_dbm(self) -> Any: + if self._dbm is None: + from database import db as _db # lazy heavy import + + self._dbm = _db + return self._dbm + + def _collections(self) -> Any: + if self._cm is None: + from database.collections_manager import CollectionsManager # lazy + + mongo = ( + self._mongo if self._mongo is not None else getattr(self._require_dbm(), "db", None) + ) + if mongo is None: + raise RuntimeError("MongoDB handle unavailable for collections") + self._cm = CollectionsManager(mongo) + return self._cm + + # -- files ------------------------------------------------------------- + def list_files(self, user_id: int, *, page: int = 1, per_page: int = 50) -> dict[str, Any]: + files, total = self._require_dbm().get_regular_files_paginated(user_id, page, per_page) + return { + "files": [_clean(f) for f in (files or [])], + "total": int(total or 0), + "page": page, + "per_page": per_page, + } + + def search_code( + self, user_id: int, *, query: str, language: str | None = None, limit: int = 20 + ) -> list[dict[str, Any]]: + rows = self._require_dbm().search_code( + user_id, query, programming_language=language, limit=limit + ) + return [_clean(r) for r in (rows or [])] + + def get_file( + self, + user_id: int, + *, + file_name: str | None = None, + file_id: str | None = None, + version: int | None = None, + ) -> dict[str, Any] | None: + dbm = self._require_dbm() + if file_id: + doc = dbm.get_file_by_id(file_id) + # get_file_by_id is NOT user-scoped -> enforce ownership explicitly. + if not doc or int(doc.get("user_id", -1)) != int(user_id): + return None + elif file_name and version is not None: + doc = dbm.get_version(user_id, file_name, int(version)) + elif file_name: + doc = dbm.get_latest_version(user_id, file_name) + else: + return None + return _full(doc) if doc else None + + def list_versions(self, user_id: int, *, file_name: str) -> list[dict[str, Any]]: + return [_clean(v) for v in (self._require_dbm().get_all_versions(user_id, file_name) or [])] + + # -- collections ------------------------------------------------------- + def list_collections(self, user_id: int, *, limit: int = 100) -> dict[str, Any]: + return self._collections().list_collections(user_id, limit=limit) + + def get_collection(self, user_id: int, *, collection_id: str) -> dict[str, Any]: + return self._collections().get_collection(user_id, collection_id) + + def get_collection_items( + self, + user_id: int, + *, + collection_id: str, + page: int = 1, + per_page: int = 50, + folder: str | None = None, + ) -> dict[str, Any]: + return self._collections().get_collection_items( + user_id, collection_id, page=page, per_page=per_page, folder_filter=folder + ) diff --git a/mcp_server/handlers.py b/mcp_server/handlers.py new file mode 100644 index 000000000..b7ad9c34e --- /dev/null +++ b/mcp_server/handlers.py @@ -0,0 +1,96 @@ +"""Pure tool handlers. + +These are plain functions (no MCP / no Starlette imports) that validate and +clamp inputs, then delegate to a ``Backend``. Keeping them separate from the +FastMCP wiring makes the business logic trivially unit-testable. + +Every handler takes an authoritative, server-derived ``user_id`` — callers must +never pass a client-supplied user id here. +""" + +from __future__ import annotations + +from typing import Any + +MAX_PER_PAGE = 200 +MAX_SEARCH_LIMIT = 100 +MAX_COLLECTIONS_LIMIT = 500 + + +def _clamp(value: Any, lo: int, hi: int, default: int) -> int: + try: + ivalue = int(value) + except (TypeError, ValueError): + return default + return max(lo, min(hi, ivalue)) + + +def list_files(backend: Any, user_id: int, *, page: int = 1, per_page: int = 50) -> dict[str, Any]: + return backend.list_files( + user_id, + page=_clamp(page, 1, 10**9, 1), + per_page=_clamp(per_page, 1, MAX_PER_PAGE, 50), + ) + + +def search_code( + backend: Any, user_id: int, *, query: str, language: str | None = None, limit: int = 20 +) -> list[dict[str, Any]]: + query = (query or "").strip() + if not query: + return [] + return backend.search_code( + user_id, + query=query, + language=(language or None), + limit=_clamp(limit, 1, MAX_SEARCH_LIMIT, 20), + ) + + +def get_file( + backend: Any, + user_id: int, + *, + file_name: str | None = None, + file_id: str | None = None, + version: int | None = None, +) -> dict[str, Any] | None: + if not file_name and not file_id: + return None + return backend.get_file(user_id, file_name=file_name, file_id=file_id, version=version) + + +def list_versions(backend: Any, user_id: int, *, file_name: str) -> list[dict[str, Any]]: + if not file_name: + return [] + return backend.list_versions(user_id, file_name=file_name) + + +def list_collections(backend: Any, user_id: int, *, limit: int = 100) -> dict[str, Any]: + return backend.list_collections(user_id, limit=_clamp(limit, 1, MAX_COLLECTIONS_LIMIT, 100)) + + +def get_collection(backend: Any, user_id: int, *, collection_id: str) -> dict[str, Any]: + if not collection_id: + return {"ok": False, "error": "missing_collection_id"} + return backend.get_collection(user_id, collection_id=collection_id) + + +def get_collection_items( + backend: Any, + user_id: int, + *, + collection_id: str, + page: int = 1, + per_page: int = 50, + folder: str | None = None, +) -> dict[str, Any]: + if not collection_id: + return {"ok": False, "error": "missing_collection_id"} + return backend.get_collection_items( + user_id, + collection_id=collection_id, + page=_clamp(page, 1, 10**9, 1), + per_page=_clamp(per_page, 1, MAX_PER_PAGE, 50), + folder=folder, + ) diff --git a/mcp_server/server.py b/mcp_server/server.py new file mode 100644 index 000000000..17e5cd8cc --- /dev/null +++ b/mcp_server/server.py @@ -0,0 +1,104 @@ +"""FastMCP server wiring: tools + resources + the authenticated ASGI app. + +``build_mcp`` registers the read-only tools against an injected ``Backend``. +``build_app`` returns a Starlette ASGI app (Streamable HTTP) wrapped with PAT +auth plus an unauthenticated ``/healthz`` endpoint for platform health checks. + +Tools are defined as **sync** functions on purpose: FastMCP runs sync tools in a +worker thread, so the blocking (pymongo) backend calls never stall the event +loop, and the tool can still read ``ctx.request_context.request.state``. +""" + +from __future__ import annotations + +from typing import Any + +from mcp.server.fastmcp import Context, FastMCP +from starlette.responses import JSONResponse +from starlette.routing import Route + +from . import handlers +from .auth import PATAuthMiddleware, current_user_id + +_INSTRUCTIONS = ( + "Access the current user's private code files and collections stored in " + "CodeKeeper. Use search_code / list_files to find files (metadata only), " + "and get_file to read full contents. All data is scoped to the " + "authenticated user; this server is read-only." +) + + +def build_mcp(backend: Any, *, name: str = "CodeKeeper") -> FastMCP: + mcp: FastMCP = FastMCP(name, instructions=_INSTRUCTIONS, stateless_http=True) + + @mcp.tool(description="List the user's saved code files (metadata only, no code).") + def list_files(ctx: Context, page: int = 1, per_page: int = 50) -> dict: + return handlers.list_files(backend, current_user_id(ctx), page=page, per_page=per_page) + + @mcp.tool(description="Search the user's code by text; returns file metadata (no content).") + def search_code(ctx: Context, query: str, language: str | None = None, limit: int = 20) -> dict: + results = handlers.search_code( + backend, current_user_id(ctx), query=query, language=language, limit=limit + ) + return {"query": query, "count": len(results), "results": results} + + @mcp.tool(description="Get a file's full content by name or id (optional version number).") + def get_file( + ctx: Context, + file_name: str | None = None, + file_id: str | None = None, + version: int | None = None, + ) -> dict: + doc = handlers.get_file( + backend, current_user_id(ctx), file_name=file_name, file_id=file_id, version=version + ) + if doc is None: + return {"found": False} + return {"found": True, "file": doc} + + @mcp.tool(description="List all saved versions of a file by file_name (metadata only).") + def list_versions(ctx: Context, file_name: str) -> dict: + versions = handlers.list_versions(backend, current_user_id(ctx), file_name=file_name) + return {"file_name": file_name, "count": len(versions), "versions": versions} + + @mcp.tool(description="List the user's collections (named folders of files).") + def list_collections(ctx: Context, limit: int = 100) -> dict: + return handlers.list_collections(backend, current_user_id(ctx), limit=limit) + + @mcp.tool(description="Get a single collection by its id.") + def get_collection(ctx: Context, collection_id: str) -> dict: + return handlers.get_collection(backend, current_user_id(ctx), collection_id=collection_id) + + @mcp.tool(description="List files in a collection (paginated); optional folder filter.") + def get_collection_items( + ctx: Context, + collection_id: str, + page: int = 1, + per_page: int = 50, + folder: str | None = None, + ) -> dict: + return handlers.get_collection_items( + backend, + current_user_id(ctx), + collection_id=collection_id, + page=page, + per_page=per_page, + folder=folder, + ) + + return mcp + + +async def _healthz(_request): + return JSONResponse({"status": "ok", "service": "codekeeper-mcp"}) + + +def build_app(backend: Any, token_store: Any, *, name: str = "CodeKeeper"): + """Build the authenticated Streamable-HTTP ASGI app.""" + mcp = build_mcp(backend, name=name) + app = mcp.streamable_http_app() # Starlette app exposing POST/GET /mcp + # Unauthenticated health endpoint for the hosting platform. + app.router.routes.append(Route("/healthz", _healthz, methods=["GET"])) + # Auth guards everything except the exempt paths (see PATAuthMiddleware). + app.add_middleware(PATAuthMiddleware, token_store=token_store) + return app diff --git a/mcp_server/token_store.py b/mcp_server/token_store.py new file mode 100644 index 000000000..ad055902e --- /dev/null +++ b/mcp_server/token_store.py @@ -0,0 +1,153 @@ +"""Personal Access Token (PAT) store for the MCP server. + +A PAT lets a non-browser client (an MCP client such as Claude Code) authenticate +as a specific Telegram user without a session cookie. This mirrors the existing +one-time ``webapp_tokens`` bridge (see ``conversation_handlers.py``), but the +tokens here are long-lived, reusable, scoped, and revocable. + +Security choices: +- We store only a SHA-256 **hash** of each token, never the raw value. The raw + token is shown to the user exactly once (at issue time). +- Tokens carry 256 bits of entropy (``secrets.token_urlsafe(32)``), so a single + SHA-256 is sufficient here — unlike low-entropy passwords, no slow KDF needed. +- ``user_id`` is always authoritative and server-derived from the stored token. + +The store depends only on a duck-typed pymongo collection handle, so it can be +unit-tested with a tiny fake collection and adds no import-time dependencies. +""" + +from __future__ import annotations + +import hashlib +import secrets +from collections.abc import Iterable +from datetime import UTC, datetime, timedelta +from typing import Any + +TOKEN_PREFIX = "ckmcp_" # human-recognizable, greppable prefix +_RAW_ENTROPY_BYTES = 32 # 256-bit +DEFAULT_SCOPES = ("read",) +COLLECTION_NAME = "mcp_tokens" + + +def _now() -> datetime: + return datetime.now(UTC) + + +def _as_aware(dt: Any) -> datetime | None: + """Coerce a possibly tz-naive datetime (as Mongo may return) to aware UTC.""" + if not isinstance(dt, datetime): + return None + if dt.tzinfo is None: + return dt.replace(tzinfo=UTC) + return dt + + +def _iso(dt: Any) -> str | None: + aware = _as_aware(dt) + return aware.isoformat() if aware else None + + +def hash_token(raw_token: str) -> str: + """Return the stable lookup hash for a raw token.""" + return hashlib.sha256((raw_token or "").encode("utf-8")).hexdigest() + + +class MCPTokenStore: + """CRUD-lite store over the ``mcp_tokens`` collection.""" + + def __init__(self, db: Any, *, collection_name: str = COLLECTION_NAME) -> None: + # ``db`` is a pymongo Database handle (or a compatible fake). + self._coll = db[collection_name] + self._ensure_indexes() + + def _ensure_indexes(self) -> None: + # Best-effort; token operations must never fail because indexing did. + try: + self._coll.create_index("token_hash", unique=True) + self._coll.create_index("user_id") + except Exception: + pass + + # -- write ------------------------------------------------------------- + def issue( + self, + user_id: int, + *, + label: str | None = None, + scopes: Iterable[str] = DEFAULT_SCOPES, + ttl_days: int | None = None, + ) -> str: + """Create a new token and return the RAW value (shown once).""" + raw = TOKEN_PREFIX + secrets.token_urlsafe(_RAW_ENTROPY_BYTES) + expires_at = _now() + timedelta(days=ttl_days) if ttl_days else None + doc = { + "token_hash": hash_token(raw), + # first few chars only, for display in a "connections" list + "token_prefix": raw[: len(TOKEN_PREFIX) + 6], + "user_id": int(user_id), + "scopes": list(scopes) or list(DEFAULT_SCOPES), + "label": (label or "").strip() or "Claude", + "created_at": _now(), + "last_used_at": None, + "expires_at": expires_at, + "revoked": False, + } + self._coll.insert_one(doc) + return raw + + def revoke(self, user_id: int, token_id: Any) -> bool: + """Revoke a token owned by ``user_id`` (by its string id).""" + query_id: Any = token_id + try: # ids are ObjectId in real Mongo, plain values in fakes + from bson import ObjectId + + query_id = ObjectId(str(token_id)) + except Exception: + query_id = token_id + res = self._coll.update_one( + {"_id": query_id, "user_id": int(user_id)}, + {"$set": {"revoked": True, "revoked_at": _now()}}, + ) + return bool(getattr(res, "modified_count", 0)) + + # -- read -------------------------------------------------------------- + def verify(self, raw_token: str) -> dict[str, Any] | None: + """Return ``{"user_id", "scopes"}`` for a valid token, else ``None``. + + A token is valid iff it exists, is not revoked, and is not expired. + """ + if not raw_token: + return None + doc = self._coll.find_one({"token_hash": hash_token(raw_token), "revoked": {"$ne": True}}) + if not doc: + return None + expires_at = _as_aware(doc.get("expires_at")) + if expires_at is not None and expires_at < _now(): + return None + try: # best-effort "last used" touch; never block auth on it + self._coll.update_one({"_id": doc["_id"]}, {"$set": {"last_used_at": _now()}}) + except Exception: + pass + return { + "user_id": int(doc["user_id"]), + "scopes": list(doc.get("scopes") or DEFAULT_SCOPES), + } + + def list_for_user(self, user_id: int) -> list[dict[str, Any]]: + """List a user's tokens (metadata only — never the hash or raw value).""" + out: list[dict[str, Any]] = [] + for d in self._coll.find({"user_id": int(user_id)}): + out.append( + { + "id": str(d.get("_id")), + "token_prefix": d.get("token_prefix"), + "label": d.get("label"), + "scopes": list(d.get("scopes") or []), + "created_at": _iso(d.get("created_at")), + "last_used_at": _iso(d.get("last_used_at")), + "expires_at": _iso(d.get("expires_at")), + "revoked": bool(d.get("revoked", False)), + } + ) + return out diff --git a/requirements/base.txt b/requirements/base.txt index 0d0f743d2..d189bab8d 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -77,6 +77,10 @@ uvicorn==0.38.0 whitenoise==6.11.0 Flask-Compress==1.20 +# MCP server (Model Context Protocol) — exposes user files to Claude via mcp_server/ +# Compatible with the existing pins above (uvicorn>=0.31, pydantic>=2.11, httpx 0.28.x, starlette). +mcp==1.28.1 + # Rate limiting (Flask + generic) Flask-Limiter==4.0.0 limits==5.6.0 diff --git a/scripts/mcp_issue_token.py b/scripts/mcp_issue_token.py new file mode 100644 index 000000000..742137a18 --- /dev/null +++ b/scripts/mcp_issue_token.py @@ -0,0 +1,75 @@ +#!/usr/bin/env python3 +"""Issue a Personal Access Token (PAT) for the CodeKeeper MCP server. + +This is ops/testing tooling — it lets you mint a token for a given Telegram +user id without going through the bot. The raw token is printed ONCE; store it +in your MCP client's ``Authorization: Bearer `` header. + +Usage: + MONGODB_URL=... python scripts/mcp_issue_token.py --user-id 12345 \ + [--label "Claude Desktop"] [--ttl-days 90] +""" + +from __future__ import annotations + +import argparse +import os +import sys + + +def _resolve_mongo(db_manager): + mongo = getattr(db_manager, "db", None) + if mongo is not None: + return mongo + for attr in ("connect", "_get_repo", "ensure_connection"): + fn = getattr(db_manager, attr, None) + if callable(fn): + try: + fn() + except Exception: + pass + mongo = getattr(db_manager, "db", None) + if mongo is not None: + return mongo + return None + + +def main() -> int: + parser = argparse.ArgumentParser(description="Issue a CodeKeeper MCP access token") + parser.add_argument( + "--user-id", type=int, required=True, help="Telegram user id (owner of the files)" + ) + parser.add_argument("--label", default="Claude", help="Human label for this token") + parser.add_argument( + "--ttl-days", type=int, default=None, help="Optional expiry in days (default: no expiry)" + ) + args = parser.parse_args() + + if not os.getenv("MONGODB_URL"): + print("ERROR: MONGODB_URL is not set.", file=sys.stderr) + return 2 + + # Make the repo importable when run directly. + sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + + from database import db as db_manager # noqa: E402 + from mcp_server.token_store import MCPTokenStore # noqa: E402 + + mongo = _resolve_mongo(db_manager) + if mongo is None: + print("ERROR: could not connect to MongoDB.", file=sys.stderr) + return 3 + + store = MCPTokenStore(mongo) + raw = store.issue(args.user_id, label=args.label, ttl_days=args.ttl_days) + print(raw) + print( + f"\nIssued for user_id={args.user_id} (label={args.label!r}). " + "Store it now — it will not be shown again.", + file=sys.stderr, + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_mcp_auth_middleware.py b/tests/test_mcp_auth_middleware.py new file mode 100644 index 000000000..ff4ed95f0 --- /dev/null +++ b/tests/test_mcp_auth_middleware.py @@ -0,0 +1,68 @@ +"""Unit tests for the PAT auth middleware (Starlette-level, no server/port).""" + +import pytest + +pytest.importorskip("starlette") + +from starlette.requests import Request # noqa: E402 +from starlette.responses import PlainTextResponse # noqa: E402 + +from mcp_server.auth import PATAuthMiddleware # noqa: E402 + + +class _FakeStore: + def __init__(self, mapping): + self.mapping = mapping + + def verify(self, token): + return self.mapping.get(token) + + +def _request(path="/mcp", headers=None): + raw_headers = [(k.lower().encode(), v.encode()) for k, v in (headers or [])] + scope = { + "type": "http", + "method": "POST", + "path": path, + "headers": raw_headers, + "query_string": b"", + "server": ("test", 80), + "scheme": "http", + } + return Request(scope) + + +async def _call_next(request): + return PlainTextResponse(f"ok:{getattr(request.state, 'user_id', None)}") + + +def _mw(store): + return PATAuthMiddleware(app=None, token_store=store) + + +async def test_missing_bearer_returns_401(): + resp = await _mw(_FakeStore({})).dispatch(_request(headers=[]), _call_next) + assert resp.status_code == 401 + assert resp.headers.get("WWW-Authenticate", "").startswith("Bearer") + + +async def test_invalid_token_returns_401(): + store = _FakeStore({"good": {"user_id": 5, "scopes": ["read"]}}) + resp = await _mw(store).dispatch( + _request(headers=[("authorization", "Bearer bad")]), _call_next + ) + assert resp.status_code == 401 + + +async def test_valid_token_injects_user_id(): + store = _FakeStore({"good": {"user_id": 5, "scopes": ["read"]}}) + resp = await _mw(store).dispatch( + _request(headers=[("authorization", "Bearer good")]), _call_next + ) + assert resp.status_code == 200 + assert resp.body == b"ok:5" + + +async def test_healthz_is_exempt(): + resp = await _mw(_FakeStore({})).dispatch(_request(path="/healthz", headers=[]), _call_next) + assert resp.status_code == 200 diff --git a/tests/test_mcp_backend.py b/tests/test_mcp_backend.py new file mode 100644 index 000000000..b8b2c430c --- /dev/null +++ b/tests/test_mcp_backend.py @@ -0,0 +1,119 @@ +"""Unit tests for ProductionBackend: serialization + the critical ownership check.""" + +from mcp_server.backend import ProductionBackend, _full + + +class _FakeDbManager: + def __init__(self, files=None, by_id=None, versions=None, search=None): + self._files = files or [] + self._by_id = by_id or {} + self._versions = versions or [] + self._search = search or [] + + def get_regular_files_paginated(self, user_id, page, per_page): + return list(self._files), len(self._files) + + def search_code(self, user_id, query, programming_language=None, limit=20): + return list(self._search) + + def get_file_by_id(self, file_id): + return self._by_id.get(file_id) + + def get_latest_version(self, user_id, file_name): + return next((f for f in self._files if f.get("file_name") == file_name), None) + + def get_version(self, user_id, file_name, version): + return next( + ( + v + for v in self._versions + if v.get("file_name") == file_name and v.get("version") == version + ), + None, + ) + + def get_all_versions(self, user_id, file_name): + return [v for v in self._versions if v.get("file_name") == file_name] + + +def test_list_files_excludes_heavy_code_field(): + dbm = _FakeDbManager( + files=[ + { + "_id": "a", + "file_name": "x.py", + "code": "secret", + "programming_language": "python", + "file_size": 6, + } + ] + ) + out = ProductionBackend(db_manager=dbm).list_files(7, page=1, per_page=50) + assert out["total"] == 1 + f = out["files"][0] + assert "code" not in f + assert f["id"] == "a" + assert f["file_name"] == "x.py" + assert f["language"] == "python" + + +def test_search_excludes_code(): + dbm = _FakeDbManager( + search=[{"_id": "s1", "file_name": "y.py", "programming_language": "python"}] + ) + rows = ProductionBackend(db_manager=dbm).search_code(7, query="y") + assert rows and "code" not in rows[0] + assert rows[0]["id"] == "s1" + + +def test_get_file_by_id_enforces_ownership(): + dbm = _FakeDbManager( + by_id={"f1": {"_id": "f1", "user_id": 999, "file_name": "o.py", "code": "x"}} + ) + be = ProductionBackend(db_manager=dbm) + # Requesting user is not the owner -> denied. + assert be.get_file(7, file_id="f1") is None + # Owner gets full content. + got = be.get_file(999, file_id="f1") + assert got is not None and got["code"] == "x" + + +def test_get_file_by_name_returns_full_content(): + dbm = _FakeDbManager( + files=[{"_id": "a", "file_name": "x.py", "code": "print(1)", "user_id": 7}] + ) + doc = ProductionBackend(db_manager=dbm).get_file(7, file_name="x.py") + assert doc["code"] == "print(1)" + + +def test_get_specific_version(): + dbm = _FakeDbManager( + versions=[{"_id": "v2", "file_name": "x.py", "version": 2, "code": "v2code"}] + ) + doc = ProductionBackend(db_manager=dbm).get_file(7, file_name="x.py", version=2) + assert doc["version"] == 2 and doc["code"] == "v2code" + + +def test_large_file_content_mapped_to_code(): + assert _full({"_id": "a", "content": "blob"})["code"] == "blob" + + +def test_collections_delegate_to_manager(): + class _FakeCM: + def list_collections(self, user_id, limit=100): + return {"seen": (user_id, limit)} + + def get_collection(self, user_id, collection_id): + return {"seen": (user_id, collection_id)} + + def get_collection_items( + self, user_id, collection_id, page=1, per_page=20, folder_filter=None + ): + return {"seen": (user_id, collection_id, page, per_page, folder_filter)} + + be = ProductionBackend(collections_manager=_FakeCM()) + assert be.list_collections(3, limit=50)["seen"] == (3, 50) + assert be.get_collection(3, collection_id="c1")["seen"] == (3, "c1") + assert be.get_collection_items(3, collection_id="c1", page=2, per_page=10, folder="f")[ + "seen" + ] == (3, "c1", 2, 10, "f") diff --git a/tests/test_mcp_handlers.py b/tests/test_mcp_handlers.py new file mode 100644 index 000000000..1119b0689 --- /dev/null +++ b/tests/test_mcp_handlers.py @@ -0,0 +1,79 @@ +"""Unit tests for the pure tool handlers (input validation + clamping).""" + +from mcp_server import handlers + + +class _RecordingBackend: + def __init__(self): + self.calls = [] + + def list_files(self, user_id, *, page, per_page): + self.calls.append(("list_files", user_id, page, per_page)) + return {"files": [], "total": 0, "page": page, "per_page": per_page} + + def search_code(self, user_id, *, query, language, limit): + self.calls.append(("search", user_id, query, language, limit)) + return [] + + def get_file(self, user_id, *, file_name, file_id, version): + self.calls.append(("get_file", user_id, file_name, file_id, version)) + return None + + def list_versions(self, user_id, *, file_name): + self.calls.append(("versions", user_id, file_name)) + return [] + + def list_collections(self, user_id, *, limit): + self.calls.append(("list_coll", user_id, limit)) + return {} + + def get_collection(self, user_id, *, collection_id): + self.calls.append(("get_coll", user_id, collection_id)) + return {} + + def get_collection_items(self, user_id, *, collection_id, page, per_page, folder): + self.calls.append(("items", user_id, collection_id, page, per_page, folder)) + return {} + + +def test_list_files_clamps_page_and_per_page(): + be = _RecordingBackend() + handlers.list_files(be, 1, page=0, per_page=99999) + assert be.calls[0] == ("list_files", 1, 1, 200) # page floored, per_page capped + + +def test_search_empty_query_short_circuits(): + be = _RecordingBackend() + assert handlers.search_code(be, 1, query=" ") == [] + assert be.calls == [] # backend not touched + + +def test_search_limit_capped(): + be = _RecordingBackend() + handlers.search_code(be, 1, query="x", limit=10_000) + assert be.calls[0] == ("search", 1, "x", None, 100) + + +def test_get_file_requires_an_identifier(): + be = _RecordingBackend() + assert handlers.get_file(be, 1) is None + assert be.calls == [] + + +def test_list_versions_requires_name(): + be = _RecordingBackend() + assert handlers.list_versions(be, 1, file_name="") == [] + assert be.calls == [] + + +def test_get_collection_items_missing_id_errors_without_call(): + be = _RecordingBackend() + out = handlers.get_collection_items(be, 1, collection_id="") + assert out["ok"] is False + assert be.calls == [] + + +def test_collections_limit_capped(): + be = _RecordingBackend() + handlers.list_collections(be, 1, limit=10_000) + assert be.calls[0] == ("list_coll", 1, 500) diff --git a/tests/test_mcp_server_build.py b/tests/test_mcp_server_build.py new file mode 100644 index 000000000..ef7c56bd3 --- /dev/null +++ b/tests/test_mcp_server_build.py @@ -0,0 +1,59 @@ +"""Smoke tests for the FastMCP wiring (tools registered, health route present).""" + +import pytest + +pytest.importorskip("mcp") +pytest.importorskip("starlette") + +from mcp_server.server import build_app, build_mcp # noqa: E402 + +_EXPECTED_TOOLS = { + "list_files", + "search_code", + "get_file", + "list_versions", + "list_collections", + "get_collection", + "get_collection_items", +} + + +class _FakeBackend: + def list_files(self, *a, **k): + return {} + + def search_code(self, *a, **k): + return [] + + def get_file(self, *a, **k): + return None + + def list_versions(self, *a, **k): + return [] + + def list_collections(self, *a, **k): + return {} + + def get_collection(self, *a, **k): + return {} + + def get_collection_items(self, *a, **k): + return {} + + +class _FakeStore: + def verify(self, token): + return None + + +async def test_all_tools_are_registered(): + mcp = build_mcp(_FakeBackend()) + tools = await mcp.list_tools() + names = {t.name for t in tools} + assert _EXPECTED_TOOLS <= names + + +def test_build_app_exposes_healthz_route(): + app = build_app(_FakeBackend(), _FakeStore()) + paths = {getattr(r, "path", None) for r in app.routes} + assert "/healthz" in paths diff --git a/tests/test_mcp_token_store.py b/tests/test_mcp_token_store.py new file mode 100644 index 000000000..30f84f7b6 --- /dev/null +++ b/tests/test_mcp_token_store.py @@ -0,0 +1,126 @@ +"""Unit tests for the MCP Personal Access Token store. + +Uses a tiny hand-rolled fake Mongo collection (the repo convention — no +mongomock dependency). +""" + +from datetime import UTC, datetime, timedelta + +from mcp_server.token_store import TOKEN_PREFIX, MCPTokenStore, hash_token + + +class _Result: + def __init__(self, modified=0): + self.modified_count = modified + + +class _FakeCollection: + def __init__(self): + self.docs = [] + self._seq = 0 + + def create_index(self, *a, **k): + return "idx" + + def insert_one(self, doc): + self._seq += 1 + doc = dict(doc) + doc.setdefault("_id", self._seq) + self.docs.append(doc) + return _Result() + + @staticmethod + def _match(doc, query): + for key, cond in query.items(): + if isinstance(cond, dict) and "$ne" in cond: + if doc.get(key) == cond["$ne"]: + return False + elif doc.get(key) != cond: + return False + return True + + def find_one(self, query): + for d in self.docs: + if self._match(d, query): + return d + return None + + def find(self, query): + return [d for d in self.docs if self._match(d, query)] + + def update_one(self, query, update): + for d in self.docs: + if self._match(d, query): + d.update(update.get("$set", {})) + return _Result(1) + return _Result(0) + + +class _FakeDB: + def __init__(self): + self._collections = {} + + def __getitem__(self, name): + return self._collections.setdefault(name, _FakeCollection()) + + +def _store(): + return MCPTokenStore(_FakeDB()) + + +def test_issue_returns_prefixed_raw_and_stores_hash_only(): + store = _store() + raw = store.issue(111, label="Claude") + assert raw.startswith(TOKEN_PREFIX) + doc = store._coll.docs[0] + assert doc["token_hash"] == hash_token(raw) + assert "token" not in doc # raw value is never persisted + assert doc["user_id"] == 111 + assert doc["scopes"] == ["read"] + + +def test_verify_roundtrip(): + store = _store() + raw = store.issue(222) + assert store.verify(raw) == {"user_id": 222, "scopes": ["read"]} + + +def test_verify_wrong_or_empty_token_returns_none(): + store = _store() + store.issue(1) + assert store.verify("ckmcp_nope") is None + assert store.verify("") is None + + +def test_revoked_token_is_denied(): + store = _store() + raw = store.issue(5) + token_id = store._coll.docs[0]["_id"] + assert store.revoke(5, token_id) is True + assert store.verify(raw) is None + + +def test_expired_token_is_denied(): + store = _store() + raw = store.issue(7, ttl_days=1) + store._coll.docs[0]["expires_at"] = datetime.now(UTC) - timedelta(days=2) + assert store.verify(raw) is None + + +def test_verify_updates_last_used(): + store = _store() + raw = store.issue(8) + assert store._coll.docs[0]["last_used_at"] is None + store.verify(raw) + assert store._coll.docs[0]["last_used_at"] is not None + + +def test_list_for_user_hides_secrets(): + store = _store() + store.issue(9, label="A") + rows = store.list_for_user(9) + assert len(rows) == 1 + assert "token_hash" not in rows[0] and "token" not in rows[0] + assert rows[0]["label"] == "A" + assert rows[0]["token_prefix"].startswith(TOKEN_PREFIX) + assert store.list_for_user(999) == [] From 1a3b6654f4bf8434628eb98483006e03ef2239da Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Jul 2026 13:03:18 +0000 Subject: [PATCH 3/8] =?UTF-8?q?refactor(mcp):=20address=20code=20review=20?= =?UTF-8?q?=E2=80=94=20defensive=20scrub,=20dedup,=20logging,=20docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit תיקוני סקירה (כל ההערות היו תקפות): - backend.get_collection_items: הגנת עומק — מסנן שדות כבדים (code/content) מפריטי אוסף לפני החזרה, כדי שלא ידלפו גם אם CollectionsManager ישתנה. - mcp_server/wiring.py: חילוץ resolve_mongo המשותף; הוסרו ההעתקים מ-app.py ומ-scripts/mcp_issue_token.py. - auth.py: לוג לחריגה שנבלעה ב-verify (בלי הטוקן) לצורך אבחון בפרודקשן. - Markdown: שפת code fence (text) לדיאגרמות ולבלוק ההרצה, ושורות ריקות לפני טבלאות (MD040/MD058) ב-README ובמסמך התכנון. - טסט חדש: מוודא שפריטי אוסף מסוננים משדות כבדים. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01WNFuSyshwpRcxozVZEui5K --- .../FEATURE_MCP_CLAUDE_INTEGRATION.md | 7 ++--- mcp_server/README.md | 4 ++- mcp_server/app.py | 23 ++------------- mcp_server/auth.py | 6 ++++ mcp_server/backend.py | 16 ++++++++++- mcp_server/wiring.py | 28 +++++++++++++++++++ scripts/mcp_issue_token.py | 20 ++----------- tests/test_mcp_backend.py | 17 +++++++++++ 8 files changed, 75 insertions(+), 46 deletions(-) create mode 100644 mcp_server/wiring.py diff --git a/FEATURE_SUGGESTIONS/FEATURE_MCP_CLAUDE_INTEGRATION.md b/FEATURE_SUGGESTIONS/FEATURE_MCP_CLAUDE_INTEGRATION.md index ff0f1f36e..b8a355baf 100644 --- a/FEATURE_SUGGESTIONS/FEATURE_MCP_CLAUDE_INTEGRATION.md +++ b/FEATURE_SUGGESTIONS/FEATURE_MCP_CLAUDE_INTEGRATION.md @@ -11,10 +11,6 @@ > מאגר הטוקנים נמצא ב‑`mcp_server/token_store.py` (ולא `database/mcp_tokens.py`), כדי > שהמודולים יהיו נטולי תלויות כבדות וניתנים לבדיקה בבידוד. פקודת הבוט `/connect_claude` > ו‑OAuth (Claude.ai) יגיעו בפאזות הבאות. -> **סטטוס:** תכנון (Draft) — לפני כתיבת קוד. ממתין לאישור כיוון. -> **ענף פיתוח:** `claude/mcp-codekeeper-webapp-ldnzsg` -> **מתי להשתמש:** לפני מימוש חיבור MCP; מסמך זה הוא מקור האמת לתכנון. -> **ראו גם:** [CodeBot – Project Docs](https://amirbiron.github.io/CodeBot/), `CLAUDE.md` (מדיניות מחייבת). --- @@ -99,7 +95,7 @@ db.delete_file(user_id, file_name) # מחיקה רכה (recycle ## 4. ארכיטקטורה מוצעת -``` +```text ┌────────────┐ OAuth 2.1 (PKCE) ┌──────────────────────────┐ │ Claude.ai │ ───────────────────► │ שרת MCP חדש (שירות נפרד) │ │ (Connector)│ Streamable HTTP │ Python + ASGI (uvicorn) │ @@ -128,6 +124,7 @@ db.delete_file(user_id, file_name) # מחיקה רכה (recycle ## 5. הכלים והמשאבים (MCP surface) ### 5.1 Tools + | כלי | קלט | פלט | נשען על | |-----|-----|-----|---------| | `list_files` | `limit`, `page`, `language?`, `tag?` | רשימת מטא‑דאטה (בלי `code`) | `get_user_files` | diff --git a/mcp_server/README.md b/mcp_server/README.md index 35f80ac11..fa117af47 100644 --- a/mcp_server/README.md +++ b/mcp_server/README.md @@ -15,6 +15,7 @@ OAuth בפאזה הבאה). קריאה בלבד — אין כלי כתיבה/מ לא מחזירים את שדה ה‑`code` הכבד — תוכן מלא רק ב‑`get_file`. ### הכלים (Tools) + | כלי | תיאור | |-----|-------| | `list_files` | רשימת קבצים (מטא‑דאטה בלבד), עם עימוד | @@ -78,7 +79,7 @@ claude mcp add --transport http codekeeper http://localhost:8000/mcp \ ## פריסה (Render) שירות web נפרד (ASGI), משתף את אותו `MONGODB_URL` ואת אותם סודות: -``` +```text Start command: uvicorn mcp_server.app:app --host 0.0.0.0 --port $PORT Health check: /healthz ``` @@ -87,6 +88,7 @@ Health check: /healthz --- ## מבנה הקוד + | קובץ | תפקיד | |------|-------| | `token_store.py` | ניהול PAT (הנפקה/אימות/ביטול) מעל `mcp_tokens` | diff --git a/mcp_server/app.py b/mcp_server/app.py index 522d3156b..641e6f59c 100644 --- a/mcp_server/app.py +++ b/mcp_server/app.py @@ -12,36 +12,17 @@ from __future__ import annotations import os -from typing import Any from .backend import ProductionBackend from .server import build_app from .token_store import MCPTokenStore - - -def _resolve_mongo(db_manager: Any) -> Any: - """Return the pymongo Database handle, forcing a connection if needed.""" - mongo = getattr(db_manager, "db", None) - if mongo is not None: - return mongo - # Some managers connect lazily; nudge them, then re-read. - for attr in ("connect", "_get_repo", "ensure_connection"): - fn = getattr(db_manager, attr, None) - if callable(fn): - try: - fn() - except Exception: - pass - mongo = getattr(db_manager, "db", None) - if mongo is not None: - return mongo - return None +from .wiring import resolve_mongo def create_app(): from database import db as db_manager # lazy heavy import - mongo = _resolve_mongo(db_manager) + mongo = resolve_mongo(db_manager) if mongo is None: raise RuntimeError( "MongoDB is not available (database.db is None). " diff --git a/mcp_server/auth.py b/mcp_server/auth.py index 7a971cebf..9cedb01c5 100644 --- a/mcp_server/auth.py +++ b/mcp_server/auth.py @@ -10,6 +10,7 @@ from __future__ import annotations +import logging from collections.abc import Iterable from typing import Any @@ -18,6 +19,8 @@ from starlette.requests import Request from starlette.responses import JSONResponse +logger = logging.getLogger(__name__) + # Paths that must remain reachable without a token (health checks, etc.). EXEMPT_PATHS = {"/healthz", "/health", "/"} @@ -55,6 +58,9 @@ async def dispatch(self, request: Request, call_next): # verify() is a sync DB call — keep it off the event loop. principal = await anyio.to_thread.run_sync(self._store.verify, token) except Exception: + # Log the failure (never the token) so DB/connectivity issues are + # diagnosable in production, then fail closed. + logger.warning("MCP token verification raised an error", exc_info=True) principal = None if not principal: diff --git a/mcp_server/backend.py b/mcp_server/backend.py index 24d59a0c5..06dc52ff0 100644 --- a/mcp_server/backend.py +++ b/mcp_server/backend.py @@ -61,6 +61,15 @@ def _full(doc: dict[str, Any]) -> dict[str, Any]: return out +def _strip_heavy(value: Any) -> Any: + """Recursively drop heavy content fields from an already-serialized value.""" + if isinstance(value, dict): + return {k: _strip_heavy(v) for k, v in value.items() if k not in _HEAVY_FIELDS} + if isinstance(value, list): + return [_strip_heavy(v) for v in value] + return value + + class ProductionBackend: """Backend backed by the real in-process ``database`` layer. @@ -154,6 +163,11 @@ def get_collection_items( per_page: int = 50, folder: str | None = None, ) -> dict[str, Any]: - return self._collections().get_collection_items( + result = self._collections().get_collection_items( user_id, collection_id, page=page, per_page=per_page, folder_filter=folder ) + # Defense-in-depth: collection items are file *references* (no code today), + # but never let a heavy content field slip through if the manager changes. + if isinstance(result, dict) and isinstance(result.get("items"), list): + result["items"] = [_strip_heavy(item) for item in result["items"]] + return result diff --git a/mcp_server/wiring.py b/mcp_server/wiring.py new file mode 100644 index 000000000..89c7fc7a7 --- /dev/null +++ b/mcp_server/wiring.py @@ -0,0 +1,28 @@ +"""Small wiring helpers shared between the ASGI entrypoint and CLI scripts. + +Kept free of heavy/side-effecting imports so both ``mcp_server.app`` and +``scripts/mcp_issue_token.py`` can reuse it without triggering a Mongo connect. +""" + +from __future__ import annotations + +from typing import Any + + +def resolve_mongo(db_manager: Any) -> Any: + """Return the pymongo Database handle, forcing a lazy connection if needed.""" + mongo = getattr(db_manager, "db", None) + if mongo is not None: + return mongo + # Some managers connect lazily; nudge them, then re-read. + for attr in ("connect", "_get_repo", "ensure_connection"): + fn = getattr(db_manager, attr, None) + if callable(fn): + try: + fn() + except Exception: + pass + mongo = getattr(db_manager, "db", None) + if mongo is not None: + return mongo + return None diff --git a/scripts/mcp_issue_token.py b/scripts/mcp_issue_token.py index 742137a18..0bd5c3114 100644 --- a/scripts/mcp_issue_token.py +++ b/scripts/mcp_issue_token.py @@ -17,23 +17,6 @@ import sys -def _resolve_mongo(db_manager): - mongo = getattr(db_manager, "db", None) - if mongo is not None: - return mongo - for attr in ("connect", "_get_repo", "ensure_connection"): - fn = getattr(db_manager, attr, None) - if callable(fn): - try: - fn() - except Exception: - pass - mongo = getattr(db_manager, "db", None) - if mongo is not None: - return mongo - return None - - def main() -> int: parser = argparse.ArgumentParser(description="Issue a CodeKeeper MCP access token") parser.add_argument( @@ -54,8 +37,9 @@ def main() -> int: from database import db as db_manager # noqa: E402 from mcp_server.token_store import MCPTokenStore # noqa: E402 + from mcp_server.wiring import resolve_mongo # noqa: E402 - mongo = _resolve_mongo(db_manager) + mongo = resolve_mongo(db_manager) if mongo is None: print("ERROR: could not connect to MongoDB.", file=sys.stderr) return 3 diff --git a/tests/test_mcp_backend.py b/tests/test_mcp_backend.py index b8b2c430c..a2f87350e 100644 --- a/tests/test_mcp_backend.py +++ b/tests/test_mcp_backend.py @@ -117,3 +117,20 @@ def get_collection_items( assert be.get_collection_items(3, collection_id="c1", page=2, per_page=10, folder="f")[ "seen" ] == (3, "c1", 2, 10, "f") + + +def test_collection_items_strip_heavy_fields(): + class _LeakyCM: + def get_collection_items( + self, user_id, collection_id, page=1, per_page=20, folder_filter=None + ): + return { + "ok": True, + "items": [{"id": "1", "file_name": "a.py", "code": "LEAK", "content": "LEAK2"}], + } + + be = ProductionBackend(collections_manager=_LeakyCM()) + out = be.get_collection_items(3, collection_id="c1") + item = out["items"][0] + assert "code" not in item and "content" not in item + assert item["file_name"] == "a.py" From bfc10ae57da81091f9eb90cd90defe99d717d2e1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Jul 2026 13:14:18 +0000 Subject: [PATCH 4/8] feat(mcp): add /connect_claude bot command to issue MCP tokens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit מוסיף פקודת בוט /connect_claude שמנפיקה למשתמש טוקן MCP אישי מתוך טלגרם (במקום להריץ סקריפט CLI), ומחזירה פקודת חיבור מוכנה ל-Claude Code. - main.py: connect_claude_command (מודול-לבל), רישום ב-setup_handlers, וכניסה ב-HELP_SECTIONS. אבטחה: מונפק בצ'אט פרטי בלבד (שלא ידלוף בקבוצה); fallback לטקסט אם פרסום HTML נכשל (לא לאבד את הטוקן). - files_facade.issue_mcp_token: עוטף את MCPTokenStore (import עצל) כדי לשמור את החיווט במקום אחד ולא לגעת ב-PyMongo גולמי מה-handler. - README + מסמך התכנון עודכנו: /connect_claude זמין, + ENV MCP_SERVER_URL. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01WNFuSyshwpRcxozVZEui5K --- .../FEATURE_MCP_CLAUDE_INTEGRATION.md | 2 +- main.py | 73 +++++++++++++++++++ mcp_server/README.md | 14 +++- .../composition/files_facade.py | 22 ++++++ 4 files changed, 106 insertions(+), 5 deletions(-) diff --git a/FEATURE_SUGGESTIONS/FEATURE_MCP_CLAUDE_INTEGRATION.md b/FEATURE_SUGGESTIONS/FEATURE_MCP_CLAUDE_INTEGRATION.md index b8a355baf..9817635c2 100644 --- a/FEATURE_SUGGESTIONS/FEATURE_MCP_CLAUDE_INTEGRATION.md +++ b/FEATURE_SUGGESTIONS/FEATURE_MCP_CLAUDE_INTEGRATION.md @@ -10,7 +10,7 @@ > `scripts/mcp_issue_token.py`, וטסטים `tests/test_mcp_*.py`. **הערת סטייה מהמסמך:** > מאגר הטוקנים נמצא ב‑`mcp_server/token_store.py` (ולא `database/mcp_tokens.py`), כדי > שהמודולים יהיו נטולי תלויות כבדות וניתנים לבדיקה בבידוד. פקודת הבוט `/connect_claude` -> ו‑OAuth (Claude.ai) יגיעו בפאזות הבאות. +> כבר זמינה (הנפקת טוקן מתוך טלגרם); OAuth (Claude.ai) וכלי כתיבה יגיעו בפאזות הבאות. --- diff --git a/main.py b/main.py index b8fde56f2..6e7395a22 100644 --- a/main.py +++ b/main.py @@ -777,6 +777,72 @@ async def _send_direct_admins(context: ContextTypes.DEFAULT_TYPE, text: str) -> return False +async def connect_claude_command(update: Update, context: ContextTypes.DEFAULT_TYPE): + """מנפיק טוקן אישי (PAT) לחיבור הקבצים של המשתמש ל‑Claude דרך MCP (קריאה בלבד).""" + try: + message = update.message or update.effective_message + if message is None: + return + + # אבטחה: הטוקן סודי — מנפיקים רק בצ'אט פרטי כדי שלא ידלוף בקבוצה. + chat = update.effective_chat + if getattr(chat, "type", "private") != "private": + await message.reply_text("🔒 הפקודה זמינה בצ'אט פרטי בלבד (הטוקן סודי).") + return + + user = update.effective_user + user_id = getattr(user, "id", None) + if not user_id: + await message.reply_text("לא זוהה משתמש.") + return + + raw = None + try: + from src.infrastructure.composition.webapp_container import get_files_facade + + raw = get_files_facade().issue_mcp_token(int(user_id), label="Claude") + except Exception: + logger.error("connect_claude: token issue failed", exc_info=True) + raw = None + + if not raw: + await message.reply_text("אירעה שגיאה ביצירת הטוקן. נסו שוב מאוחר יותר.") + return + + base = (os.getenv("MCP_SERVER_URL") or "https://YOUR-MCP-HOST").rstrip("/") + add_cmd = ( + f'claude mcp add --transport http codekeeper {base}/mcp ' + f'--header "Authorization: Bearer {raw}"' + ) + text = ( + "🔌 חיבור הקבצים שלך ל‑Claude (MCP)\n\n" + "הטוקן האישי שלך (יוצג פעם אחת בלבד — שמור אותו):\n" + f"{raw}\n\n" + "לחיבור מ‑Claude Code (העתק‑הדבק):\n" + f"{add_cmd}\n\n" + "⚠️ אל תשתפו את הטוקן. כרגע החיבור עובד מול Claude Code / Desktop " + "(קריאה בלבד); תמיכה ב‑Claude.ai בוובאפ תגיע בהמשך." + ) + try: + await message.reply_text(text, parse_mode=ParseMode.HTML) + except Exception: + # נפילת פרסום HTML לא תגרום לאובדן הטוקן — שולחים גרסת טקסט. + plain = ( + text.replace("", "") + .replace("", "") + .replace("", "") + .replace("", "") + ) + await message.reply_text(plain) + except Exception: + logger.error("connect_claude_command failed", exc_info=True) + try: + if update and update.message: + await update.message.reply_text("אירעה שגיאה. נסו שוב מאוחר יותר.") + except Exception: + pass + + async def admin_report_command(update: Update, context: ContextTypes.DEFAULT_TYPE): """קבלת דיווח משתמש ושליחה לאדמינים.""" try: @@ -2109,6 +2175,12 @@ class HelpSection(TypedDict): HELP_SECTIONS: list[HelpSection] = [ + { + "title": "🔌 חיבור ל‑Claude (MCP)", + "entries": [ + {"commands": ("connect_claude",), "description": "חיבור הקבצים שלך ל‑Claude (טוקן MCP)"}, + ], + }, { "title": "🔔 תזכורות", "entries": [ @@ -3823,6 +3895,7 @@ async def _global_callback_guard(update: Update, context: ContextTypes.DEFAULT_T self.application.add_handler(CommandHandler("stats", self.stats_command)) self.application.add_handler(CommandHandler("check", self.check_commands)) self.application.add_handler(CommandHandler("admin", admin_report_command)) + self.application.add_handler(CommandHandler("connect_claude", connect_claude_command)) # ChatOps: /jobs (Background Jobs Monitor) async def jobs_command(update: Update, context: ContextTypes.DEFAULT_TYPE): diff --git a/mcp_server/README.md b/mcp_server/README.md index fa117af47..24c076dc2 100644 --- a/mcp_server/README.md +++ b/mcp_server/README.md @@ -34,12 +34,16 @@ OAuth בפאזה הבאה). קריאה בלבד — אין כלי כתיבה/מ `mcp_tokens`, קשור ל‑`user_id`, וניתן לביטול. ה‑`user_id` נגזר תמיד מהטוקן — לעולם לא מקלט הלקוח. -### הנפקת טוקן (לבדיקות/אופס) +### הנפקת טוקן + +**הדרך הפשוטה — מתוך הבוט:** שלחו `/connect_claude` בצ'אט פרטי עם הבוט. תקבלו טוקן +מוכן + פקודת חיבור מוכנה להעתקה. (הפקודה זמינה בצ'אט פרטי בלבד כדי שהטוקן לא ידלוף.) + +**לאופס/בדיקות (CLI):** ```bash MONGODB_URL="..." python scripts/mcp_issue_token.py --user-id --label "Claude Desktop" ``` -הפקודה מדפיסה את הטוקן **פעם אחת**. (בפאזה הבאה תתווסף פקודת בוט `/connect_claude` -שתעשה את זה מתוך טלגרם.) +הטוקן מוצג **פעם אחת בלבד** — שמרו אותו. --- @@ -83,7 +87,9 @@ claude mcp add --transport http codekeeper http://localhost:8000/mcp \ Start command: uvicorn mcp_server.app:app --host 0.0.0.0 --port $PORT Health check: /healthz ``` -מומלץ שירות נפרד (ולא בתוך הוובאפ) כי ה‑worker של הוובאפ יחיד ורגיש לזיכרון. +ENV: `MONGODB_URL` (משותף), ו‑`MCP_SERVER_URL` (ה‑URL הציבורי של השירות — משמש את +`/connect_claude` בבוט כדי לבנות את פקודת החיבור). מומלץ שירות נפרד (ולא בתוך הוובאפ) +כי ה‑worker של הוובאפ יחיד ורגיש לזיכרון. --- diff --git a/src/infrastructure/composition/files_facade.py b/src/infrastructure/composition/files_facade.py index 556937dd2..f10eadb64 100644 --- a/src/infrastructure/composition/files_facade.py +++ b/src/infrastructure/composition/files_facade.py @@ -639,6 +639,28 @@ def insert_webapp_login_token(self, token_doc: Dict[str, Any]) -> bool: logger.error("insert_webapp_login_token failed", exc_info=True) return False + def issue_mcp_token( + self, user_id: int, *, label: str = "Claude", ttl_days: Optional[int] = None + ) -> Optional[str]: + """ + Issue a long-lived MCP Personal Access Token bound to ``user_id`` and + return the RAW token (shown once). Returns None on failure. + + Keeps the MCP token-store wiring in one place so handlers don't reach + for raw PyMongo. See ``mcp_server/token_store.py``. + """ + try: + mongo_db = self.get_mongo_db() + if mongo_db is None: + return None + from mcp_server.token_store import MCPTokenStore # lazy import + + store = MCPTokenStore(mongo_db) + return store.issue(int(user_id), label=label, ttl_days=ttl_days) + except Exception: + logger.error("issue_mcp_token failed", exc_info=True) + return None + def list_active_user_ids(self) -> Optional[List[int]]: """ Return user ids eligible for admin broadcast (non-blocked users). From 6649d6d9d10e528ca6cf367fdc4884c7c5d04f21 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Jul 2026 13:42:36 +0000 Subject: [PATCH 5/8] =?UTF-8?q?docs(mcp):=20clarify=20deploy=20env=20?= =?UTF-8?q?=E2=80=94=20same=20MongoDB=20as=20bot/webapp,=20minimal=20secre?= =?UTF-8?q?ts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit מבהיר בדיוק אילו ENV שירות ה-MCP צריך: MONGODB_URL+DATABASE_NAME זהים לבוט/וובאפ (אותו DB, אחרת הטוקן מ-/connect_claude לא יימצא), BOT_TOKEN רק כדי שה-config המשותף ייטען, ולא צריך את סודות הוובאפ. MCP_SERVER_URL מוגדר על שירות הבוט. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01WNFuSyshwpRcxozVZEui5K --- mcp_server/README.md | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/mcp_server/README.md b/mcp_server/README.md index 24c076dc2..ef104fa0f 100644 --- a/mcp_server/README.md +++ b/mcp_server/README.md @@ -82,14 +82,20 @@ claude mcp add --transport http codekeeper http://localhost:8000/mcp \ --- ## פריסה (Render) -שירות web נפרד (ASGI), משתף את אותו `MONGODB_URL` ואת אותם סודות: +שירות web נפרד (ASGI). **חייב להתחבר לאותו MongoDB כמו הבוט/הוובאפ** — `/connect_claude` +בבוט כותב את הטוקן ל‑`mcp_tokens`, ושירות ה‑MCP קורא אותו משם; DB שונה = הטוקן לא יימצא. ```text Start command: uvicorn mcp_server.app:app --host 0.0.0.0 --port $PORT Health check: /healthz ``` -ENV: `MONGODB_URL` (משותף), ו‑`MCP_SERVER_URL` (ה‑URL הציבורי של השירות — משמש את -`/connect_claude` בבוט כדי לבנות את פקודת החיבור). מומלץ שירות נפרד (ולא בתוך הוובאפ) -כי ה‑worker של הוובאפ יחיד ורגיש לזיכרון. +**ENV שהשירות צריך (הכי נקי לשכפל מבלוק ה‑ENV של הבוט):** +- `MONGODB_URL` + `DATABASE_NAME` — **אותם ערכים בדיוק** כמו הבוט/הוובאפ (אותו DB, ברירת מחדל `code_keeper_bot`). +- `BOT_TOKEN` — נדרש רק כדי שמודול ה‑`config` המשותף ייטען (השירות עצמו לא מדבר עם טלגרם). +- `MCP_SERVER_NAME` — אופציונלי (שם תצוגה). + +**לא צריך** את סודות הוובאפ (`SECRET_KEY`, VAPID/Push, Google Drive וכו'). +**על שירות הבוט** מגדירים `MCP_SERVER_URL` = ה‑URL הציבורי של שירות ה‑MCP (עבור `/connect_claude`). +ב‑Render הכי נוח env group משותף. מומלץ שירות נפרד (לא בתוך הוובאפ) כי ה‑worker שלו יחיד ורגיש לזיכרון. --- From a0defc323fee506cfe714210d146ae112127c97a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Jul 2026 14:03:31 +0000 Subject: [PATCH 6/8] docs(env): document MCP_SERVER_URL and MCP_SERVER_NAME env vars MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit לפי תבנית ה-PR: תיעוד משתני הסביבה החדשים ששירות ה-MCP/הבוט משתמשים בהם. - docs/environment-variables.rst: שתי שורות בטבלת התפעול (URL ציבורי + שם תצוגה). - services/config_inspector_service.py: ConfigDefinition לשניהם תחת category="mcp", כדי שיופיעו ב-Config Inspector. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01WNFuSyshwpRcxozVZEui5K --- docs/environment-variables.rst | 12 ++++++++++++ services/config_inspector_service.py | 14 ++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/docs/environment-variables.rst b/docs/environment-variables.rst index c893caa24..a549b27a4 100644 --- a/docs/environment-variables.rst +++ b/docs/environment-variables.rst @@ -2096,6 +2096,18 @@ - ``""`` / ``code_keeper_bot`` - ``mongodb://localhost:27017`` / ``my_db`` - Scripts + * - ``MCP_SERVER_URL`` + - ה-URL הציבורי של שירות ה-MCP. משמש את פקודת הבוט ``/connect_claude`` כדי לבנות את פקודת החיבור ל-Claude. + - לא + - ``https://YOUR-MCP-HOST`` + - ``https://code-keeper-mcp.onrender.com`` + - Bot + * - ``MCP_SERVER_NAME`` + - שם התצוגה של שרת ה-MCP (מוצג ללקוח כשם ה-Connector). + - לא + - ``CodeKeeper`` + - ``CodeKeeper`` + - MCP דגלי בדיקות ופיתוח ------------------- diff --git a/services/config_inspector_service.py b/services/config_inspector_service.py index 2fc2c322f..017cec181 100644 --- a/services/config_inspector_service.py +++ b/services/config_inspector_service.py @@ -513,6 +513,20 @@ class ConfigService: category="webserver", ), + # --- MCP Server (Claude integration) --- + "MCP_SERVER_URL": ConfigDefinition( + key="MCP_SERVER_URL", + default="", + description="ה-URL הציבורי של שירות ה-MCP; משמש את /connect_claude בבוט לבניית פקודת החיבור.", + category="mcp", + ), + "MCP_SERVER_NAME": ConfigDefinition( + key="MCP_SERVER_NAME", + default="CodeKeeper", + description="שם התצוגה של שרת ה-MCP (שם ה-Connector שמוצג ללקוח).", + category="mcp", + ), + # --- Repo Sync Engine (Git Mirror) --- "REPO_NAME": ConfigDefinition( key="REPO_NAME", From e6de67e64624dd01ef66de2bd59557596458513b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Jul 2026 14:22:26 +0000 Subject: [PATCH 7/8] build: pin Python to 3.11 for Render native builds (.python-version) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Render defaulted the new native Python MCP service to 3.14, where greenlet (pulled in by gevent) fails to compile — its C++ uses CPython frame internals that changed in 3.14. The project targets 3.11 (Dockerfile python:3.11-slim; CI runs 3.11/3.12), so pin the native runtime to a supported version. - .python-version = 3.11 -> Render uses the latest 3.11 patch. - No effect on CI (explicit python-version in workflows) or Docker services; services that set PYTHON_VERSION explicitly still win over this file. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01WNFuSyshwpRcxozVZEui5K --- .python-version | 1 + 1 file changed, 1 insertion(+) create mode 100644 .python-version diff --git a/.python-version b/.python-version new file mode 100644 index 000000000..2c0733315 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.11 From 5743ec8456a7ef56f9ee21be599264e30dc3a1c7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Jul 2026 14:23:37 +0000 Subject: [PATCH 8/8] docs(mcp): de-duplicate README deploy section after squash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit מתקן כפילות שנוצרה במיזוג/squash: שורת פתיחה ישנה + פסקת ENV כפולה בסעיף הפריסה. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01WNFuSyshwpRcxozVZEui5K --- mcp_server/README.md | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/mcp_server/README.md b/mcp_server/README.md index bfbefa434..ef104fa0f 100644 --- a/mcp_server/README.md +++ b/mcp_server/README.md @@ -82,7 +82,8 @@ claude mcp add --transport http codekeeper http://localhost:8000/mcp \ --- ## פריסה (Render) -שירות web נפרד (ASGI), משתף את אותו `MONGODB_URL` ואת אותם סודות: +שירות web נפרד (ASGI). **חייב להתחבר לאותו MongoDB כמו הבוט/הוובאפ** — `/connect_claude` +בבוט כותב את הטוקן ל‑`mcp_tokens`, ושירות ה‑MCP קורא אותו משם; DB שונה = הטוקן לא יימצא. ```text Start command: uvicorn mcp_server.app:app --host 0.0.0.0 --port $PORT Health check: /healthz @@ -95,9 +96,6 @@ Health check: /healthz **לא צריך** את סודות הוובאפ (`SECRET_KEY`, VAPID/Push, Google Drive וכו'). **על שירות הבוט** מגדירים `MCP_SERVER_URL` = ה‑URL הציבורי של שירות ה‑MCP (עבור `/connect_claude`). ב‑Render הכי נוח env group משותף. מומלץ שירות נפרד (לא בתוך הוובאפ) כי ה‑worker שלו יחיד ורגיש לזיכרון. -ENV: `MONGODB_URL` (משותף), ו‑`MCP_SERVER_URL` (ה‑URL הציבורי של השירות — משמש את -`/connect_claude` בבוט כדי לבנות את פקודת החיבור). מומלץ שירות נפרד (ולא בתוך הוובאפ) -כי ה‑worker של הוובאפ יחיד ורגיש לזיכרון. ---