"""Safety checks as tests (plan §13). * a read-only API key must fail cleanly on every mutating tool * failed workflows leave no partial state (no comment, no finished step) * the API key never appears in any tool result """ import json import pytest from mcp.client import Client from fakes import make_template from mcp_server.tools import register_tools TOKEN_SAFE_MESSAGE = "labvoice-secret-key-do-not-leak" MUTATING_TOOLS = { "complete_next_protocol_step": {"experiment_id": 123}, "complete_protocol_step": {"experiment_id": 123, "step_id": 9}, "record_protocol_observation": {"experiment_id": 123, "comment": "note"}, "create_experiment_from_template": {"template_id": 7}, "adjust_inventory": {"amount": 5.0, "unit": "mL", "container_id": 31}, } @pytest.fixture async def mcp_client(patch_build_services, client): from mcp.server.fastmcp import FastMCP patch_build_services() client.templates[7] = make_template() mcp = FastMCP("labvoice-safety") register_tools(mcp) async with Client(mcp, raise_exceptions=False) as c: yield c def _state_fingerprint(client): experiment = client.experiments[123] return { "steps": [(s.id, s.finished) for s in experiment.steps], "comments": [c.body for c in experiment.comments], "containers": [ (i.id, c.sub_id, c.qty_stored) for i in client.items.values() for c in i.containers ], } @pytest.mark.parametrize("tool", sorted(MUTATING_TOOLS)) async def test_read_only_key_fails_cleanly_on_every_mutating_tool( mcp_client, client, tool ): """Plan §13: read-only key ⇒ every mutation fails with a clear message.""" client.read_only = True before = _state_fingerprint(client) result = await mcp_client.call_tool(tool, MUTATING_TOOLS[tool]) payload = None structured = getattr(result, "structured_content", None) texts = " ".join(getattr(b, "text", "") for b in getattr(result, "content", [])) if structured is not None: payload = structured assert getattr(result, "is_error", False) or ( payload is not None and payload.get("ok") is False ) or "permission" in texts.lower() or "read-only" in texts.lower(), ( f"{tool} must not silently succeed with a read-only key" ) assert _state_fingerprint(client) == before, f"{tool} must not mutate anything" async def test_aborted_workflow_leaves_no_partial_state(mcp_client, client, resolver_stub): """Unresolvable resource ⇒ no finished step, no comment, no stock change.""" resolver_stub.mappings.clear() before = _state_fingerprint(client) result = await mcp_client.call_tool( "complete_next_protocol_step", {"experiment_id": 123} ) texts = " ".join(getattr(b, "text", "") for b in getattr(result, "content", [])) assert result.is_error or "clarification" in texts.lower() or "not know" in texts.lower() assert _state_fingerprint(client) == before async def test_error_results_never_contain_the_api_key(mcp_client, client, resolver_stub): client.read_only = True resolver_stub.mappings.clear() for tool, args in MUTATING_TOOLS.items(): result = await mcp_client.call_tool(tool, dict(args)) texts = " ".join(getattr(b, "text", "") for b in getattr(result, "content", [])) structured = getattr(result, "structured_content", None) blob = texts + json.dumps(structured, default=str) assert TOKEN_SAFE_MESSAGE not in blob