feat(ai): serveur MCP Streamable HTTP + confirmations two-step (#79 phase E)
CI / lint (push) Successful in 1m3s
CI / security (push) Successful in 41s
CI / test (push) Successful in 1m20s
CI / build (push) Successful in 1m19s
CI / e2e (push) Successful in 10m56s
Desktop Build / build-windows (push) Canceled after 0s
Desktop Build / build-linux (push) Canceled after 0s
CI / lint (push) Successful in 1m3s
CI / security (push) Successful in 41s
CI / test (push) Successful in 1m20s
CI / build (push) Successful in 1m19s
CI / e2e (push) Successful in 10m56s
Desktop Build / build-windows (push) Canceled after 0s
Desktop Build / build-linux (push) Canceled after 0s
This commit is contained in:
@@ -14,6 +14,19 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
### Ajouté
|
||||
|
||||
- **#79 Phase E — Serveur MCP (Streamable HTTP)** — nouvel endpoint `/mcp` exposant ObsiGate à des
|
||||
clients MCP externes (Claude Desktop, Cursor…) via le SDK Python `mcp==1.9.4`. Les deux fronts
|
||||
(assistant in-app et MCP) consomment la même couche d'outils. **Tools** : outils de lecture/recherche
|
||||
directs ; outils d'écriture/destructifs en **two-step `propose_<tool>` / `apply_<tool>`** avec
|
||||
**jeton JWT signé, usage unique et TTL** (`OBSIGATE_MCP_CONFIRMATION_TTL`, défaut 300 s) et
|
||||
blacklist de JTI persistée (`data/mcp_used_tokens.json`, anti-rejeu). **Resources** :
|
||||
`vault://<name>` et `vault://<name>/<path>` (lecture seule, secrets redactés). **Prompts** :
|
||||
`summarize-directory`, `generate-note`, `find-related`. Auth `Authorization: Bearer <JWT>` →
|
||||
`get_current_user` ; permissions et toggle destructif par vault (`aiDestructiveTools`) appliqués.
|
||||
Nouveaux modules `backend/mcp/server.py` et `backend/mcp/confirmations.py` ; dépendances
|
||||
`mcp==1.9.4` + `sse-starlette==2.1.3` (compatibles FastAPI 0.110 / starlette 0.37). Tests :
|
||||
`tests/test_mcp.py` (13 tests).
|
||||
|
||||
- **#79 Phase D — Catalogue d'outils mutations + confirmations two-step** — 11 nouveaux outils IA
|
||||
destructifs ou d'écriture exposés par la couche partagée (`backend/tools/service.py`) :
|
||||
`create_file`, `create_directory`, `edit_file`, `append_to_file`, `rename_file`,
|
||||
|
||||
@@ -800,6 +800,7 @@ class SSESafeGZipMiddleware(GZipMiddleware):
|
||||
"/api/admin/stream",
|
||||
"/api/ai/bookslm/chat",
|
||||
"/api/ai/bookslm/agent",
|
||||
"/mcp",
|
||||
)
|
||||
|
||||
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
||||
@@ -882,6 +883,14 @@ try:
|
||||
except ImportError as e:
|
||||
logger.warning(f"Could not load plugins router: {e}")
|
||||
|
||||
# MCP server (Streamable HTTP) for external clients (#79 phase E)
|
||||
try:
|
||||
from backend.mcp.server import McpMount, mcp_app
|
||||
app.router.routes.append(McpMount(mcp_app))
|
||||
logger.info("MCP server mounted at /mcp")
|
||||
except Exception as e: # pragma: no cover - optional dependency
|
||||
logger.warning(f"Could not mount MCP server: {e}")
|
||||
|
||||
# Resolve frontend path relative to this file
|
||||
FRONTEND_DIR = Path(__file__).resolve().parent.parent / "frontend"
|
||||
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
"""Signed, single-use confirmation tokens for MCP mutations (Phase E4).
|
||||
|
||||
MCP has no "Apply" button, so mutating tools are exposed in two steps:
|
||||
``propose_<tool>`` returns a preview plus a **signed token**, and
|
||||
``apply_<tool>`` consumes that token to execute the mutation.
|
||||
|
||||
The token is a short-lived JWT (same secret as the app) carrying the tool name
|
||||
and its arguments. Single use is enforced by a persisted JTI blacklist, which
|
||||
also protects against replay and TOCTOU (a stale proposal cannot be re-applied).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from jose import JWTError, jwt
|
||||
|
||||
from backend.auth.jwt_handler import get_secret_key
|
||||
|
||||
logger = logging.getLogger("obsigate.mcp.confirmations")
|
||||
|
||||
ALGORITHM = "HS256"
|
||||
TOKEN_TYPE = "mcp_confirmation"
|
||||
|
||||
# Default token lifetime (seconds); override with OBSIGATE_MCP_CONFIRMATION_TTL.
|
||||
DEFAULT_TTL = int(os.environ.get("OBSIGATE_MCP_CONFIRMATION_TTL", "300"))
|
||||
|
||||
_USED_TOKENS_FILE = Path("data/mcp_used_tokens.json")
|
||||
_used_lock = threading.RLock()
|
||||
_used_loaded = False
|
||||
_used_jtis: dict[str, int] = {}
|
||||
|
||||
|
||||
class ConfirmationError(Exception):
|
||||
"""Raised when a confirmation token is invalid, expired, or already used."""
|
||||
|
||||
def __init__(self, message: str, *, code: str = "invalid_confirmation"):
|
||||
super().__init__(message)
|
||||
self.message = message
|
||||
self.code = code
|
||||
|
||||
|
||||
def _load_used() -> None:
|
||||
"""Load used JTIs from disk once, dropping expired entries."""
|
||||
global _used_loaded, _used_jtis
|
||||
if _used_loaded:
|
||||
return
|
||||
with _used_lock:
|
||||
if _used_loaded:
|
||||
return
|
||||
if _USED_TOKENS_FILE.exists():
|
||||
try:
|
||||
data = json.loads(_USED_TOKENS_FILE.read_text(encoding="utf-8"))
|
||||
now = int(time.time())
|
||||
_used_jtis = {jti: exp for jti, exp in data.items() if int(exp) > now}
|
||||
except Exception as e: # pragma: no cover - corrupt store
|
||||
logger.warning(f"Failed to load used MCP tokens: {e}")
|
||||
_used_jtis = {}
|
||||
_used_loaded = True
|
||||
|
||||
|
||||
def _save_used() -> None:
|
||||
"""Persist used JTIs with their expiry (best-effort)."""
|
||||
try:
|
||||
_USED_TOKENS_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = _USED_TOKENS_FILE.with_suffix(".tmp")
|
||||
tmp.write_text(json.dumps(_used_jtis), encoding="utf-8")
|
||||
tmp.replace(_USED_TOKENS_FILE)
|
||||
except Exception as e: # pragma: no cover - disk error
|
||||
logger.warning(f"Failed to persist used MCP tokens: {e}")
|
||||
|
||||
|
||||
def create_confirmation_token(
|
||||
tool: str,
|
||||
arguments: dict[str, Any],
|
||||
username: str,
|
||||
*,
|
||||
ttl: int | None = None,
|
||||
) -> str:
|
||||
"""Create a signed confirmation token for a pending mutation."""
|
||||
now = int(time.time())
|
||||
payload = {
|
||||
"type": TOKEN_TYPE,
|
||||
"tool": tool,
|
||||
"arguments": arguments,
|
||||
"sub": username,
|
||||
"jti": str(uuid.uuid4()),
|
||||
"iat": now,
|
||||
"exp": now + (ttl if ttl is not None else DEFAULT_TTL),
|
||||
}
|
||||
return jwt.encode(payload, get_secret_key(), algorithm=ALGORITHM)
|
||||
|
||||
|
||||
def peek_confirmation_token(token: str) -> dict[str, Any]:
|
||||
"""Decode and validate a token without consuming it (signature + expiry)."""
|
||||
try:
|
||||
payload = jwt.decode(token, get_secret_key(), algorithms=[ALGORITHM])
|
||||
except JWTError as e:
|
||||
raise ConfirmationError("Invalid or expired confirmation token", code="invalid_confirmation") from e
|
||||
|
||||
if payload.get("type") != TOKEN_TYPE:
|
||||
raise ConfirmationError("Wrong token type", code="invalid_confirmation")
|
||||
if not payload.get("tool") or not payload.get("sub"):
|
||||
raise ConfirmationError("Malformed confirmation token", code="invalid_confirmation")
|
||||
return payload
|
||||
|
||||
|
||||
def consume_confirmation_token(token: str, username: str) -> dict[str, Any]:
|
||||
"""Validate a token, enforce single use, and return its payload.
|
||||
|
||||
Raises:
|
||||
ConfirmationError: invalid/expired token, wrong user, or replay.
|
||||
"""
|
||||
payload = peek_confirmation_token(token)
|
||||
|
||||
if payload.get("sub") != username:
|
||||
raise ConfirmationError("Confirmation token does not belong to this user", code="forbidden")
|
||||
|
||||
jti = payload.get("jti")
|
||||
if not jti:
|
||||
raise ConfirmationError("Malformed confirmation token", code="invalid_confirmation")
|
||||
|
||||
_load_used()
|
||||
now = int(time.time())
|
||||
with _used_lock:
|
||||
if jti in _used_jtis:
|
||||
raise ConfirmationError("Confirmation token already used", code="token_reused")
|
||||
# Record as used *before* returning so a concurrent replay is rejected.
|
||||
_used_jtis[jti] = int(payload.get("exp", now))
|
||||
# Opportunistic cleanup of expired JTIs.
|
||||
for old_jti in [k for k, exp in _used_jtis.items() if exp <= now]:
|
||||
_used_jtis.pop(old_jti, None)
|
||||
_save_used()
|
||||
|
||||
return payload
|
||||
@@ -0,0 +1,485 @@
|
||||
"""ObsiGate MCP server — Streamable HTTP transport (Phase E).
|
||||
|
||||
Exposes the shared AI tool layer (``backend.tools``) to external MCP clients
|
||||
(Claude Desktop, Cursor…). The same registry that powers the in-app assistant
|
||||
is registered here, so the two fronts never diverge.
|
||||
|
||||
Primitives:
|
||||
- **Tools** — read/search tools directly; mutating tools as a two-step
|
||||
``propose_<tool>`` / ``apply_<tool>`` pair (signed, single-use token).
|
||||
- **Resources** — accessible vaults (``vault://<name>``) and files
|
||||
(``vault://<name>/<path>``), read-only and secret-redacted.
|
||||
- **Prompts** — reusable note/summary templates.
|
||||
|
||||
Transport: Streamable HTTP mounted at ``/mcp`` (auth ``Authorization: Bearer
|
||||
<JWT>``). ``stdio`` is left for later.
|
||||
|
||||
The transport manager is started lazily on the first request so the endpoint
|
||||
also works in tests (where the ASGI lifespan is not run).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import difflib
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from fastapi.responses import JSONResponse
|
||||
from mcp import types
|
||||
from mcp.server.lowlevel import Server
|
||||
from mcp.server.lowlevel.helper_types import ReadResourceContents
|
||||
from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
|
||||
from pydantic import AnyUrl
|
||||
from starlette._utils import get_route_path
|
||||
from starlette.requests import Request
|
||||
from starlette.routing import BaseRoute, Match
|
||||
from starlette.types import ASGIApp, Receive, Scope, Send
|
||||
|
||||
from backend.auth.middleware import get_current_user, is_auth_enabled
|
||||
from backend.services.files import read_file_text
|
||||
from backend.services.vaults import list_accessible_vaults
|
||||
from backend.tools.api import (
|
||||
ToolContext,
|
||||
ToolError,
|
||||
ToolMode,
|
||||
ToolRisk,
|
||||
ToolScope,
|
||||
call_tool,
|
||||
get_tool,
|
||||
list_tools,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("obsigate.mcp")
|
||||
|
||||
SERVER_NAME = "obsigate"
|
||||
SERVER_INSTRUCTIONS = (
|
||||
"ObsiGate exposes your Obsidian vaults: read, search and (with confirmation) "
|
||||
"create, edit, rename, move or delete notes. Mutating tools require the "
|
||||
"two-step propose_/apply_ flow."
|
||||
)
|
||||
|
||||
# Cap on the file size returned by resources (bytes).
|
||||
MAX_RESOURCE_BYTES = 200_000
|
||||
|
||||
|
||||
def _anonymous_user() -> dict[str, Any]:
|
||||
return {
|
||||
"username": "anonymous",
|
||||
"display_name": "Anonymous",
|
||||
"role": "admin",
|
||||
"vaults": ["*"],
|
||||
"active": True,
|
||||
"_token_vaults": ["*"],
|
||||
}
|
||||
|
||||
|
||||
def _authenticate(request: Request) -> dict[str, Any] | None:
|
||||
"""Resolve the caller from the ``Authorization: Bearer`` header."""
|
||||
if not is_auth_enabled():
|
||||
return _anonymous_user()
|
||||
|
||||
from fastapi.security import HTTPAuthorizationCredentials
|
||||
|
||||
header = request.headers.get("authorization", "")
|
||||
if not header.lower().startswith("bearer "):
|
||||
return None
|
||||
credentials = HTTPAuthorizationCredentials(scheme="Bearer", credentials=header[7:].strip())
|
||||
return get_current_user(request, credentials)
|
||||
|
||||
|
||||
def _user_from_context(server: Server) -> dict[str, Any]:
|
||||
"""Return the authenticated user attached to the current request scope."""
|
||||
request = server.request_context.request
|
||||
if request is None: # pragma: no cover - stdio not supported yet
|
||||
raise ToolError("No request context available", code="no_request_context")
|
||||
user = getattr(request.state, "user", None)
|
||||
if not user:
|
||||
raise ToolError("Unauthenticated MCP request", code="unauthenticated")
|
||||
return user
|
||||
|
||||
|
||||
def _text(payload: Any) -> list[types.Content]:
|
||||
"""Serialize a payload as a single text content block."""
|
||||
return [types.TextContent(type="text", text=json.dumps(payload, ensure_ascii=False, default=str))]
|
||||
|
||||
|
||||
def _unified_diff(old: str, new: str) -> str:
|
||||
diff = difflib.unified_diff(
|
||||
old.splitlines(keepends=True),
|
||||
new.splitlines(keepends=True),
|
||||
fromfile="current",
|
||||
tofile="proposed",
|
||||
)
|
||||
return "".join(diff)
|
||||
|
||||
|
||||
def _build_preview(spec: Any, params: Any) -> dict[str, Any]:
|
||||
"""Build a human-readable preview for a proposed mutation."""
|
||||
arguments = params.model_dump()
|
||||
preview: dict[str, Any] = {"tool": spec.name, "arguments": arguments}
|
||||
|
||||
if spec.name in ("create_file", "edit_file", "append_to_file"):
|
||||
vault = arguments.get("vault")
|
||||
path = arguments.get("path", "")
|
||||
content = arguments.get("content", "")
|
||||
try:
|
||||
current = read_file_text(vault, path, redact=True, max_bytes=MAX_RESOURCE_BYTES)["content"]
|
||||
except Exception:
|
||||
current = ""
|
||||
if spec.name == "append_to_file":
|
||||
separator = "" if (not current or current.endswith("\n")) else "\n"
|
||||
proposed = current + separator + content
|
||||
else:
|
||||
proposed = content
|
||||
preview["diff"] = _unified_diff(current, proposed)
|
||||
|
||||
return preview
|
||||
|
||||
|
||||
def _tool_definitions() -> list[types.Tool]:
|
||||
"""Build the MCP tool list from the shared registry."""
|
||||
definitions: list[types.Tool] = []
|
||||
for spec in list_tools(scope=ToolScope.MCP):
|
||||
if spec.risk == ToolRisk.READ:
|
||||
definitions.append(
|
||||
types.Tool(
|
||||
name=spec.name,
|
||||
description=spec.description,
|
||||
inputSchema=spec.parameters_schema(),
|
||||
annotations=types.ToolAnnotations(readOnlyHint=True, openWorldHint=False),
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
destructive = spec.risk == ToolRisk.DANGEROUS
|
||||
definitions.append(
|
||||
types.Tool(
|
||||
name=f"propose_{spec.name}",
|
||||
description=(
|
||||
f"Propose to run '{spec.name}' and return a confirmation token. "
|
||||
"No change is made until apply_ is called."
|
||||
),
|
||||
inputSchema=spec.parameters_schema(),
|
||||
annotations=types.ToolAnnotations(
|
||||
readOnlyHint=True, destructiveHint=False, idempotentHint=True, openWorldHint=False
|
||||
),
|
||||
)
|
||||
)
|
||||
definitions.append(
|
||||
types.Tool(
|
||||
name=f"apply_{spec.name}",
|
||||
description=f"Apply a previously proposed '{spec.name}' using its confirmation token.",
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"confirmation_token": {
|
||||
"type": "string",
|
||||
"description": f"Token returned by propose_{spec.name}",
|
||||
}
|
||||
},
|
||||
"required": ["confirmation_token"],
|
||||
},
|
||||
annotations=types.ToolAnnotations(
|
||||
readOnlyHint=False, destructiveHint=destructive, idempotentHint=False, openWorldHint=False
|
||||
),
|
||||
)
|
||||
)
|
||||
return definitions
|
||||
|
||||
|
||||
def _parse_vault_uri(uri: str) -> tuple[str, str]:
|
||||
"""Split ``vault://name/path`` into ``(vault, path)``."""
|
||||
parsed = urlparse(uri)
|
||||
if parsed.scheme != "vault" or not parsed.netloc:
|
||||
raise ValueError(f"Unsupported resource URI: {uri}")
|
||||
return parsed.netloc, parsed.path.lstrip("/")
|
||||
|
||||
|
||||
def build_server() -> Server:
|
||||
"""Create and configure the low-level MCP server."""
|
||||
from backend.version import get_version
|
||||
|
||||
server: Server = Server(SERVER_NAME, version=get_version(), instructions=SERVER_INSTRUCTIONS)
|
||||
|
||||
# ── Tools ──────────────────────────────────────────────────────────
|
||||
@server.list_tools()
|
||||
async def _list_tools() -> list[types.Tool]:
|
||||
return _tool_definitions()
|
||||
|
||||
@server.call_tool()
|
||||
async def _call_tool(name: str, arguments: dict[str, Any]) -> list[types.Content]:
|
||||
user = _user_from_context(server)
|
||||
|
||||
if name.startswith("propose_"):
|
||||
return _propose(user, name[len("propose_"):], arguments)
|
||||
if name.startswith("apply_"):
|
||||
return _apply(user, name[len("apply_"):], arguments)
|
||||
|
||||
spec = get_tool(name)
|
||||
if spec is None:
|
||||
raise ValueError(f"Unknown tool: {name}")
|
||||
if spec.risk != ToolRisk.READ:
|
||||
raise ValueError(f"Tool '{name}' is mutating; use propose_{name}/apply_{name}")
|
||||
|
||||
ctx = ToolContext(user=user, mode=ToolMode.MCP)
|
||||
try:
|
||||
result = call_tool(name, ctx, arguments)
|
||||
except ToolError as e:
|
||||
return _text(e.to_dict())
|
||||
return _text({"ok": True, "data": result.data})
|
||||
|
||||
# ── Resources ──────────────────────────────────────────────────────
|
||||
@server.list_resources()
|
||||
async def _list_resources() -> list[types.Resource]:
|
||||
user = _user_from_context(server)
|
||||
return [
|
||||
types.Resource(
|
||||
uri=AnyUrl(f"vault://{v['name']}"),
|
||||
name=v["name"],
|
||||
description=f"ObsiGate vault '{v['name']}' ({v['file_count']} files)",
|
||||
mimeType="application/x-obsigate-vault",
|
||||
)
|
||||
for v in list_accessible_vaults(user)
|
||||
]
|
||||
|
||||
@server.list_resource_templates()
|
||||
async def _list_resource_templates() -> list[types.ResourceTemplate]:
|
||||
return [
|
||||
types.ResourceTemplate(
|
||||
uriTemplate="vault://{vault}/{path}",
|
||||
name="Vault file",
|
||||
description="Read a text file from a vault (secrets redacted)",
|
||||
mimeType="text/markdown",
|
||||
)
|
||||
]
|
||||
|
||||
@server.read_resource()
|
||||
async def _read_resource(uri: Any) -> list[ReadResourceContents]:
|
||||
user = _user_from_context(server)
|
||||
vault, path = _parse_vault_uri(str(uri))
|
||||
ctx = ToolContext(user=user, mode=ToolMode.MCP)
|
||||
ctx.require_vault_access(vault)
|
||||
data = read_file_text(vault, path, redact=True, max_bytes=MAX_RESOURCE_BYTES)
|
||||
mime = "text/markdown" if Path(path).suffix.lower() == ".md" else "text/plain"
|
||||
return [ReadResourceContents(content=data["content"], mime_type=mime)]
|
||||
|
||||
# ── Prompts ────────────────────────────────────────────────────────
|
||||
@server.list_prompts()
|
||||
async def _list_prompts() -> list[types.Prompt]:
|
||||
return [
|
||||
types.Prompt(
|
||||
name="summarize-directory",
|
||||
description="Summarize every note in a vault directory.",
|
||||
arguments=[
|
||||
types.PromptArgument(name="vault", description="Vault name", required=True),
|
||||
types.PromptArgument(name="path", description="Directory path (empty = root)", required=False),
|
||||
],
|
||||
),
|
||||
types.Prompt(
|
||||
name="generate-note",
|
||||
description="Draft a new note on a topic, using the vault for context.",
|
||||
arguments=[
|
||||
types.PromptArgument(name="topic", description="Note topic", required=True),
|
||||
types.PromptArgument(name="vault", description="Target vault name", required=True),
|
||||
],
|
||||
),
|
||||
types.Prompt(
|
||||
name="find-related",
|
||||
description="Find notes related to a given note.",
|
||||
arguments=[
|
||||
types.PromptArgument(name="vault", description="Vault name", required=True),
|
||||
types.PromptArgument(name="path", description="Reference note path", required=True),
|
||||
],
|
||||
),
|
||||
]
|
||||
|
||||
@server.get_prompt()
|
||||
async def _get_prompt(name: str, arguments: dict[str, str] | None) -> types.GetPromptResult:
|
||||
args = arguments or {}
|
||||
|
||||
def _require(key: str) -> str:
|
||||
value = (args.get(key) or "").strip()
|
||||
if not value:
|
||||
raise ValueError(f"Missing required prompt argument: {key}")
|
||||
return value
|
||||
|
||||
if name == "summarize-directory":
|
||||
vault = _require("vault")
|
||||
path = (args.get("path") or "").strip()
|
||||
text = (
|
||||
f"List the directory '{path or '/'}' of vault '{vault}' (use list_directory), "
|
||||
"read the notes it contains, then write a concise summary with the key ideas."
|
||||
)
|
||||
elif name == "generate-note":
|
||||
topic = _require("topic")
|
||||
vault = _require("vault")
|
||||
text = (
|
||||
f"Draft a well-structured Markdown note about '{topic}' in vault '{vault}'. "
|
||||
"Search the vault first for existing material, then propose the note content."
|
||||
)
|
||||
elif name == "find-related":
|
||||
vault = _require("vault")
|
||||
path = _require("path")
|
||||
text = (
|
||||
f"Read '{path}' in vault '{vault}', then find related notes via backlinks and "
|
||||
"full-text search. Return a short list with why each note is related."
|
||||
)
|
||||
else:
|
||||
raise ValueError(f"Unknown prompt: {name}")
|
||||
|
||||
return types.GetPromptResult(
|
||||
description=f"ObsiGate prompt: {name}",
|
||||
messages=[types.PromptMessage(role="user", content=types.TextContent(type="text", text=text))],
|
||||
)
|
||||
|
||||
return server
|
||||
|
||||
|
||||
def _propose(user: dict[str, Any], tool: str, arguments: dict[str, Any]) -> list[types.Content]:
|
||||
"""Validate a mutation, return a preview and a confirmation token."""
|
||||
from backend.mcp.confirmations import DEFAULT_TTL, create_confirmation_token
|
||||
|
||||
spec = get_tool(tool)
|
||||
if spec is None:
|
||||
raise ValueError(f"Unknown tool: {tool}")
|
||||
if spec.risk == ToolRisk.READ:
|
||||
raise ValueError(f"Tool '{tool}' is read-only; call it directly")
|
||||
|
||||
params = spec.input_model.model_validate(arguments)
|
||||
vault = getattr(params, "vault", None)
|
||||
ctx = ToolContext(user=user, mode=ToolMode.MCP)
|
||||
if vault and vault != "all":
|
||||
ctx.require_vault_access(vault)
|
||||
if spec.risk == ToolRisk.DANGEROUS:
|
||||
ctx.require_destructive_allowed(vault)
|
||||
|
||||
token = create_confirmation_token(tool, arguments, ctx.username)
|
||||
payload = _build_preview(spec, params)
|
||||
payload["confirmation_token"] = token
|
||||
payload["expires_in"] = DEFAULT_TTL
|
||||
return _text(payload)
|
||||
|
||||
|
||||
def _apply(user: dict[str, Any], tool: str, arguments: dict[str, Any]) -> list[types.Content]:
|
||||
"""Consume a confirmation token and execute the mutation."""
|
||||
from backend.mcp.confirmations import ConfirmationError, consume_confirmation_token
|
||||
|
||||
token = (arguments or {}).get("confirmation_token")
|
||||
if not token:
|
||||
raise ValueError("Missing 'confirmation_token'")
|
||||
|
||||
try:
|
||||
payload = consume_confirmation_token(token, user.get("username", ""))
|
||||
except ConfirmationError as e:
|
||||
return _text({"ok": False, "error": {"code": e.code, "message": e.message}})
|
||||
|
||||
if payload.get("tool") != tool:
|
||||
return _text(
|
||||
{
|
||||
"ok": False,
|
||||
"error": {
|
||||
"code": "token_mismatch",
|
||||
"message": f"Token was issued for '{payload.get('tool')}', not '{tool}'",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
ctx = ToolContext(user=user, mode=ToolMode.MCP, confirmed=True)
|
||||
try:
|
||||
result = call_tool(tool, ctx, payload.get("arguments") or {}, confirm=True)
|
||||
except ToolError as e:
|
||||
return _text(e.to_dict())
|
||||
return _text({"ok": True, "data": result.data})
|
||||
|
||||
|
||||
class McpASGIApp:
|
||||
"""ASGI wrapper: authenticates the request, then runs the MCP transport.
|
||||
|
||||
The session manager is started lazily on first use and kept alive for the
|
||||
process lifetime, so the endpoint works both under uvicorn (with lifespan)
|
||||
and under the test client (without entering the ASGI lifespan).
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._manager: StreamableHTTPSessionManager | None = None
|
||||
self._start_lock: asyncio.Lock | None = None
|
||||
self._run_task: asyncio.Task | None = None
|
||||
|
||||
def _get_manager(self) -> StreamableHTTPSessionManager:
|
||||
if self._manager is None:
|
||||
self._manager = StreamableHTTPSessionManager(
|
||||
app=build_server(),
|
||||
json_response=True,
|
||||
stateless=False,
|
||||
)
|
||||
return self._manager
|
||||
|
||||
async def _ensure_started(self) -> StreamableHTTPSessionManager:
|
||||
manager = self._get_manager()
|
||||
if getattr(manager, "_task_group", None) is not None:
|
||||
return manager
|
||||
if self._start_lock is None:
|
||||
self._start_lock = asyncio.Lock()
|
||||
async with self._start_lock:
|
||||
if getattr(manager, "_task_group", None) is None:
|
||||
self._run_task = asyncio.create_task(self._run_manager(manager))
|
||||
for _ in range(500):
|
||||
if getattr(manager, "_task_group", None) is not None:
|
||||
break
|
||||
await asyncio.sleep(0.005)
|
||||
return manager
|
||||
|
||||
async def _run_manager(self, manager: StreamableHTTPSessionManager) -> None:
|
||||
async with manager.run():
|
||||
await asyncio.Event().wait()
|
||||
|
||||
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
||||
if scope["type"] != "http":
|
||||
return
|
||||
|
||||
request = Request(scope, receive)
|
||||
user = _authenticate(request)
|
||||
if user is None:
|
||||
response = JSONResponse(
|
||||
{"detail": "Authentification requise"},
|
||||
status_code=401,
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
await response(scope, receive, send)
|
||||
return
|
||||
|
||||
scope.setdefault("state", {})["user"] = user
|
||||
manager = await self._ensure_started()
|
||||
await manager.handle_request(scope, receive, send)
|
||||
|
||||
|
||||
# Module-level singleton mounted by ``backend.main`` at ``/mcp``.
|
||||
mcp_app = McpASGIApp()
|
||||
|
||||
|
||||
class McpMount(BaseRoute):
|
||||
"""ASGI route matching ``/mcp`` and ``/mcp/...`` (unlike Starlette's Mount).
|
||||
|
||||
Starlette's :class:`~starlette.routing.Mount` compiles ``/mcp/{path:path}``
|
||||
and therefore does **not** match the bare ``/mcp`` path used by MCP clients.
|
||||
This route matches both forms and delegates to :data:`mcp_app` unchanged.
|
||||
"""
|
||||
|
||||
def __init__(self, app: ASGIApp, path: str = "/mcp") -> None:
|
||||
self.app = app
|
||||
self.path = path.rstrip("/")
|
||||
|
||||
def matches(self, scope: Scope) -> tuple[Match, Scope]:
|
||||
if scope["type"] == "http":
|
||||
route_path = get_route_path(scope)
|
||||
if route_path == self.path or route_path.startswith(self.path + "/"):
|
||||
return Match.FULL, {"endpoint": self.app}
|
||||
return Match.NONE, {}
|
||||
|
||||
async def handle(self, scope: Scope, receive: Receive, send: Send) -> None:
|
||||
await self.app(scope, receive, send)
|
||||
|
||||
@@ -17,3 +17,5 @@ pyotp>=2.10.0
|
||||
webauthn==2.6.0
|
||||
psutil>=5.9
|
||||
pywebpush>=2.3.0
|
||||
mcp==1.9.4
|
||||
sse-starlette==2.1.3
|
||||
|
||||
@@ -159,14 +159,23 @@ boucle (max N itérations):
|
||||
(les appels d'outils exigent la réponse complète avant exécution). Confirmations UI en deux temps
|
||||
(`confirmation` → `confirm`/`confirm_messages`).
|
||||
|
||||
### 3.4 Couche 4 — Serveur MCP
|
||||
### 3.4 Couche 4 — Serveur MCP (livré, phase E)
|
||||
|
||||
Nouveau `backend/mcp/server.py` basé sur le SDK MCP Python :
|
||||
`backend/mcp/server.py` s'appuie sur le SDK MCP Python (`mcp==1.9.4`) :
|
||||
|
||||
- **Tools** : enregistrés depuis le registry (mutations + recherche).
|
||||
- **Resources** : vaults/fichiers en lecture (`vault://name/path.md`).
|
||||
- **Prompts** : templates (résumer un dossier, générer une note…).
|
||||
- Transports : **stdio** (local) et **HTTP/SSE** (distant, auth JWT).
|
||||
- **Tools** : enregistrés depuis le registry (scope `mcp`). Les outils `read` sont exposés
|
||||
directement ; les outils `write`/`dangerous` le sont via la paire `propose_<tool>` /
|
||||
`apply_<tool>` (jeton signé, usage unique, TTL).
|
||||
- **Resources** : `vault://<name>` (vaults accessibles) et `vault://<name>/<path>` (fichiers,
|
||||
lecture seule, secrets redactés).
|
||||
- **Prompts** : `summarize-directory`, `generate-note`, `find-related`.
|
||||
- Transport : **Streamable HTTP** (`/mcp`, SDK `StreamableHTTPSessionManager` en
|
||||
`json_response=True`), auth `Authorization: Bearer <JWT>` → `get_current_user`. `stdio`
|
||||
optionnel plus tard.
|
||||
- Confirmations (E4) : `backend/mcp/confirmations.py` — jeton JWT (`type=mcp_confirmation`)
|
||||
contenant outil + arguments + utilisateur ; blacklist de JTI persistée pour l'anti-rejeu.
|
||||
- Le manager de session est démarré **paresseusement** à la première requête, pour fonctionner
|
||||
aussi bien sous uvicorn que sous le client de test (sans lifespan ASGI).
|
||||
|
||||
---
|
||||
|
||||
@@ -319,6 +328,8 @@ Voir `docs/ROADMAP.md` (item dédié) pour le détail des activités.
|
||||
- `backend/services/` — logique métier partagée (vaults, files, search, backups, graph, recent) consommée par les routes et les outils
|
||||
- `backend/ai_chat.py` — chat completion provider-agnostique avec tool calling et streaming (OpenAI-compat + Gemini)
|
||||
- `backend/agent/loop.py` — agent loop in-app (multi-étapes, LLM injectable)
|
||||
- `backend/mcp/server.py` — serveur MCP (Streamable HTTP `/mcp`, tools/resources/prompts)
|
||||
- `backend/mcp/confirmations.py` — jetons de confirmation signés (two-step, anti-rejeu)
|
||||
- `backend/bookslm.py`, `backend/bookslm_routes.py` — assistant contextuel (+ endpoint `/agent`)
|
||||
- `frontend/js/ai.js`, `frontend/js/bookslm.js` — UI IA
|
||||
- `backend/auth/middleware.py` — permissions
|
||||
|
||||
+3
-3
@@ -43,12 +43,12 @@
|
||||
### 79. Assistant IA — Outils (function calling) & serveur MCP
|
||||
|
||||
- **Effort :** 10-15 jours | **Impact :** 🟡
|
||||
- **Statut :** 🔵 Phase 0 + A2 + B1/B2/B3/B4/B5/B6/B7 + C + D + G livrés (2026-09-11). Détail complet : [features/ai-tools-mcp.md](./features/ai-tools-mcp.md)
|
||||
- **Statut :** 🔵 Phase 0 + A2 + B1/B2/B3/B4/B5/B6/B7 + C + D + E + G livrés (2026-09-11). Détail complet : [features/ai-tools-mcp.md](./features/ai-tools-mcp.md)
|
||||
- **Description :** Transformer l'assistant BooksLM en agent (lire, chercher, lister, ouvrir, modifier) via **function calling natif**, puis exposer ObsiGate à des **clients MCP externes** (Claude Desktop, Cursor…). Les deux fronts consomment une **couche d'outils partagée**.
|
||||
- **Reste à faire :**
|
||||
- [x] **C.** Catalogue lecture & recherche (vaults, read, backlinks, backups, search, tags)
|
||||
- [x] **D.** Catalogue mutations (create/edit/append/rename/move/delete) + confirmations two-step
|
||||
- [ ] **E.** Serveur MCP (Streamable HTTP `/mcp`, auth Bearer JWT, `propose`/`apply`, toggle par vault)
|
||||
- [x] **E.** Serveur MCP (Streamable HTTP `/mcp`, auth Bearer JWT, `propose`/`apply`, toggle par vault)
|
||||
- [ ] **F.** Durcissement (rate limiting, redaction secrets, OpenAPI + guide MCP, E2E)
|
||||
- **Documentation :** [AI_ARCHITECTURE_GUIDE.md](./AI_ARCHITECTURE_GUIDE.md) (architecture, catalogue d'outils, sécurité, phases).
|
||||
|
||||
@@ -180,7 +180,7 @@
|
||||
|---|---|---|
|
||||
| ✅ Complété | #1 → #59, #61, #63–68, #71, #72, #74–76, #78 | ~82 jours réalisés |
|
||||
| 🔵 P2 restant | #77 Desktop : signature code (optionnel), wizard 1er lancement (optionnel), 6 tests E2E **manuels** | ~1-2 jours |
|
||||
| ⚪ P3 restant | #62 Collaboration Yjs (5-7j) · #79 Assistant IA outils + MCP (10-15j, Phase 0 + A2 + B + C + D + G livrés) · #80 Assistant IA UX (livré, en attente vérif) | ~15-22 jours |
|
||||
| ⚪ P3 restant | #62 Collaboration Yjs (5-7j) · #79 Assistant IA outils + MCP (10-15j, Phase 0 + A2 + B + C + D + E + G livrés) · #80 Assistant IA UX (livré, en attente vérif) | ~15-22 jours |
|
||||
| ⚪ P4 restant | #69 Mobile éditeur (2-3j) · #70 Sémantique (4-5j) · #73 Sync (6-8j) | 11-16 jours |
|
||||
| **Total restant** | **6 items + finitions** | **~27-39 jours** |
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# #79 — Assistant IA — Outils (function calling) & serveur MCP
|
||||
|
||||
> **Statut :** 🔵 En cours — Phase 0 + A2 + B1/B2/B3/B4/B5/B6/B7 + C + D + G livrés (2026-09-11)
|
||||
> **Statut :** 🔵 En cours — Phase 0 + A2 + B1/B2/B3/B4/B5/B6/B7 + C + D + E + G livrés (2026-09-11)
|
||||
> **Effort :** 10-15 jours | **Impact :** 🟡
|
||||
> **Références :** [Roadmap](../ROADMAP.md) · [Guide d'architecture IA](../AI_ARCHITECTURE_GUIDE.md)
|
||||
|
||||
@@ -41,13 +41,17 @@
|
||||
Toggle par vault `aiDestructiveTools` (défaut activé) pour désactiver les outils `DANGEROUS`.
|
||||
Tests : `tests/test_tools_mutations.py` (40 tests).
|
||||
|
||||
## E. Serveur MCP (2-3 jours)
|
||||
- [ ] **E1.** `backend/mcp/server.py` (SDK MCP Python) enregistrant les outils depuis le registry
|
||||
- [ ] **E2.** Mapping des primitives : Tools (mutations + recherche), Resources (vaults/fichiers en lecture `vault://`), Prompts (templates)
|
||||
- [ ] **E3.** Transport **Streamable HTTP** : endpoint `/mcp` dans FastAPI, auth `Authorization: Bearer <JWT>` → `get_current_user` (décision 2026-09-11). `stdio` optionnel plus tard
|
||||
- [ ] **E4.** Confirmations : **two-step `propose_*`/`apply_*`** avec token signé (usage unique, TTL) ; élicitation optionnelle si le client l'annonce (décision 2026-09-11)
|
||||
- [ ] **E5.** Toggle par vault pour désactiver les outils destructifs (défaut : activés)
|
||||
- [ ] **E6.** Tests : handshake MCP, permissions par vault, mapping des ressources, anti-rejeu du token
|
||||
## E. Serveur MCP (2-3 jours) — ✅ livré (2026-09-11)
|
||||
- [x] **E1.** `backend/mcp/server.py` (SDK MCP Python `mcp==1.9.4`) enregistrant les outils depuis le registry
|
||||
- [x] **E2.** Mapping des primitives : Tools (lecture + `propose_*`/`apply_*`), Resources (`vault://<name>`, `vault://<name>/<path>`), Prompts (`summarize-directory`, `generate-note`, `find-related`)
|
||||
- [x] **E3.** Transport **Streamable HTTP** : route `/mcp` (matching exact + sous-chemins), auth `Authorization: Bearer <JWT>` → `get_current_user`. `stdio` optionnel plus tard
|
||||
- [x] **E4.** Confirmations **two-step `propose_*`/`apply_*`** avec token JWT signé (usage unique, TTL `OBSIGATE_MCP_CONFIRMATION_TTL`, défaut 300 s) + blacklist de JTI persistée (`data/mcp_used_tokens.json`, anti-rejeu)
|
||||
- [x] **E5.** Toggle par vault `aiDestructiveTools` appliqué à la proposition et à l'application
|
||||
- [x] **E6.** Tests : `tests/test_mcp.py` (13 tests) — handshake `initialize`, `tools/list`/`tools/call`, resources, prompts, permissions par vault, anti-rejeu du token
|
||||
- **Détail :** `backend/mcp/confirmations.py` (jetons signés + anti-rejeu) ; `backend/mcp/server.py`
|
||||
(`Server` lowlevel + `StreamableHTTPSessionManager` en `json_response=True`, `McpMount` pour
|
||||
matcher `/mcp` exact, démarrage paresseux du manager pour les tests). Dépendances ajoutées :
|
||||
`mcp==1.9.4`, `sse-starlette==2.1.3` (compatibles avec FastAPI 0.110 / starlette 0.37).
|
||||
|
||||
## F. Durcissement & documentation (1 jour)
|
||||
- [ ] **F1.** Rate limiting par token/outil + quotas (`BOOKSLM_MAX_*`)
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
# tests/test_mcp.py — Unit tests for the MCP server (Phase E)
|
||||
"""Tests for the Streamable HTTP MCP endpoint: handshake, tools, resources,
|
||||
prompts, per-vault permissions and single-use confirmation tokens.
|
||||
|
||||
The endpoint is mounted at ``/mcp``. Auth is disabled in the ``client``
|
||||
fixture, so the caller is an anonymous admin unless a test monkeypatches
|
||||
``backend.mcp.server._authenticate``.
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
ACCEPT = "application/json, text/event-stream"
|
||||
PROTOCOL = "2025-03-26"
|
||||
|
||||
|
||||
def _post(client, payload, session=None, headers=None):
|
||||
merged = {"Accept": ACCEPT, "Content-Type": "application/json"}
|
||||
if session:
|
||||
merged["Mcp-Session-Id"] = session
|
||||
if headers:
|
||||
merged.update(headers)
|
||||
return client.post("/mcp", content=json.dumps(payload), headers=merged)
|
||||
|
||||
|
||||
def _initialize(client):
|
||||
resp = _post(
|
||||
client,
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "initialize",
|
||||
"params": {
|
||||
"protocolVersion": PROTOCOL,
|
||||
"capabilities": {},
|
||||
"clientInfo": {"name": "pytest", "version": "1.0"},
|
||||
},
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
session = resp.headers.get("mcp-session-id")
|
||||
assert session
|
||||
# Client acknowledges initialization (notification, no id).
|
||||
ack = _post(client, {"jsonrpc": "2.0", "method": "notifications/initialized"}, session=session)
|
||||
assert ack.status_code == 202
|
||||
return session, resp.json()
|
||||
|
||||
|
||||
def _call(client, session, method, params=None, req_id=2):
|
||||
resp = _post(
|
||||
client,
|
||||
{"jsonrpc": "2.0", "id": req_id, "method": method, "params": params or {}},
|
||||
session=session,
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
return resp.json()
|
||||
|
||||
|
||||
def _text_json(result):
|
||||
return json.loads(result["content"][0]["text"])
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mcp_client(app_with_vault, monkeypatch):
|
||||
"""Persistent TestClient (lifespan + single event loop) with a fresh MCP manager."""
|
||||
import backend.main as main
|
||||
|
||||
async def _noop_build(*args, **kwargs):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(main, "build_index", _noop_build)
|
||||
monkeypatch.setattr(main, "init_inverted_index", lambda: None)
|
||||
|
||||
# The manager can only run() once per instance; reset it between tests so
|
||||
# each test gets a manager bound to its own event loop.
|
||||
main.mcp_app._manager = None
|
||||
main.mcp_app._run_task = None
|
||||
main.mcp_app._start_lock = None
|
||||
|
||||
with TestClient(main.app) as client:
|
||||
yield client
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# Handshake & primitives
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestHandshake:
|
||||
def test_initialize(self, mcp_client):
|
||||
session, body = _initialize(mcp_client)
|
||||
assert session
|
||||
result = body["result"]
|
||||
assert result["serverInfo"]["name"] == "obsigate"
|
||||
assert result["protocolVersion"]
|
||||
assert "tools" in result["capabilities"]
|
||||
assert "resources" in result["capabilities"]
|
||||
assert "prompts" in result["capabilities"]
|
||||
|
||||
def test_tools_list(self, mcp_client):
|
||||
session, _ = _initialize(mcp_client)
|
||||
body = _call(mcp_client, session, "tools/list")
|
||||
names = {t["name"] for t in body["result"]["tools"]}
|
||||
# Read tools exposed directly.
|
||||
assert "read_file" in names
|
||||
assert "search_fulltext" in names
|
||||
# Mutations exposed only through propose_/apply_.
|
||||
assert "edit_file" not in names
|
||||
assert "propose_edit_file" in names
|
||||
assert "apply_edit_file" in names
|
||||
assert "propose_delete_file" in names
|
||||
assert "apply_delete_file" in names
|
||||
|
||||
def test_resources_list(self, mcp_client):
|
||||
session, _ = _initialize(mcp_client)
|
||||
body = _call(mcp_client, session, "resources/list")
|
||||
uris = {r["uri"] for r in body["result"]["resources"]}
|
||||
assert "vault://TestVault" in uris
|
||||
|
||||
def test_resource_templates(self, mcp_client):
|
||||
session, _ = _initialize(mcp_client)
|
||||
body = _call(mcp_client, session, "resources/templates/list")
|
||||
templates = [t["uriTemplate"] for t in body["result"]["resourceTemplates"]]
|
||||
assert "vault://{vault}/{path}" in templates
|
||||
|
||||
def test_prompts_list_and_get(self, mcp_client):
|
||||
session, _ = _initialize(mcp_client)
|
||||
listing = _call(mcp_client, session, "prompts/list")
|
||||
names = {p["name"] for p in listing["result"]["prompts"]}
|
||||
assert {"summarize-directory", "generate-note", "find-related"} <= names
|
||||
|
||||
got = _call(
|
||||
mcp_client,
|
||||
session,
|
||||
"prompts/get",
|
||||
{"name": "find-related", "arguments": {"vault": "TestVault", "path": "note1.md"}},
|
||||
)
|
||||
text = got["result"]["messages"][0]["content"]["text"]
|
||||
assert "note1.md" in text
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# Tools: read + two-step mutations
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestTools:
|
||||
def test_call_read_tool(self, mcp_client):
|
||||
session, _ = _initialize(mcp_client)
|
||||
body = _call(
|
||||
mcp_client,
|
||||
session,
|
||||
"tools/call",
|
||||
{"name": "read_file", "arguments": {"vault": "TestVault", "path": "note1.md"}},
|
||||
)
|
||||
payload = _text_json(body["result"])
|
||||
assert payload["ok"] is True
|
||||
assert "Python" in payload["data"]["content"]
|
||||
|
||||
def test_resource_read(self, mcp_client):
|
||||
session, _ = _initialize(mcp_client)
|
||||
body = _call(
|
||||
mcp_client,
|
||||
session,
|
||||
"resources/read",
|
||||
{"uri": "vault://TestVault/note1.md"},
|
||||
)
|
||||
text = body["result"]["contents"][0]["text"]
|
||||
assert "Python" in text
|
||||
|
||||
def test_propose_then_apply(self, mcp_client):
|
||||
session, _ = _initialize(mcp_client)
|
||||
proposed = _call(
|
||||
mcp_client,
|
||||
session,
|
||||
"tools/call",
|
||||
{
|
||||
"name": "propose_edit_file",
|
||||
"arguments": {"vault": "TestVault", "path": "note1.md", "content": "# Edited via MCP\n"},
|
||||
},
|
||||
)
|
||||
payload = _text_json(proposed["result"])
|
||||
token = payload["confirmation_token"]
|
||||
assert payload["tool"] == "edit_file"
|
||||
assert "diff" in payload
|
||||
|
||||
applied = _call(
|
||||
mcp_client,
|
||||
session,
|
||||
"tools/call",
|
||||
{"name": "apply_edit_file", "arguments": {"confirmation_token": token}},
|
||||
req_id=3,
|
||||
)
|
||||
result = _text_json(applied["result"])
|
||||
assert result["ok"] is True
|
||||
|
||||
from backend.indexer import get_vault_data
|
||||
from pathlib import Path
|
||||
|
||||
content = (Path(get_vault_data("TestVault")["path"]) / "note1.md").read_text(encoding="utf-8")
|
||||
assert content == "# Edited via MCP\n"
|
||||
|
||||
def test_token_replay_rejected(self, mcp_client):
|
||||
session, _ = _initialize(mcp_client)
|
||||
proposed = _call(
|
||||
mcp_client,
|
||||
session,
|
||||
"tools/call",
|
||||
{
|
||||
"name": "propose_edit_file",
|
||||
"arguments": {"vault": "TestVault", "path": "note1.md", "content": "# Once\n"},
|
||||
},
|
||||
)
|
||||
token = _text_json(proposed["result"])["confirmation_token"]
|
||||
|
||||
first = _call(
|
||||
mcp_client,
|
||||
session,
|
||||
"tools/call",
|
||||
{"name": "apply_edit_file", "arguments": {"confirmation_token": token}},
|
||||
req_id=3,
|
||||
)
|
||||
assert _text_json(first["result"])["ok"] is True
|
||||
|
||||
second = _call(
|
||||
mcp_client,
|
||||
session,
|
||||
"tools/call",
|
||||
{"name": "apply_edit_file", "arguments": {"confirmation_token": token}},
|
||||
req_id=4,
|
||||
)
|
||||
payload = _text_json(second["result"])
|
||||
assert payload["ok"] is False
|
||||
assert payload["error"]["code"] == "token_reused"
|
||||
|
||||
def test_apply_without_token_rejected(self, mcp_client):
|
||||
session, _ = _initialize(mcp_client)
|
||||
body = _call(
|
||||
mcp_client,
|
||||
session,
|
||||
"tools/call",
|
||||
{"name": "apply_edit_file", "arguments": {}},
|
||||
)
|
||||
# The low-level handler converts the ValueError into an MCP error result.
|
||||
assert body["result"].get("isError") is True
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# Permissions & auth
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestPermissions:
|
||||
def test_denied_vault_tool_call(self, mcp_client, monkeypatch):
|
||||
import backend.mcp.server as mcp_server
|
||||
|
||||
monkeypatch.setattr(
|
||||
mcp_server,
|
||||
"_authenticate",
|
||||
lambda request: {"username": "limited", "vaults": ["OtherVault"], "_token_vaults": ["OtherVault"]},
|
||||
)
|
||||
session, _ = _initialize(mcp_client)
|
||||
body = _call(
|
||||
mcp_client,
|
||||
session,
|
||||
"tools/call",
|
||||
{"name": "read_file", "arguments": {"vault": "TestVault", "path": "note1.md"}},
|
||||
)
|
||||
payload = _text_json(body["result"])
|
||||
assert payload["ok"] is False
|
||||
assert payload["error"]["code"] in ("vault_access_denied", "permission_denied")
|
||||
|
||||
def test_denied_vault_resource_read(self, mcp_client, monkeypatch):
|
||||
import backend.mcp.server as mcp_server
|
||||
|
||||
monkeypatch.setattr(
|
||||
mcp_server,
|
||||
"_authenticate",
|
||||
lambda request: {"username": "limited", "vaults": ["OtherVault"], "_token_vaults": ["OtherVault"]},
|
||||
)
|
||||
session, _ = _initialize(mcp_client)
|
||||
body = _call(
|
||||
mcp_client,
|
||||
session,
|
||||
"resources/read",
|
||||
{"uri": "vault://TestVault/note1.md"},
|
||||
)
|
||||
# A denied read surfaces as a JSON-RPC error (or an isError result).
|
||||
assert "error" in body or body.get("result", {}).get("isError") is True
|
||||
|
||||
def test_missing_token_returns_401_when_auth_enabled(self, mcp_client, monkeypatch):
|
||||
import backend.mcp.server as mcp_server
|
||||
|
||||
monkeypatch.setattr(mcp_server, "is_auth_enabled", lambda: True)
|
||||
resp = _post(
|
||||
mcp_client,
|
||||
{"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}},
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
Reference in New Issue
Block a user