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
14 changes: 11 additions & 3 deletions FEATURE_SUGGESTIONS/FEATURE_MCP_CLAUDE_INTEGRATION.md
Original file line number Diff line number Diff line change
@@ -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) וכלי כתיבה יגיעו בפאזות הבאות.

---

Expand Down Expand Up @@ -88,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) │
Expand Down Expand Up @@ -117,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` |
Expand Down
73 changes: 73 additions & 0 deletions main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = (
"🔌 <b>חיבור הקבצים שלך ל‑Claude (MCP)</b>\n\n"
"הטוקן האישי שלך (יוצג פעם אחת בלבד — שמור אותו):\n"
f"<code>{raw}</code>\n\n"
"לחיבור מ‑Claude Code (העתק‑הדבק):\n"
f"<code>{add_cmd}</code>\n\n"
"⚠️ אל תשתפו את הטוקן. כרגע החיבור עובד מול Claude Code / Desktop "
"(קריאה בלבד); תמיכה ב‑Claude.ai בוובאפ תגיע בהמשך."
)
try:
await message.reply_text(text, parse_mode=ParseMode.HTML)
except Exception:
# נפילת פרסום HTML לא תגרום לאובדן הטוקן — שולחים גרסת טקסט.
plain = (
text.replace("<b>", "")
.replace("</b>", "")
.replace("<code>", "")
.replace("</code>", "")
)
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:
Expand Down Expand Up @@ -2109,6 +2175,12 @@ class HelpSection(TypedDict):


HELP_SECTIONS: list[HelpSection] = [
{
"title": "🔌 <b>חיבור ל‑Claude (MCP)</b>",
"entries": [
{"commands": ("connect_claude",), "description": "חיבור הקבצים שלך ל‑Claude (טוקן MCP)"},
],
},
{
"title": "🔔 <b>תזכורות</b>",
"entries": [
Expand Down Expand Up @@ -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):
Expand Down
107 changes: 107 additions & 0 deletions mcp_server/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
# 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 <token>`. הטוקן נשמר כ‑**hash בלבד** בקולקשן
`mcp_tokens`, קשור ל‑`user_id`, וניתן לביטול. ה‑`user_id` נגזר תמיד מהטוקן —
לעולם לא מקלט הלקוח.

### הנפקת טוקן

**הדרך הפשוטה — מתוך הבוט:** שלחו `/connect_claude` בצ'אט פרטי עם הבוט. תקבלו טוקן
מוכן + פקודת חיבור מוכנה להעתקה. (הפקודה זמינה בצ'אט פרטי בלבד כדי שהטוקן לא ידלוף.)

**לאופס/בדיקות (CLI):**
```bash
MONGODB_URL="..." python scripts/mcp_issue_token.py --user-id <TELEGRAM_ID> --label "Claude Desktop"
```
הטוקן מוצג **פעם אחת בלבד** — שמרו אותו.

---

## הרצה מקומית
```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 <token>"
```

## חיבור מ‑Claude Desktop (`claude_desktop_config.json`)
```json
{
"mcpServers": {
"codekeeper": {
"type": "http",
"url": "https://<your-host>/mcp",
"headers": { "Authorization": "Bearer <token>" }
}
}
}
```

> **Claude.ai (וובאפ):** דורש OAuth 2.1 — זו פאזה 1, עדיין לא כאן. PAT עובד היום
> מול Claude Code / Claude Desktop.

---

## פריסה (Render)
שירות web נפרד (ASGI), משתף את אותו `MONGODB_URL` ואת אותם סודות:
```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 של הוובאפ יחיד ורגיש לזיכרון.

---

## מבנה הקוד

| קובץ | תפקיד |
|------|-------|
| `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 אמיתי).
16 changes: 16 additions & 0 deletions mcp_server/__init__.py
Original file line number Diff line number Diff line change
@@ -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.
"""
38 changes: 38 additions & 0 deletions mcp_server/app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
"""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 .backend import ProductionBackend
from .server import build_app
from .token_store import MCPTokenStore
from .wiring import resolve_mongo


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()
85 changes: 85 additions & 0 deletions mcp_server/auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
"""PAT (Bearer) authentication for the MCP HTTP app.

A Starlette middleware verifies ``Authorization: Bearer <token>`` 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

import logging
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

logger = logging.getLogger(__name__)

# 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:
# 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:
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)
Loading
Loading