import asyncio import unittest from contextlib import asynccontextmanager from unittest.mock import patch import httpx from starlette.applications import Starlette from starlette.responses import JSONResponse from nocobase_crm_mcp import server as server_module from nocobase_crm_mcp.nb_client import NbAuthenticationError, NbCommandError from nocobase_crm_mcp.server import ( DEFAULT_HTTP_HOST, DEFAULT_HTTP_PATH, DEFAULT_HTTP_PORT, BearerTokenAuthMiddleware, FastMCP, _make_write_services, _read_nocobase_environment, create_http_app, create_server, run_http_server, ) async def _ok_app(scope, receive, send): await send({"type": "http.response.start", "status": 200, "headers": []}) await send({"type": "http.response.body", "body": b"ok"}) def run_asgi_request(app, *, authorization=None, method="POST", path="/mcp"): events = [] headers = [] if authorization is not None: headers.append((b"authorization", authorization.encode("latin1"))) async def receive(): return {"type": "http.request", "body": b"", "more_body": False} async def send(message): events.append(message) scope = {"type": "http", "method": method, "path": path, "headers": headers} asyncio.run(app(scope, receive, send)) return events class FakeCrmService: def crm_health(self): return {"ok": True} def crm_schema(self, include_fields=False): return {"include_fields": include_fields} def crm_overview(self, **kwargs): return {"totals": {"customers": 1}} def crm_config_template(self): return {"collections": {}} def crm_filter_options(self, **kwargs): return {"customer_status": []} def resolve_owner(self, **kwargs): return {"results": []} def list_customers(self, **kwargs): return {"results": []} def get_customer(self, **kwargs): return {"customer": None} def list_contacts(self, **kwargs): return {"results": []} def list_followups(self, **kwargs): return {"results": []} class ErrorCrmService(FakeCrmService): def list_customers(self, **kwargs): raise NbCommandError("nb command failed with exit code 1: denied") class AuthenticationErrorCrmService(FakeCrmService): def list_customers(self, **kwargs): raise NbAuthenticationError("Request failed with status 401") class MissingNbCrmService(FakeCrmService): def crm_health(self): raise FileNotFoundError(2, "No such file or directory", "nb") class FakeProjectService: def create_project(self, **kwargs): return {"project_id": 1} def update_project(self, project_id, **kwargs): return {"project_id": project_id} class FakeUploadEndpoint: async def __call__(self, request): return JSONResponse({"uploaded": True}) async def run_asgi_lifespan(app, on_started): messages = asyncio.Queue() await messages.put({"type": "lifespan.startup"}) async def receive(): return await messages.get() async def send(message): if message["type"] == "lifespan.startup.complete": on_started() await messages.put({"type": "lifespan.shutdown"}) await app({"type": "lifespan"}, receive, send) class ServerTests(unittest.TestCase): def test_bearer_token_middleware_rejects_missing_or_wrong_token(self) -> None: app = BearerTokenAuthMiddleware(_ok_app, "secret") self.assertEqual(run_asgi_request(app)[0]["status"], 401) self.assertEqual(run_asgi_request(app, authorization="Bearer wrong")[0]["status"], 401) def test_bearer_token_middleware_allows_matching_token(self) -> None: app = BearerTokenAuthMiddleware(_ok_app, "secret") events = run_asgi_request(app, authorization="Bearer secret") self.assertEqual(events[0]["status"], 200) self.assertEqual(events[1]["body"], b"ok") def test_bearer_token_middleware_allows_options_preflight_without_token(self) -> None: app = BearerTokenAuthMiddleware(_ok_app, "secret") events = run_asgi_request(app, method="OPTIONS") self.assertEqual(events[0]["status"], 204) def test_readonly_server_exposes_crm_tools_only(self) -> None: if FastMCP is None: self.skipTest("MCP SDK is not installed") server = create_server(crm_service=FakeCrmService(), readonly=True) tools = asyncio.run(server.list_tools()) self.assertEqual( {tool.name for tool in tools}, { "crm_health", "crm_schema", "crm_overview", "crm_filter_options", "resolve_owner", "list_teams", "resolve_team", "get_team", "team_crm_overview", "list_customers", "get_customer", "list_contacts", "list_followups", "list_projects", "get_project", "get_project_timeline", "list_stale_customers", }, ) def test_write_server_exposes_fixed_project_tools(self) -> None: if FastMCP is None: self.skipTest("MCP SDK is not installed") server = create_server( crm_service=FakeCrmService(), project_service=FakeProjectService(), readonly=False, ) names = {tool.name for tool in asyncio.run(server.list_tools())} self.assertTrue({"create_project", "update_project"}.issubset(names)) self.assertNotIn("destroy_project", names) def test_tool_schemas_expose_raw_opt_in_parameters(self) -> None: if FastMCP is None: self.skipTest("MCP SDK is not installed") server = create_server(crm_service=FakeCrmService(), readonly=True) tools = {tool.name: tool for tool in asyncio.run(server.list_tools())} self.assertIn("include_raw", tools["list_customers"].inputSchema["properties"]) self.assertIn("include_raw", tools["list_contacts"].inputSchema["properties"]) self.assertIn("include_raw", tools["list_followups"].inputSchema["properties"]) self.assertIn("include_raw", tools["get_customer"].inputSchema["properties"]) self.assertIn("include_details", tools["list_contacts"].inputSchema["properties"]) def test_raw_rows_require_explicit_local_policy_opt_in(self) -> None: if FastMCP is None: self.skipTest("MCP SDK is not installed") server = create_server(crm_service=FakeCrmService(), readonly=True) with patch.dict("os.environ", {}, clear=True): _content, blocked = asyncio.run(server.call_tool("list_customers", {"include_raw": True})) with patch.dict("os.environ", {"NOCOBASE_MCP_ENABLE_RAW_DATA": "1"}, clear=True): _content, allowed = asyncio.run(server.call_tool("list_customers", {"include_raw": True})) self.assertEqual(blocked["error"]["code"], "raw_data_disabled") self.assertEqual(allowed, {"results": []}) def test_tools_return_structured_error_for_nb_failures(self) -> None: if FastMCP is None: self.skipTest("MCP SDK is not installed") server = create_server(crm_service=ErrorCrmService(), readonly=True) _content, structured = asyncio.run(server.call_tool("list_customers", {})) self.assertEqual(structured["error"]["code"], "nb_command_failed") self.assertIn("denied", structured["error"]["message"]) self.assertFalse(structured["error"]["retryable"]) def test_tools_return_specific_error_for_missing_or_expired_user_login(self) -> None: if FastMCP is None: self.skipTest("MCP SDK is not installed") server = create_server(crm_service=AuthenticationErrorCrmService(), readonly=True) _content, structured = asyncio.run(server.call_tool("list_customers", {})) self.assertEqual(structured["error"]["code"], "nb_auth_failed") self.assertIn("secure connect-key flow", structured["error"]["hint"]) self.assertIn("private local prompt", structured["error"]["hint"]) self.assertFalse(structured["error"]["retryable"]) def test_tools_return_specific_error_when_nb_is_not_found(self) -> None: if FastMCP is None: self.skipTest("MCP SDK is not installed") server = create_server(crm_service=MissingNbCrmService(), readonly=True) _content, structured = asyncio.run(server.call_tool("crm_health", {})) self.assertEqual(structured["error"]["code"], "nb_not_found") self.assertIn("NOCOBASE_MCP_NB_BIN", structured["error"]["hint"]) self.assertFalse(structured["error"]["retryable"]) def test_make_service_uses_nb_bin_from_environment(self) -> None: with ( patch.dict("os.environ", {"NOCOBASE_MCP_NB_BIN": "/opt/node/bin/nb"}), patch.object(server_module, "NbClient") as nb_client, patch.object( server_module, "CrmService", side_effect=lambda config, client: {"config": config, "client": client} ), ): service = server_module._make_service() self.assertIs(service["client"], nb_client.return_value) nb_client.assert_called_once_with(env_name="nb-mcp", data_source="main", nb_bin="/opt/node/bin/nb") def test_upload_route_requires_the_same_bearer_token(self) -> None: if FastMCP is None: self.skipTest("MCP SDK is not installed") server = create_server(crm_service=FakeCrmService(), readonly=True) app = create_http_app(server, "secret", upload_endpoint=FakeUploadEndpoint()) self.assertEqual(run_asgi_request(app, path="/uploads")[0]["status"], 401) def test_upload_route_is_not_exposed_without_an_endpoint(self) -> None: if FastMCP is None: self.skipTest("MCP SDK is not installed") server = create_server(crm_service=FakeCrmService(), readonly=True) app = create_http_app(server, "secret") async def request_upload() -> httpx.Response: async with httpx.AsyncClient( transport=httpx.ASGITransport(app=app), base_url="http://testserver" ) as client: return await client.post("/uploads", headers={"Authorization": "Bearer secret"}) self.assertEqual(asyncio.run(request_upload()).status_code, 404) def test_upload_route_accepts_the_matching_bearer_token(self) -> None: if FastMCP is None: self.skipTest("MCP SDK is not installed") server = create_server(crm_service=FakeCrmService(), readonly=True) async def upload_endpoint(request): return JSONResponse({"uploaded": True}) app = create_http_app(server, "secret", upload_endpoint=upload_endpoint) async def request_upload() -> httpx.Response: async with httpx.AsyncClient( transport=httpx.ASGITransport(app=app), base_url="http://testserver" ) as client: return await client.post("/uploads", headers={"Authorization": "Bearer secret"}) response = asyncio.run(request_upload()) self.assertEqual(response.status_code, 200) self.assertEqual(response.json(), {"uploaded": True}) def test_upload_route_dispatches_callable_endpoint_objects_as_request_handlers(self) -> None: if FastMCP is None: self.skipTest("MCP SDK is not installed") server = create_server(crm_service=FakeCrmService(), readonly=True) app = create_http_app(server, "secret", upload_endpoint=FakeUploadEndpoint()) async def request_upload() -> httpx.Response: async with httpx.AsyncClient( transport=httpx.ASGITransport(app=app), base_url="http://testserver" ) as client: return await client.post("/uploads", headers={"Authorization": "Bearer secret"}) response = asyncio.run(request_upload()) self.assertEqual(response.status_code, 200) self.assertEqual(response.json(), {"uploaded": True}) def test_upload_route_preserves_mcp_application_lifespan(self) -> None: lifecycle = {"started": False, "stopped": False} @asynccontextmanager async def lifespan(_app): lifecycle["started"] = True try: yield finally: lifecycle["stopped"] = True class FakeServer: def streamable_http_app(self): return Starlette(lifespan=lifespan) app = create_http_app(FakeServer(), "secret", upload_endpoint=FakeUploadEndpoint()) asyncio.run(run_asgi_lifespan(app, lambda: self.assertTrue(lifecycle["started"]))) self.assertTrue(lifecycle["stopped"]) def test_nocobase_environment_prefers_canonical_shared_credentials(self) -> None: with patch.dict( "os.environ", { "NOCOBASE_MCP_API_BASE_URL": "https://nb.example/api/", "NOCOBASE_MCP_API_TOKEN": "shared-key", "NOCOBASE_MCP_UPLOAD_MAX_BYTES": "123", }, clear=True, ): self.assertEqual( _read_nocobase_environment(), ("https://nb.example/api", "shared-key", 123), ) def test_nocobase_environment_defaults_to_the_users_saved_cli_login(self) -> None: with patch.dict("os.environ", {}, clear=True): self.assertEqual(_read_nocobase_environment(), (None, None, 26214400)) def test_nocobase_environment_accepts_matching_legacy_credentials(self) -> None: with patch.dict( "os.environ", { "NOCOBASE_MCP_UPLOAD_API_BASE_URL": "https://legacy.example/api", "NOCOBASE_MCP_UPLOAD_JWT": "legacy-key", }, clear=True, ): self.assertEqual( _read_nocobase_environment(), ("https://legacy.example/api", "legacy-key", 26214400), ) def test_nocobase_environment_rejects_incomplete_or_conflicting_credentials(self) -> None: cases = ( {"NOCOBASE_MCP_API_BASE_URL": "https://nb.example/api"}, { "NOCOBASE_MCP_API_BASE_URL": "https://nb.example/api", "NOCOBASE_MCP_API_TOKEN": "canonical", "NOCOBASE_MCP_UPLOAD_API_BASE_URL": "https://other.example/api", "NOCOBASE_MCP_UPLOAD_JWT": "legacy", }, ) for values in cases: with self.subTest(values=set(values)), patch.dict("os.environ", values, clear=True): with self.assertRaises(RuntimeError): _read_nocobase_environment() def test_write_services_use_the_selected_nb_environment_credentials_by_default(self) -> None: with ( patch.object(server_module, "NbClient") as nb_client, patch.object(server_module, "CrmService") as crm_service, patch.object(server_module, "ProjectService") as project_service, patch.object(server_module, "NocoBaseHttpClient") as http_client, ): result = _make_write_services() nb_client.assert_called_once_with( env_name="nb-mcp", data_source="main", nb_bin="nb", api_base_url=None, api_token=None, ) http_client.assert_called_once_with(credential_provider=nb_client.return_value.environment_credentials) crm_service.assert_called_once() project_service.assert_called_once() self.assertEqual(result, (crm_service.return_value, project_service.return_value, http_client.return_value)) def test_http_write_mode_uses_saved_cli_login_without_shared_api_credentials(self) -> None: with patch.dict("os.environ", {"NOCOBASE_MCP_TOKEN": "secret"}, clear=True): with ( patch.object(server_module, "_make_write_services") as make_services, patch.object(server_module, "create_server") as create_server_mock, patch.object(server_module, "UploadEndpoint"), patch.object(server_module, "create_http_app", return_value="app"), patch("uvicorn.run") as uvicorn_run, ): make_services.return_value = ("crm", "projects", "http") run_http_server( config_path=None, host=DEFAULT_HTTP_HOST, port=DEFAULT_HTTP_PORT, token_env="NOCOBASE_MCP_TOKEN", streamable_http_path=DEFAULT_HTTP_PATH, allowed_hosts=None, allowed_origins=None, ) make_services.assert_called_once_with(None, api_base_url=None, api_token=None) create_server_mock.assert_called_once() uvicorn_run.assert_called_once_with( "app", host=DEFAULT_HTTP_HOST, port=DEFAULT_HTTP_PORT, log_level="info", ) def test_http_upload_route_is_disabled_by_default(self) -> None: with patch.dict("os.environ", {"NOCOBASE_MCP_TOKEN": "secret"}, clear=True): with ( patch.object(server_module, "_make_write_services") as make_services, patch.object(server_module, "create_server") as create_server_mock, patch.object(server_module, "UploadEndpoint") as upload_endpoint, patch.object(server_module, "create_http_app", return_value="app") as create_http_app_mock, patch("uvicorn.run"), ): make_services.return_value = ("crm", "projects", "http") run_http_server( config_path=None, host=DEFAULT_HTTP_HOST, port=DEFAULT_HTTP_PORT, token_env="NOCOBASE_MCP_TOKEN", streamable_http_path=DEFAULT_HTTP_PATH, allowed_hosts=None, allowed_origins=None, ) upload_endpoint.assert_not_called() create_http_app_mock.assert_called_once_with(create_server_mock.return_value, "secret", upload_endpoint=None) def test_http_upload_route_requires_explicit_local_opt_in(self) -> None: with patch.dict( "os.environ", {"NOCOBASE_MCP_TOKEN": "secret", "NOCOBASE_MCP_ENABLE_UPLOADS": "1"}, clear=True, ): with ( patch.object(server_module, "_make_write_services") as make_services, patch.object(server_module, "create_server") as create_server_mock, patch.object(server_module, "UploadEndpoint") as upload_endpoint, patch.object(server_module, "create_http_app", return_value="app") as create_http_app_mock, patch("uvicorn.run"), ): make_services.return_value = ("crm", "projects", "http") run_http_server( config_path=None, host=DEFAULT_HTTP_HOST, port=DEFAULT_HTTP_PORT, token_env="NOCOBASE_MCP_TOKEN", streamable_http_path=DEFAULT_HTTP_PATH, allowed_hosts=None, allowed_origins=None, ) upload_endpoint.assert_called_once() create_http_app_mock.assert_called_once_with( create_server_mock.return_value, "secret", upload_endpoint=upload_endpoint.return_value, ) def test_config_template_tool_is_opt_in(self) -> None: if FastMCP is None: self.skipTest("MCP SDK is not installed") with patch.dict("os.environ", {"NOCOBASE_MCP_ENABLE_CONFIG_TOOLS": "1"}): server = create_server(crm_service=FakeCrmService(), readonly=True) tools = asyncio.run(server.list_tools()) self.assertIn("crm_config_template", {tool.name for tool in tools}) def test_create_http_app_requires_bearer_token(self) -> None: if FastMCP is None: self.skipTest("MCP SDK is not installed") server = create_server(crm_service=FakeCrmService(), readonly=True) with self.assertRaises(ValueError): create_http_app(server, "") if __name__ == "__main__": unittest.main()