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
302 lines
11 KiB
Python
302 lines
11 KiB
Python
# 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
|