Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .help/templates/sidecar/concept.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
---
feature: sidecar
depth: concept
generated_at: 2026-05-23T15:23:17.077441+00:00
source_hash: 82f32c163679d9108687682ce676ff1f4f1242f118d1e8295e480bcbcb749660
generated_at: 2026-05-23T15:26:20.582683+00:00
source_hash: 278c82dcb0488307aba1af37c2e2b49ddd2ba1b93775befbe9147bf96965936d
status: generated
---

Expand All @@ -20,7 +20,7 @@ The main building blocks are:
- **`Registry`** — In-memory snapshot of ``~/.attune/corpora.json``.
- **`EditorSession`** — In-process state for a single ``(corpus, path)`` editing tab.

Under the hood, this feature spans 109 source
Under the hood, this feature spans 110 source
files covering:

- Filesystem helpers shared across routes.
Expand Down
9 changes: 7 additions & 2 deletions .help/templates/sidecar/reference.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
---
feature: sidecar
depth: reference
generated_at: 2026-05-23T15:23:17.087987+00:00
source_hash: 82f32c163679d9108687682ce676ff1f4f1242f118d1e8295e480bcbcb749660
generated_at: 2026-05-23T15:26:20.594051+00:00
source_hash: 278c82dcb0488307aba1af37c2e2b49ddd2ba1b93775befbe9147bf96965936d
status: generated
---

Expand Down Expand Up @@ -524,6 +524,11 @@ status: generated
| `test_config_command_set_unknown_key_returns_2()` | — | `sidecar/tests/test_main.py` |
| `test_config_command_unset_unknown_key_returns_2()` | — | `sidecar/tests/test_main.py` |
| `test_config_command_unknown_action_returns_2()` | — | `sidecar/tests/test_main.py` |
| `shared_specs_root()` | Isolated specs root with two fixture specs, visible to both surfaces. | `sidecar/tests/test_mcp_integration.py` |
| `mcp_app()` | — | `sidecar/tests/test_mcp_integration.py` |
| `test_gui_list_specs_matches_fastapi_route()` | Same workspace must yield the same spec list on both surfaces. | `sidecar/tests/test_mcp_integration.py` |
| `test_gui_get_spec_returns_disk_truth()` | gui_get_spec content must match what's on disk, and the route's | `sidecar/tests/test_mcp_integration.py` |
| `test_set_spec_status_round_trips_to_fastapi_route()` | Flip status via MCP; the FastAPI listing must reflect the new value. | `sidecar/tests/test_mcp_integration.py` |
| `test_app_initializes_with_full_tool_registry()` | — | `sidecar/tests/test_mcp_server.py` |
| `test_unknown_tool_returns_error_envelope()` | — | `sidecar/tests/test_mcp_server.py` |
| `test_server_name_is_attune_gui()` | — | `sidecar/tests/test_mcp_server.py` |
Expand Down
4 changes: 2 additions & 2 deletions .help/templates/sidecar/task.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
---
feature: sidecar
depth: task
generated_at: 2026-05-23T15:23:17.083621+00:00
source_hash: 82f32c163679d9108687682ce676ff1f4f1242f118d1e8295e480bcbcb749660
generated_at: 2026-05-23T15:26:20.589168+00:00
source_hash: 278c82dcb0488307aba1af37c2e2b49ddd2ba1b93775befbe9147bf96965936d
status: generated
---

Expand Down
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,21 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
and a cross-link to attune-ai's complementary `ops-specs-features`
spec. Completes the [mcp-server-scope](docs/specs/mcp-server-scope/)
spec's Phase 5.
- **MCP server — Phase 4 integration tests.** New
`sidecar/tests/test_mcp_integration.py` (3 tests) exercises
the MCP tool surface against the FastAPI routes on a shared
isolated specs root:
- `gui_list_specs` returns the same `specs` and `specs_roots`
lists as `GET /api/cowork/specs`
- `gui_get_spec` content matches disk byte-for-byte, and the
FastAPI listing agrees on most-advanced phase + status
- `gui_set_spec_status` round-trip: flip via MCP, the FastAPI
listing reflects the change with no cache layer in between
Uses in-process dispatch (`AttuneGuiMCPServer.call_tool`)
rather than a stdio subprocess — same parity guarantee, much
lighter test cost. Closes Phase 4 of
[mcp-server-scope](docs/specs/mcp-server-scope/) — the spec
is now fully shipped end-to-end.
- **MCP server — Phase 3 write tool.** New `gui_set_spec_status`
tool wraps the existing
`PUT /api/cowork/specs/{feature}/{phase}/status` route as MCP.
Expand Down
19 changes: 14 additions & 5 deletions docs/specs/mcp-server-scope/tasks.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,11 +46,20 @@ Each tool:

## Phase 4 — Integration test

- [ ] **4.1** Test that starts the MCP server, queries
`gui_list_specs`, and asserts the result matches the
FastAPI route's response on the same data
- [ ] **4.2** Same for `gui_get_spec` against a known fixture
spec
- [x] **4.1** In-process parity test: `gui_list_specs` via
`AttuneGuiMCPServer.call_tool` returns the same `specs`
and `specs_roots` lists as `GET /api/cowork/specs` on
the same isolated specs root. (Stdio-subprocess spawn
was reinterpreted as in-process dispatch — the
surface-parity guarantee is the same and the test is
lighter; tracked in
`sidecar/tests/test_mcp_integration.py`.)
- [x] **4.2** `gui_get_spec` returns disk-truth content for
every phase file, and the FastAPI listing references
the same most-advanced phase + status. Plus a bonus
round-trip through `gui_set_spec_status` (Phase 3) that
flips status via MCP and asserts the FastAPI listing
reflects the change.

## Phase 5 — Docs

Expand Down
140 changes: 140 additions & 0 deletions sidecar/tests/test_mcp_integration.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
"""Phase 4 integration tests for the MCP server.

Exercises the MCP tool surface against the FastAPI route surface on the
same isolated specs root, proving the two stay in sync. Covers spec tasks
4.1 (``gui_list_specs`` parity), 4.2 (``gui_get_spec`` round-trip), and
adds a bonus round-trip through ``gui_set_spec_status`` (Phase 3) to lock
in the read-after-write contract across both surfaces.

These tests use the in-process MCP application directly via
``create_server().call_tool(...)`` — the same dispatch path the MCP SDK
hits — rather than spawning a stdio subprocess. The spec's "start the
MCP server" wording was aspirational; the value is parity with the
routes, which the in-process path proves at lower cost.
"""

from __future__ import annotations

from pathlib import Path

import pytest
from attune_gui.mcp.server import create_server
from fastapi.testclient import TestClient

# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------


@pytest.fixture
def shared_specs_root(tmp_path, monkeypatch) -> Path:
"""Isolated specs root with two fixture specs, visible to both surfaces."""
root = tmp_path / "specs"

alpha = root / "alpha"
alpha.mkdir(parents=True)
(alpha / "requirements.md").write_text(
"# alpha\n\n**Status**: approved\n\nReq body.\n", encoding="utf-8"
)
(alpha / "design.md").write_text("# design alpha\n\n**Status**: draft\n", encoding="utf-8")

beta = root / "beta"
beta.mkdir(parents=True)
(beta / "requirements.md").write_text("# beta\n\n**Status**: draft\n", encoding="utf-8")

monkeypatch.setenv("ATTUNE_SPECS_ROOT", str(root))
return root


@pytest.fixture
def mcp_app():
return create_server()


# ---------------------------------------------------------------------------
# 4.1 — gui_list_specs parity with GET /api/cowork/specs
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_gui_list_specs_matches_fastapi_route(
mcp_app, client: TestClient, shared_specs_root
) -> None:
"""Same workspace must yield the same spec list on both surfaces."""
route_resp = client.get("/api/cowork/specs")
assert route_resp.status_code == 200
route_body = route_resp.json()

mcp_resp = await mcp_app.call_tool("gui_list_specs", {})
assert mcp_resp["success"] is True

# The MCP surface uses {"specs": [...], "specs_roots": [...]} — the
# FastAPI route additionally returns a legacy "specs_root" string.
# Compare the field that both surfaces share verbatim.
assert mcp_resp["specs"] == route_body["specs"]
assert mcp_resp["specs_roots"] == route_body["specs_roots"]


# ---------------------------------------------------------------------------
# 4.2 — gui_get_spec content parity with the on-disk fixture
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_gui_get_spec_returns_disk_truth(
mcp_app, client: TestClient, shared_specs_root
) -> None:
"""gui_get_spec content must match what's on disk, and the route's
listing must reference the same feature with the same status."""
mcp_resp = await mcp_app.call_tool("gui_get_spec", {"feature": "alpha"})
assert mcp_resp["success"] is True
assert mcp_resp["feature"] == "alpha"

# The phase contents the tool returns must match the file bytes on disk.
for phase_name, phase in mcp_resp["phases"].items():
expected = (shared_specs_root / "alpha" / phase["file"]).read_text(encoding="utf-8")
assert phase["content"] == expected, f"content drift on phase={phase_name}"

# And the FastAPI listing for the same spec must agree on the most-
# advanced phase + its status — proving both surfaces read the same data.
route_resp = client.get("/api/cowork/specs")
alpha_entry = next(s for s in route_resp.json()["specs"] if s["feature"] == "alpha")
most_advanced = alpha_entry["phase"] # e.g. "design.md"
# _PHASE_NAMES order: requirements -> design -> tasks
expected_name = most_advanced.replace(".md", "")
assert mcp_resp["phases"][expected_name]["status"] == alpha_entry["status"]


# ---------------------------------------------------------------------------
# Bonus — gui_set_spec_status round-trip across both surfaces (Phase 3 cover)
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_set_spec_status_round_trips_to_fastapi_route(
mcp_app, client: TestClient, shared_specs_root
) -> None:
"""Flip status via MCP; the FastAPI listing must reflect the new value."""
# Baseline — beta's only phase is requirements, currently "draft".
pre = client.get("/api/cowork/specs").json()
beta_pre = next(s for s in pre["specs"] if s["feature"] == "beta")
assert beta_pre["status"] == "draft"

# Flip via MCP.
flip = await mcp_app.call_tool(
"gui_set_spec_status",
{"feature": "beta", "phase": "requirements", "status": "approved"},
)
assert flip["success"] is True
assert flip["status"] == "approved"

# FastAPI now sees the new status — no cache, both surfaces read the file.
post = client.get("/api/cowork/specs").json()
beta_post = next(s for s in post["specs"] if s["feature"] == "beta")
assert beta_post["status"] == "approved"

# And the MCP get_spec_status tool agrees.
follow = await mcp_app.call_tool(
"gui_get_spec_status", {"feature": "beta", "phase": "requirements"}
)
assert follow["status"] == "approved"
Loading