522 lines
20 KiB
Python
522 lines
20 KiB
Python
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()
|