import asyncio import json import socket import threading import time from datetime import UTC, datetime from typing import Any from unittest.mock import patch import httpx import uvicorn from fastapi.testclient import TestClient from fastmcp import Client from fastmcp.client.transports import StreamableHttpTransport from sqlalchemy import select from memrelay.models import CurationChange, UsageProfile from tests.conftest import csrf_headers from tests.fakes import FakeBasicMemory from tests.test_vault import FakeBwRunner, configure_vault, install_fake_vault def create_mcp_token(client: TestClient, name: str, access_mode: str) -> dict[str, Any]: response = client.post( "/api/v1/tokens", json={"name": name, "access_mode": access_mode}, headers=csrf_headers(client), ) assert response.status_code == 201 return response.json() def asgi_transport(app, token: str) -> StreamableHttpTransport: # type: ignore[no-untyped-def] def factory(**kwargs) -> httpx.AsyncClient: # type: ignore[no-untyped-def] headers = dict(kwargs.get("headers") or {}) headers["Authorization"] = f"Bearer {token}" return httpx.AsyncClient( transport=httpx.ASGITransport(app=app), base_url="http://testserver", headers=headers, timeout=kwargs.get("timeout"), follow_redirects=True, ) return StreamableHttpTransport( "http://testserver/mcp", auth=token, httpx_client_factory=factory ) def result_data(result) -> Any: # type: ignore[no-untyped-def] data = result.data return data.model_dump(mode="json") if hasattr(data, "model_dump") else data def test_mcp_get_sse_stream_returns_405_not_404(initialized_client: TestClient) -> None: # 客户端 initialize 后会 GET /mcp 尝试打开服务端主动 SSE 流;无状态模式不支持, # 规范要求 405(客户端静默容忍)。若落入 SPA 兜底返回 404,客户端会把它当作 # 会话失效并在连续重试后禁用整个连接。 response = initialized_client.get("/mcp", headers={"Accept": "text/event-stream"}) assert response.status_code == 405 assert "POST" in response.headers.get("allow", "") def test_mcp_requires_token(initialized_client: TestClient) -> None: response = initialized_client.post( "/mcp", headers={"Accept": "application/json, text/event-stream"}, json={ "jsonrpc": "2.0", "id": 1, "method": "initialize", "params": { "protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "test", "version": "1"}, }, }, ) assert response.status_code == 401 def test_streamable_http_mcp_operates_over_a_real_tcp_server( initialized_client: TestClient, ) -> None: client = initialized_client token = create_mcp_token(client, "TCP MCP", "read_write") listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM) listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) listener.bind(("127.0.0.1", 0)) listener.listen(128) port = int(listener.getsockname()[1]) server = uvicorn.Server( uvicorn.Config( client.app, host="127.0.0.1", port=port, log_level="error", lifespan="off", ) ) thread = threading.Thread(target=lambda: server.run(sockets=[listener]), daemon=True) thread.start() deadline = time.monotonic() + 10 while not server.started and thread.is_alive() and time.monotonic() < deadline: time.sleep(0.01) assert server.started async def exercise() -> None: transport = StreamableHttpTransport( f"http://127.0.0.1:{port}/mcp", auth=token["token"], ) async with Client(transport) as mcp: names = {tool.name for tool in await mcp.list_tools()} assert { "capabilities_get", "curation_status", "curation_history", "curated_document_get", "curation_run", } <= names capabilities = result_data(await mcp.call_tool("capabilities_get", {})) assert capabilities["curation_enabled"] is False status = result_data(await mcp.call_tool("curation_status", {})) assert status["settings"]["enabled"] is False history = result_data(await mcp.call_tool("curation_history", {"limit": 5})) assert history == [] resources = await mcp.list_resources() assert {str(item.uri) for item in resources} >= { "memrelay://guide", "memrelay://capabilities", "memrelay://projects", } try: asyncio.run(exercise()) finally: server.should_exit = True thread.join(timeout=10) listener.close() assert not thread.is_alive() def test_mcp_tools_resources_and_scope_enforcement(initialized_client: TestClient) -> None: client = initialized_client client.app.state.basic_memory = FakeBasicMemory() project = client.post( "/api/v1/projects", json={"name": "MCP Project"}, headers=csrf_headers(client), ).json() stored_file = client.app.state.settings.files_dir / "mcp-metadata.txt" stored_file.write_text("MCP metadata", encoding="utf-8") client.post("/api/v1/files/scan", headers=csrf_headers(client)) file_record = client.get( "/api/v1/files", params={"query": "mcp-metadata.txt"} ).json()[0] client.patch( f"/api/v1/files/{file_record['id']}", json={ "description": "Original MCP description", "version": "1.0", "purpose": "reference", "tags": ["mcp", "stable"], "project_id": project["id"], }, headers=csrf_headers(client), ) read_token = create_mcp_token(client, "Reader", "read_only") write_token = create_mcp_token(client, "Writer", "read_write") verified = asyncio.run(client.app.state.mcp_server.auth.verify_token(read_token["token"])) assert verified is not None assert verified.scopes == ["read"] authenticated_probe = client.post( "/mcp", headers={ "Accept": "application/json, text/event-stream", "Authorization": f"Bearer {read_token['token']}", }, json={ "jsonrpc": "2.0", "id": 1, "method": "initialize", "params": { "protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "test", "version": "1"}, }, }, ) assert authenticated_probe.status_code != 401, authenticated_probe.text async def exercise() -> None: async with Client(asgi_transport(client.app, read_token["token"])) as reader: names = {tool.name for tool in await reader.list_tools()} expected_read = { "capabilities_get", "project_resolve", "project_list", "project_export_prepare", "context_get", "memory_list", "memory_search", "memory_get", "checkpoint_get", "secret_capabilities", "secret_list", "secret_item_get", "secret_search", "secret_get", "secret_totp", "file_search", "file_list", "file_get", "file_upload_status", "curation_status", "curation_history", "curated_document_list", "curated_document_get", "curated_document_history", "curated_document_diff", "curation_settings_get", "curation_schedule_list", "curation_schedule_preview", "curation_model_connection_status", "curation_model_list", "usage_profile_list", "git_connection_get", "git_mode_migration_preview", "memory_repository_list", "memory_repository_status", "memory_repository_history", "memory_repository_diff", "memory_version_get", "memory_repository_remote_inspect", "dashboard_get", "system_settings_get", "token_list", } write_tools = { "project_create", "project_resolve_or_create", "project_update", "project_archive", "project_delete", "memory_save", "memory_archive", "memory_delete", "checkpoint_save", "checkpoint_delete", "secret_sync", "secret_resolve", "secret_save", "secret_delete", "file_directory_create", "file_upload_prepare", "file_metadata_update", "file_move", "file_delete", "file_scan", "curation_run", "curation_source_submit", "curated_document_update", "curated_document_revert", "curation_cancel", "curation_retry", "curation_settings_update", "curation_schedule_save", "curation_schedule_delete", "curation_schedule_reset_defaults", "curation_model_connection_save", "curation_model_connection_test", "usage_profile_save", "usage_profile_token_bind", "usage_profile_delete", "git_connection_save", "git_mode_migration_execute", "git_connection_test", "git_connection_delete", "memory_repository_create", "memory_repository_sync", "memory_version_restore", "memory_snapshot_restore", "memory_remote_import", "memory_remote_bootstrap", "memory_repository_unbind", "memory_repository_archive_purge", "system_settings_update", "token_create", "token_reveal", "token_revoke", "token_delete", } assert expected_read <= names assert names.isdisjoint(write_tools) capabilities = result_data(await reader.call_tool("capabilities_get", {})) assert capabilities["memory"] is True assert capabilities["vault"] is False assert capabilities["semantic_search"]["status"] == "available" dashboard = result_data(await reader.call_tool("dashboard_get", {})) assert "curation" in dashboard assert "memory_git" in dashboard assert "database" in dashboard resolved = result_data( await reader.call_tool("project_resolve", {"name": "MCP Project"}) ) assert resolved["id"] == project["id"] read_only_directory = "D:/ReadOnly/MCP Project" resolved_with_directory = result_data( await reader.call_tool( "project_resolve", {"name": "MCP Project", "directory": read_only_directory}, ) ) assert resolved_with_directory["id"] == project["id"] assert read_only_directory.casefold() not in resolved_with_directory["aliases"] try: await reader.call_tool( "memory_save", { "request_id": "denied", "scope": "global", "memory_type": "rule", "title": "Denied", "content": "Must not write.", }, ) except Exception as exc: message = str(exc).casefold() assert "authorization" in message or "scope" in message or "unknown tool" in message else: raise AssertionError("Read-only token unexpectedly called a write tool") guide = await reader.read_resource("memrelay://guide") assert "不把猜测" in guide[0].text assert "project_resolve_or_create" in guide[0].text assert "AI 自动整理" not in guide[0].text assert "记忆版本历史" not in guide[0].text status_with_timestamp = client.app.state.curation.model_status() status_with_timestamp["last_check_at"] = datetime.now(UTC) with patch.object( client.app.state.curation, "model_status", return_value=status_with_timestamp, ): capabilities_resource = await reader.read_resource("memrelay://capabilities") resource_data = json.loads(capabilities_resource[0].text) assert resource_data["semantic_search"]["status"] == "available" assert resource_data["tools"] == capabilities["tools"] assert resource_data["curation"]["model"]["last_check_at"].endswith("+00:00") async with Client(asgi_transport(client.app, write_token["token"])) as writer: writer_names = {tool.name for tool in await writer.list_tools()} assert write_tools <= writer_names saved = result_data( await writer.call_tool( "memory_save", { "request_id": "mcp-save-1", "scope": "project", "project_id": project["id"], "memory_type": "decision", "title": "MCP contract", "content": "Use Streamable HTTP.", }, ) ) assert saved["revision"] == 1 context = result_data( await writer.call_tool( "context_get", {"project_id": project["id"], "query": "HTTP"} ) ) assert saved["id"] in context["included_memory_ids"] upload = result_data( await writer.call_tool("file_upload_prepare", {"path": "mcp/file.txt", "size": 4}) ) assert upload["upload_url"].startswith("http://testserver/") async with httpx.AsyncClient( transport=httpx.ASGITransport(app=client.app), base_url="http://testserver", ) as uploader: uploaded = await uploader.put(upload["upload_url"], content=b"mcp!") assert uploaded.status_code == 200, uploaded.text partial_metadata = result_data( await writer.call_tool( "file_metadata_update", { "file_id": file_record["id"], "description": "Updated through MCP", }, ) ) assert partial_metadata["description"] == "Updated through MCP" assert partial_metadata["version"] == "1.0" assert partial_metadata["purpose"] == "reference" assert partial_metadata["tags"] == ["mcp", "stable"] assert partial_metadata["project_id"] == project["id"] cleared_metadata = result_data( await writer.call_tool( "file_metadata_update", { "file_id": file_record["id"], "clear_version": True, "clear_project": True, "tags": [], }, ) ) assert cleared_metadata["version"] is None assert cleared_metadata["project_id"] is None assert cleared_metadata["purpose"] == "reference" assert cleared_metadata["tags"] == [] created_project = result_data( await writer.call_tool( "project_create", { "name": "Created by MCP", "git_remote": "https://example.com/team/repo.git", "aliases": ["C:/code/repo"], }, ) ) updated_project = result_data( await writer.call_tool( "project_update", {"project_id": created_project["id"], "name": "Updated by MCP"}, ) ) assert updated_project["name"] == "Updated by MCP" resolved_or_created = result_data( await writer.call_tool( "project_resolve_or_create", { "git_remote": "git@example.test:team/created-from-workflow.git", "directory": "E:/Projects/CreatedFromWorkflow", }, ) ) assert resolved_or_created["created"] is True assert resolved_or_created["project"]["name"] == "created-from-workflow" assert "e:/projects/createdfromworkflow" in resolved_or_created["project"]["aliases"] resolved_again = result_data( await writer.call_tool( "project_resolve_or_create", { "name": "A different local folder name", "git_remote": "https://example.test/team/created-from-workflow.git", "directory": "F:/Projects/CreatedFromWorkflow", }, ) ) assert resolved_again["created"] is False assert resolved_again["project"]["id"] == resolved_or_created["project"]["id"] assert "f:/projects/createdfromworkflow" in resolved_again["project"]["aliases"] checkpoint = result_data( await writer.call_tool( "checkpoint_save", { "project_id": project["id"], "request_id": "mcp-checkpoint-1", "content": "## Completed\n\nVerified the complete MCP workflow.", }, ) ) export = result_data( await writer.call_tool("project_export_prepare", {"project_id": project["id"]}) ) assert export["url"].startswith("http://testserver/") assert "aidocs/" in export["powershell_command"] listed_memories = result_data( await writer.call_tool("memory_list", {"project_id": project["id"]}) ) assert saved["id"] in {item["id"] for item in listed_memories} # Checkpoints leave the pending bucket and get their own lifecycle filter. assert checkpoint["id"] not in {item["id"] for item in listed_memories} listed_checkpoints = result_data( await writer.call_tool( "memory_list", {"project_id": project["id"], "lifecycle": "checkpoint"}, ) ) assert checkpoint["id"] in {item["id"] for item in listed_checkpoints} assert result_data(await writer.call_tool("dashboard_get", {}))["checkpoints"] == 1 await writer.call_tool("checkpoint_delete", {"checkpoint_id": checkpoint["id"]}) generated_token = result_data( await writer.call_tool( "token_create", {"name": "Generated token", "access_mode": "read_only"} ) ) assert generated_token["token"].startswith("mcp_") profile = result_data( await writer.call_tool( "usage_profile_save", {"name": "MCP profile", "description": "Bound through MCP"}, ) ) bound = result_data( await writer.call_tool( "usage_profile_token_bind", {"profile_id": profile["id"], "token_ids": [generated_token["id"]]}, ) ) assert bound["token_count"] == 1 schedules = result_data(await writer.call_tool("curation_schedule_reset_defaults", {})) assert {item["trigger_type"] for item in schedules} == { "workspace_quiet", "workspace_fallback", "global_curation", } await writer.call_tool( "usage_profile_token_bind", {"profile_id": profile["id"], "token_ids": []}, ) await writer.call_tool("usage_profile_delete", {"profile_id": profile["id"]}) await writer.call_tool("token_delete", {"token_id": generated_token["id"]}) await writer.call_tool( "project_archive", {"project_id": created_project["id"], "archived": True} ) await writer.call_tool("project_delete", {"project_id": created_project["id"]}) asyncio.run(exercise()) with client.app.state.session_factory() as db: shared = db.scalar(select(UsageProfile).where(UsageProfile.is_shared.is_(True))) agent_changes = db.scalars( select(CurationChange).where( CurationChange.project_id == project["id"], CurationChange.origin == "agent", ) ).all() assert shared is not None and agent_changes assert {item.usage_profile_id for item in agent_changes} == {shared.id} def test_mcp_password_vault_management(initialized_client: TestClient) -> None: client = initialized_client runner = FakeBwRunner() install_fake_vault(client, runner) configure_vault(client) token = create_mcp_token(client, "Vault manager", "read_write") async def exercise() -> None: async with Client(asgi_transport(client.app, token["token"])) as mcp: listed = result_data(await mcp.call_tool("secret_list", {})) assert len(listed["results"]) == 2 assert "git-password" not in str(listed) detail = result_data(await mcp.call_tool("secret_item_get", {"item_id": "item-1"})) assert detail["password"] == "git-password" saved = result_data( await mcp.call_tool( "secret_save", { "name": "AI Created Credential", "username": "automation", "password": "generated-password", "uris": ["https://automation.example.com"], "fields": [ {"name": "api_token", "value": "generated-token", "hidden": True} ], "lookup_key": "automation-service", }, ) ) assert saved["created"] is True assert saved["item"]["password"] == "generated-password" deleted = result_data( await mcp.call_tool("secret_delete", {"item_id": saved["item"]["id"]}) ) assert deleted == {"deleted": True} asyncio.run(exercise()) def test_all_profiles_token_includes_profiles_created_later( initialized_client: TestClient, ) -> None: client = initialized_client client.app.state.basic_memory = FakeBasicMemory() token_response = client.post( "/api/v1/tokens", json={"name": "All profiles", "access_mode": "read_write", "all_profiles": True}, headers=csrf_headers(client), ) assert token_response.status_code == 201 token = token_response.json() future_profile = client.post( "/api/v1/curation/profiles", json={"name": "Created after token"}, headers=csrf_headers(client), ).json() future_memory = client.post( "/api/v1/memories", json={ "request_id": "future-profile-memory", "scope": "global", "usage_profile_id": future_profile["id"], "memory_type": "preference", "title": "Future profile preference", "content": "This profile was created after the MCP token.", "tags": [], }, headers=csrf_headers(client), ).json() async def exercise() -> None: async with Client(asgi_transport(client.app, token["token"])) as mcp: listed = result_data(await mcp.call_tool("memory_list", {"scope": "global"})) assert future_memory["id"] in {item["id"] for item in listed} saved = result_data( await mcp.call_tool( "memory_save", { "request_id": "all-profile-targeted-write", "scope": "global", "usage_profile_id": future_profile["id"], "memory_type": "task_list", "title": "Future profile tasks", "content": "- [ ] Verify all-profile access", }, ) ) assert saved["usage_profile_id"] == future_profile["id"] assert saved["memory_type"] == "task_list" asyncio.run(exercise())