|
| 1 | +"""Phase 4 integration tests for the MCP server. |
| 2 | +
|
| 3 | +Exercises the MCP tool surface against the FastAPI route surface on the |
| 4 | +same isolated specs root, proving the two stay in sync. Covers spec tasks |
| 5 | +4.1 (``gui_list_specs`` parity), 4.2 (``gui_get_spec`` round-trip), and |
| 6 | +adds a bonus round-trip through ``gui_set_spec_status`` (Phase 3) to lock |
| 7 | +in the read-after-write contract across both surfaces. |
| 8 | +
|
| 9 | +These tests use the in-process MCP application directly via |
| 10 | +``create_server().call_tool(...)`` — the same dispatch path the MCP SDK |
| 11 | +hits — rather than spawning a stdio subprocess. The spec's "start the |
| 12 | +MCP server" wording was aspirational; the value is parity with the |
| 13 | +routes, which the in-process path proves at lower cost. |
| 14 | +""" |
| 15 | + |
| 16 | +from __future__ import annotations |
| 17 | + |
| 18 | +from pathlib import Path |
| 19 | + |
| 20 | +import pytest |
| 21 | +from attune_gui.mcp.server import create_server |
| 22 | +from fastapi.testclient import TestClient |
| 23 | + |
| 24 | +# --------------------------------------------------------------------------- |
| 25 | +# Fixtures |
| 26 | +# --------------------------------------------------------------------------- |
| 27 | + |
| 28 | + |
| 29 | +@pytest.fixture |
| 30 | +def shared_specs_root(tmp_path, monkeypatch) -> Path: |
| 31 | + """Isolated specs root with two fixture specs, visible to both surfaces.""" |
| 32 | + root = tmp_path / "specs" |
| 33 | + |
| 34 | + alpha = root / "alpha" |
| 35 | + alpha.mkdir(parents=True) |
| 36 | + (alpha / "requirements.md").write_text( |
| 37 | + "# alpha\n\n**Status**: approved\n\nReq body.\n", encoding="utf-8" |
| 38 | + ) |
| 39 | + (alpha / "design.md").write_text("# design alpha\n\n**Status**: draft\n", encoding="utf-8") |
| 40 | + |
| 41 | + beta = root / "beta" |
| 42 | + beta.mkdir(parents=True) |
| 43 | + (beta / "requirements.md").write_text("# beta\n\n**Status**: draft\n", encoding="utf-8") |
| 44 | + |
| 45 | + monkeypatch.setenv("ATTUNE_SPECS_ROOT", str(root)) |
| 46 | + return root |
| 47 | + |
| 48 | + |
| 49 | +@pytest.fixture |
| 50 | +def mcp_app(): |
| 51 | + return create_server() |
| 52 | + |
| 53 | + |
| 54 | +# --------------------------------------------------------------------------- |
| 55 | +# 4.1 — gui_list_specs parity with GET /api/cowork/specs |
| 56 | +# --------------------------------------------------------------------------- |
| 57 | + |
| 58 | + |
| 59 | +@pytest.mark.asyncio |
| 60 | +async def test_gui_list_specs_matches_fastapi_route( |
| 61 | + mcp_app, client: TestClient, shared_specs_root |
| 62 | +) -> None: |
| 63 | + """Same workspace must yield the same spec list on both surfaces.""" |
| 64 | + route_resp = client.get("/api/cowork/specs") |
| 65 | + assert route_resp.status_code == 200 |
| 66 | + route_body = route_resp.json() |
| 67 | + |
| 68 | + mcp_resp = await mcp_app.call_tool("gui_list_specs", {}) |
| 69 | + assert mcp_resp["success"] is True |
| 70 | + |
| 71 | + # The MCP surface uses {"specs": [...], "specs_roots": [...]} — the |
| 72 | + # FastAPI route additionally returns a legacy "specs_root" string. |
| 73 | + # Compare the field that both surfaces share verbatim. |
| 74 | + assert mcp_resp["specs"] == route_body["specs"] |
| 75 | + assert mcp_resp["specs_roots"] == route_body["specs_roots"] |
| 76 | + |
| 77 | + |
| 78 | +# --------------------------------------------------------------------------- |
| 79 | +# 4.2 — gui_get_spec content parity with the on-disk fixture |
| 80 | +# --------------------------------------------------------------------------- |
| 81 | + |
| 82 | + |
| 83 | +@pytest.mark.asyncio |
| 84 | +async def test_gui_get_spec_returns_disk_truth( |
| 85 | + mcp_app, client: TestClient, shared_specs_root |
| 86 | +) -> None: |
| 87 | + """gui_get_spec content must match what's on disk, and the route's |
| 88 | + listing must reference the same feature with the same status.""" |
| 89 | + mcp_resp = await mcp_app.call_tool("gui_get_spec", {"feature": "alpha"}) |
| 90 | + assert mcp_resp["success"] is True |
| 91 | + assert mcp_resp["feature"] == "alpha" |
| 92 | + |
| 93 | + # The phase contents the tool returns must match the file bytes on disk. |
| 94 | + for phase_name, phase in mcp_resp["phases"].items(): |
| 95 | + expected = (shared_specs_root / "alpha" / phase["file"]).read_text(encoding="utf-8") |
| 96 | + assert phase["content"] == expected, f"content drift on phase={phase_name}" |
| 97 | + |
| 98 | + # And the FastAPI listing for the same spec must agree on the most- |
| 99 | + # advanced phase + its status — proving both surfaces read the same data. |
| 100 | + route_resp = client.get("/api/cowork/specs") |
| 101 | + alpha_entry = next(s for s in route_resp.json()["specs"] if s["feature"] == "alpha") |
| 102 | + most_advanced = alpha_entry["phase"] # e.g. "design.md" |
| 103 | + # _PHASE_NAMES order: requirements -> design -> tasks |
| 104 | + expected_name = most_advanced.replace(".md", "") |
| 105 | + assert mcp_resp["phases"][expected_name]["status"] == alpha_entry["status"] |
| 106 | + |
| 107 | + |
| 108 | +# --------------------------------------------------------------------------- |
| 109 | +# Bonus — gui_set_spec_status round-trip across both surfaces (Phase 3 cover) |
| 110 | +# --------------------------------------------------------------------------- |
| 111 | + |
| 112 | + |
| 113 | +@pytest.mark.asyncio |
| 114 | +async def test_set_spec_status_round_trips_to_fastapi_route( |
| 115 | + mcp_app, client: TestClient, shared_specs_root |
| 116 | +) -> None: |
| 117 | + """Flip status via MCP; the FastAPI listing must reflect the new value.""" |
| 118 | + # Baseline — beta's only phase is requirements, currently "draft". |
| 119 | + pre = client.get("/api/cowork/specs").json() |
| 120 | + beta_pre = next(s for s in pre["specs"] if s["feature"] == "beta") |
| 121 | + assert beta_pre["status"] == "draft" |
| 122 | + |
| 123 | + # Flip via MCP. |
| 124 | + flip = await mcp_app.call_tool( |
| 125 | + "gui_set_spec_status", |
| 126 | + {"feature": "beta", "phase": "requirements", "status": "approved"}, |
| 127 | + ) |
| 128 | + assert flip["success"] is True |
| 129 | + assert flip["status"] == "approved" |
| 130 | + |
| 131 | + # FastAPI now sees the new status — no cache, both surfaces read the file. |
| 132 | + post = client.get("/api/cowork/specs").json() |
| 133 | + beta_post = next(s for s in post["specs"] if s["feature"] == "beta") |
| 134 | + assert beta_post["status"] == "approved" |
| 135 | + |
| 136 | + # And the MCP get_spec_status tool agrees. |
| 137 | + follow = await mcp_app.call_tool( |
| 138 | + "gui_get_spec_status", {"feature": "beta", "phase": "requirements"} |
| 139 | + ) |
| 140 | + assert follow["status"] == "approved" |
0 commit comments