PMFlow/backend/tests/test_mcp_config_api.py

311 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

from __future__ import annotations
from types import SimpleNamespace
import pytest
import pytest_asyncio
from fastapi import FastAPI
from httpx import ASGITransport, AsyncClient
from sqlalchemy import JSON, select
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from app.api.mcp_api import routes as mcp_routes
from app.core.database import get_db
from app.dependencies.auth import get_current_user
from app.models.base import Base
from app.models.integration import Integration
from app.services.mcp_config import McpConfigService
def _use_sqlite_json_types(*tables) -> None:
for table in tables:
for column in table.columns:
if column.type.__class__.__name__ in {"JSONB", "ARRAY", "Vector"}:
column.type = JSON()
@pytest_asyncio.fixture
async def db_session():
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
tables = [Integration.__table__]
_use_sqlite_json_types(*tables)
async with engine.begin() as conn:
await conn.run_sync(lambda sync_conn: Base.metadata.create_all(sync_conn, tables=tables))
maker = async_sessionmaker(engine, expire_on_commit=False)
async with maker() as session:
yield session
await engine.dispose()
def _build_app(db_session, *, role: str, tenant_id: int = 1) -> FastAPI:
app = FastAPI()
app.include_router(mcp_routes.router)
async def _fake_get_db():
yield db_session
app.dependency_overrides[get_db] = _fake_get_db
app.dependency_overrides[get_current_user] = lambda: SimpleNamespace(
id=7,
tenant_id=tenant_id,
role=role,
username="tester",
email="tester@example.com",
name="Tester",
)
return app
@pytest.mark.asyncio
async def test_mcp_admin_api_requires_manager_role(db_session) -> None:
app = _build_app(db_session, role="AI")
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get("/mcp/admin/catalog")
assert response.status_code == 403
@pytest.mark.asyncio
async def test_mcp_admin_catalog_and_server_lifecycle(db_session, monkeypatch) -> None:
async def _fake_probe(self, *, template, params): # noqa: ANN001
_ = (self, template, params)
return {
"status": "ready",
"checked_at": "2026-04-15T12:01:00+08:00",
"message": "PMFlow backend can reach Microsoft Learn MCP.",
"tool_names": ["microsoft_docs_search", "microsoft_docs_fetch"],
}
monkeypatch.setattr(McpConfigService, "_probe_backend_runtime", _fake_probe)
app = _build_app(db_session, role="ADMIN")
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
catalog_response = await client.get("/mcp/admin/catalog")
create_response = await client.post(
"/mcp/admin/servers",
json={
"name": "microsoft-learn",
"display_name": "Microsoft Learn MCP",
"template_id": "microsoft_learn",
"params": {},
},
)
created = create_response.json()["data"]
list_response = await client.get("/mcp/admin/servers")
validate_response = await client.post(f"/mcp/admin/servers/{created['id']}/validate")
update_response = await client.put(
f"/mcp/admin/servers/{created['id']}",
json={
"display_name": "Microsoft Learn Docs",
"params": {},
},
)
confirm_response = await client.post(f"/mcp/admin/servers/{created['id']}/confirm-client-apply")
assert catalog_response.status_code == 200
assert catalog_response.json()["code"] == 0
assert catalog_response.json()["data"]["recommended_template_id"] == "microsoft_learn"
assert catalog_response.json()["data"]["items"][0]["execution_mode"] == "backend_direct"
assert any(item["id"] == "duckduckgo" for item in catalog_response.json()["data"]["items"])
assert create_response.status_code == 200
assert create_response.json()["code"] == 0
assert created["execution_mode"] == "backend_direct"
assert created["client_apply_state"] == "not_required"
assert created["runtime_probe"]["status"] == "unknown"
assert list_response.status_code == 200
assert list_response.json()["data"]["items"][0]["name"] == "microsoft-learn"
assert validate_response.status_code == 200
assert validate_response.json()["code"] == 0
assert validate_response.json()["data"]["is_valid"] is True
assert validate_response.json()["data"]["preview"]["add_command"] == "无需本机命令。保存后由 PMFlow 后端直接连接 Microsoft Learn MCP。"
assert validate_response.json()["data"]["preview"]["verify_command"] == "点击“重新校验”后,PMFlow 会从后端执行 MCP initialize 和 tools/list。"
assert validate_response.json()["data"]["runtime_probe"]["status"] == "ready"
assert validate_response.json()["data"]["runtime_probe"]["tool_names"] == ["microsoft_docs_search", "microsoft_docs_fetch"]
assert update_response.status_code == 200
assert update_response.json()["code"] == 0
assert update_response.json()["data"]["display_name"] == "Microsoft Learn Docs"
assert confirm_response.status_code == 400
assert confirm_response.json()["code"] == 400
assert "后端直连" in confirm_response.json()["message"]
@pytest.mark.asyncio
async def test_mcp_admin_servers_are_tenant_scoped(db_session) -> None:
tenant1_app = _build_app(db_session, role="ADMIN", tenant_id=1)
tenant2_app = _build_app(db_session, role="ADMIN", tenant_id=2)
async with AsyncClient(transport=ASGITransport(app=tenant1_app), base_url="http://test") as client:
create_response = await client.post(
"/mcp/admin/servers",
json={
"name": "playwright",
"display_name": "Playwright MCP",
"template_id": "playwright",
"params": {"output_dir": "~/.codex/mcp/playwright-output"},
},
)
async with AsyncClient(transport=ASGITransport(app=tenant2_app), base_url="http://test") as client:
list_response = await client.get("/mcp/admin/servers")
assert create_response.status_code == 200
assert create_response.json()["code"] == 0
assert list_response.status_code == 200
assert list_response.json()["code"] == 0
assert list_response.json()["data"]["items"] == []
@pytest.mark.asyncio
async def test_mcp_admin_github_template_rejects_raw_token_and_only_persists_env_var_name(db_session) -> None:
app = _build_app(db_session, role="ADMIN")
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
invalid_response = await client.post(
"/mcp/admin/servers",
json={
"name": "github",
"display_name": "GitHub MCP",
"template_id": "github",
"params": {
"token_env_var_name": "GITHUB_PAT",
"access_token": "ghp_secret_should_not_be_saved",
},
},
)
valid_response = await client.post(
"/mcp/admin/servers",
json={
"name": "github",
"display_name": "GitHub MCP",
"template_id": "github",
"params": {
"token_env_var_name": "GITHUB_PAT",
},
},
)
row = (
await db_session.execute(
select(Integration).where(
Integration.tenant_id == 1,
Integration.type == "mcp_template_center",
)
)
).scalar_one()
server = row.config["servers"][0]
assert invalid_response.status_code == 400
assert invalid_response.json()["code"] == 400
assert "token" in invalid_response.json()["message"].lower()
assert valid_response.status_code == 200
assert valid_response.json()["code"] == 0
assert valid_response.json()["data"]["execution_mode"] == "local_bridge"
assert server["params"]["token_env_var_name"] == "GITHUB_PAT"
assert "access_token" not in server["params"]
@pytest.mark.asyncio
async def test_mcp_admin_filesystem_requires_allowed_paths(db_session) -> None:
app = _build_app(db_session, role="ADMIN")
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.post(
"/mcp/admin/servers",
json={
"name": "filesystem",
"display_name": "Filesystem MCP",
"template_id": "filesystem",
"params": {},
},
)
assert response.status_code == 400
assert response.json()["code"] == 400
assert "allowed_paths" in response.json()["message"]
@pytest.mark.asyncio
async def test_local_bridge_template_uses_manual_instructions_and_can_be_confirmed(db_session) -> None:
app = _build_app(db_session, role="ADMIN")
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
create_response = await client.post(
"/mcp/admin/servers",
json={
"name": "filesystem",
"display_name": "Filesystem MCP",
"template_id": "filesystem",
"params": {
"allowed_paths": ["/Users/yuuuu/Documents"],
},
},
)
created = create_response.json()["data"]
validate_response = await client.post(f"/mcp/admin/servers/{created['id']}/validate")
confirm_response = await client.post(f"/mcp/admin/servers/{created['id']}/confirm-client-apply")
assert create_response.status_code == 200
assert created["execution_mode"] == "local_bridge"
assert created["client_apply_state"] == "pending_local_apply"
assert "codex mcp add" not in created["preview"]["add_command"].lower()
assert "local bridge" in created["preview"]["add_command"].lower()
assert validate_response.status_code == 200
assert validate_response.json()["data"]["runtime_probe"]["status"] == "local_bridge_required"
assert confirm_response.status_code == 200
assert confirm_response.json()["data"]["client_apply_state"] == "user_confirmed"
@pytest.mark.asyncio
async def test_duckduckgo_template_uses_backend_direct_preview_without_extra_params(db_session, monkeypatch) -> None:
async def _fake_probe(self, *, template, params): # noqa: ANN001
_ = (self, params)
assert template.id == "duckduckgo"
return {
"status": "ready",
"checked_at": "2026-04-16T20:01:00+08:00",
"message": "PMFlow backend can directly reach DuckDuckGo search runtime.",
"tool_names": ["ddg-text-search", "ddg-news-search"],
}
monkeypatch.setattr(McpConfigService, "_probe_backend_runtime", _fake_probe)
app = _build_app(db_session, role="ADMIN")
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
create_response = await client.post(
"/mcp/admin/servers",
json={
"name": "duckduckgo",
"display_name": "DuckDuckGo MCP",
"template_id": "duckduckgo",
"params": {},
},
)
created = create_response.json()["data"]
validate_response = await client.post(f"/mcp/admin/servers/{created['id']}/validate")
assert create_response.status_code == 200
assert created["execution_mode"] == "backend_direct"
assert created["client_apply_state"] == "not_required"
assert created["params"] == {}
assert "无需本机命令" in created["preview"]["add_command"]
assert "ddg-text-search" in created["preview"]["expected_output"]
assert validate_response.status_code == 200
assert validate_response.json()["data"]["runtime_probe"]["status"] == "ready"