Skip to content

Commit 1657c2f

Browse files
feat(mcp): Phase 3 — gui_set_spec_status (the only write tool) (#55)
* feat(mcp): Phase 3 — gui_set_spec_status (the only write tool) Wraps the existing PUT /api/cowork/specs/{feature}/{phase}/status route as an MCP tool. Same validation (slug regex, phase enum, _VALID_STATUSES), same atomic write via attune_gui._fs.atomic_write. Behavior mirrors the FastAPI route: - if a `**Status**:` line already exists, substitute via _STATUS_RE - otherwise insert a fresh line near the top, after the title This is the only write tool in the MCP surface — additive to the five read-mostly tools from Phase 2. The spec called it out as "optional / gated" and Patrick approved shipping it as part of the post-Phase-2 plan. Tests (test_mcp_tools.py, +6): - happy path: status flip persists to disk + gui_get_spec_status reflects the new value - invalid status (not in _VALID_STATUSES) - invalid phase - invalid feature slug (path-traversal attempt) - unknown feature - missing phase file (e.g. setting tasks.md status on a spec that has only requirements/design) Also updated test_mcp_server.py's tool-registry assertion to expect six tools, renaming the test from _phase2_ to _full_. Marks 3.1 done in tasks.md. CHANGELOG entry under Unreleased. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * docs(living-docs): regenerate sidecar templates for Phase 3 changes Auto-regen triggered by the new gui_set_spec_status code in sidecar/attune_gui/mcp/tools.py. source_hash drift only. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 0a5901f commit 1657c2f

8 files changed

Lines changed: 207 additions & 12 deletions

File tree

.help/templates/sidecar/concept.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
---
22
feature: sidecar
33
depth: concept
4-
generated_at: 2026-05-23T15:00:21.678404+00:00
5-
source_hash: 8bd8cc535fccde713e960fad2b1ad134f8610ef75f44845a840dfbd2e438853e
4+
generated_at: 2026-05-23T15:23:17.077441+00:00
5+
source_hash: 82f32c163679d9108687682ce676ff1f4f1242f118d1e8295e480bcbcb749660
66
status: generated
77
---
88

.help/templates/sidecar/reference.md

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
---
22
feature: sidecar
33
depth: reference
4-
generated_at: 2026-05-23T15:00:21.690599+00:00
5-
source_hash: 8bd8cc535fccde713e960fad2b1ad134f8610ef75f44845a840dfbd2e438853e
4+
generated_at: 2026-05-23T15:23:17.087987+00:00
5+
source_hash: 82f32c163679d9108687682ce676ff1f4f1242f118d1e8295e480bcbcb749660
66
status: generated
77
---
88

@@ -128,6 +128,7 @@ status: generated
128128
| `gui_get_spec_status()` || `sidecar/attune_gui/mcp/tools.py` |
129129
| `gui_list_living_docs()` || `sidecar/attune_gui/mcp/tools.py` |
130130
| `gui_get_living_doc()` || `sidecar/attune_gui/mcp/tools.py` |
131+
| `gui_set_spec_status()` || `sidecar/attune_gui/mcp/tools.py` |
131132
| `get_dispatch()` | Tool-name → async handler. Imported by :mod:`.server`. | `sidecar/attune_gui/mcp/tools.py` |
132133
| `list_features()` | Return the feature names from ``<help_dir>/features.yaml``. | `sidecar/attune_gui/routes/choices.py` |
133134
| `read_file()` | Return raw file contents (UTF-8) plus the `manual` frontmatter flag for `.md` files. | `sidecar/attune_gui/routes/cowork_files.py` |
@@ -523,7 +524,7 @@ status: generated
523524
| `test_config_command_set_unknown_key_returns_2()` || `sidecar/tests/test_main.py` |
524525
| `test_config_command_unset_unknown_key_returns_2()` || `sidecar/tests/test_main.py` |
525526
| `test_config_command_unknown_action_returns_2()` || `sidecar/tests/test_main.py` |
526-
| `test_app_initializes_with_phase2_tool_registry()` || `sidecar/tests/test_mcp_server.py` |
527+
| `test_app_initializes_with_full_tool_registry()` || `sidecar/tests/test_mcp_server.py` |
527528
| `test_unknown_tool_returns_error_envelope()` || `sidecar/tests/test_mcp_server.py` |
528529
| `test_server_name_is_attune_gui()` || `sidecar/tests/test_mcp_server.py` |
529530
| `test_main_entry_point_is_callable()` || `sidecar/tests/test_mcp_server.py` |
@@ -538,6 +539,12 @@ status: generated
538539
| `test_get_spec_status_returns_most_advanced()` || `sidecar/tests/test_mcp_tools.py` |
539540
| `test_get_spec_status_explicit_phase()` || `sidecar/tests/test_mcp_tools.py` |
540541
| `test_get_spec_status_rejects_invalid_phase()` || `sidecar/tests/test_mcp_tools.py` |
542+
| `test_set_spec_status_persists_to_disk()` || `sidecar/tests/test_mcp_tools.py` |
543+
| `test_set_spec_status_rejects_invalid_status()` || `sidecar/tests/test_mcp_tools.py` |
544+
| `test_set_spec_status_rejects_invalid_phase()` || `sidecar/tests/test_mcp_tools.py` |
545+
| `test_set_spec_status_rejects_invalid_feature()` || `sidecar/tests/test_mcp_tools.py` |
546+
| `test_set_spec_status_unknown_feature()` || `sidecar/tests/test_mcp_tools.py` |
547+
| `test_set_spec_status_missing_phase_file()` || `sidecar/tests/test_mcp_tools.py` |
541548
| `test_list_living_docs_returns_docs()` || `sidecar/tests/test_mcp_tools.py` |
542549
| `test_get_living_doc_reads_file_content()` || `sidecar/tests/test_mcp_tools.py` |
543550
| `test_get_living_doc_rejects_malformed_id()` || `sidecar/tests/test_mcp_tools.py` |

.help/templates/sidecar/task.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
---
22
feature: sidecar
33
depth: task
4-
generated_at: 2026-05-23T15:00:21.685731+00:00
5-
source_hash: 8bd8cc535fccde713e960fad2b1ad134f8610ef75f44845a840dfbd2e438853e
4+
generated_at: 2026-05-23T15:23:17.083621+00:00
5+
source_hash: 82f32c163679d9108687682ce676ff1f4f1242f118d1e8295e480bcbcb749660
66
status: generated
77
---
88

CHANGELOG.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,18 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
1313
and a cross-link to attune-ai's complementary `ops-specs-features`
1414
spec. Completes the [mcp-server-scope](docs/specs/mcp-server-scope/)
1515
spec's Phase 5.
16+
- **MCP server — Phase 3 write tool.** New `gui_set_spec_status`
17+
tool wraps the existing
18+
`PUT /api/cowork/specs/{feature}/{phase}/status` route as MCP.
19+
Same validation (slug regex, phase enum, `_VALID_STATUSES`),
20+
same atomic write via `attune_gui._fs.atomic_write`. The only
21+
write tool in the MCP surface — additive to the five read-mostly
22+
tools from Phase 2. Six new tests in `test_mcp_tools.py` cover
23+
the happy path (persistence + readback parity with
24+
`gui_get_spec_status`) plus invalid status, invalid phase,
25+
invalid feature slug, unknown feature, and missing phase file.
26+
Closes Phase 3 of
27+
[mcp-server-scope](docs/specs/mcp-server-scope/).
1628
- **MCP server — Phase 2 tools.** Five read-mostly tools wired
1729
on top of the Phase 1 scaffold, each returning a
1830
JSON-serializable envelope with `{"success": bool, ...}`:

docs/specs/mcp-server-scope/tasks.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -37,10 +37,12 @@ Each tool:
3737

3838
## Phase 3 — Optional write tool
3939

40-
- [ ] **3.1** `gui_set_spec_status` — wrap the existing
41-
PUT route. Same body shape:
42-
`{"status": "<valid-value>"}`. Same validation. Honor
43-
any read-only flag on the server.
40+
- [x] **3.1** `gui_set_spec_status` — wrap the existing
41+
PUT route. Same validation (slug regex, phase enum,
42+
`_VALID_STATUSES`). Atomic write via `attune_gui._fs.atomic_write`.
43+
Inserts a `**Status**:` line if missing, otherwise substitutes
44+
via `_STATUS_RE`. The only write tool — additive to the
45+
five read-mostly tools.
4446

4547
## Phase 4 — Integration test
4648

sidecar/attune_gui/mcp/tools.py

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,39 @@
142142
"additionalProperties": False,
143143
},
144144
},
145+
"gui_set_spec_status": {
146+
"description": (
147+
"Rewrite the `**Status**:` line in a spec's phase file. The only "
148+
"write tool — use sparingly. Mirrors the validation of the existing "
149+
"PUT /api/cowork/specs/{feature}/{phase}/status route."
150+
),
151+
"input_schema": {
152+
"type": "object",
153+
"properties": {
154+
"feature": {"type": "string"},
155+
"phase": {
156+
"type": "string",
157+
"enum": list(_PHASE_NAMES),
158+
},
159+
"status": {
160+
"type": "string",
161+
"description": (
162+
"One of: draft, in-review, approved, complete, " "completed, done."
163+
),
164+
},
165+
"root": {
166+
"type": "string",
167+
"description": (
168+
"Optional absolute path of the specs root to "
169+
"disambiguate when the same slug exists in multiple "
170+
"roots. If omitted, the first match wins."
171+
),
172+
},
173+
},
174+
"required": ["feature", "phase", "status"],
175+
"additionalProperties": False,
176+
},
177+
},
145178
}
146179

147180

@@ -355,6 +388,68 @@ async def gui_get_living_doc(args: dict[str, Any]) -> dict[str, Any]:
355388
}
356389

357390

391+
async def gui_set_spec_status(args: dict[str, Any]) -> dict[str, Any]:
392+
# Lazy imports keep the writes-only deps (atomic_write, _VALID_STATUSES, the
393+
# status regexes) out of module-import time. Matches the gui_get_living_doc
394+
# workspace pattern above.
395+
from attune_gui._fs import atomic_write # noqa: PLC0415
396+
from attune_gui.routes.cowork_specs import ( # noqa: PLC0415
397+
_STATUS_RE,
398+
_STATUS_VALUE_RE,
399+
_VALID_STATUSES,
400+
)
401+
402+
feature = args.get("feature", "")
403+
if (msg := _validate_slug(feature)) is not None:
404+
return _err(msg)
405+
phase = args.get("phase", "")
406+
if phase not in _PHASE_FILE_BY_NAME:
407+
return _err(f"Invalid phase: {phase!r} (valid: {', '.join(_PHASE_NAMES)})")
408+
status = args.get("status", "")
409+
if not isinstance(status, str) or status not in _VALID_STATUSES:
410+
return _err(f"Invalid status: {status!r} (valid: {', '.join(_VALID_STATUSES)})")
411+
root = args.get("root")
412+
413+
found = _resolve_feature_dir(feature, root)
414+
if isinstance(found, dict):
415+
return found
416+
feat_dir, project = found
417+
418+
target = feat_dir / _PHASE_FILE_BY_NAME[phase]
419+
if not target.is_file():
420+
return _err(f"Phase file does not exist: {target.name}")
421+
422+
try:
423+
original = target.read_text(encoding="utf-8")
424+
except OSError as exc:
425+
return _err(f"Read failed: {exc}")
426+
427+
# Mirrors the PUT route's logic: substitute if a Status line exists,
428+
# insert near the top otherwise.
429+
if not _STATUS_VALUE_RE.search(original):
430+
lines = original.splitlines()
431+
insert_at = 1 if lines and lines[0].startswith("# ") else 0
432+
lines.insert(insert_at, f"\n**Status**: {status}\n")
433+
new_text = "\n".join(lines) + ("\n" if not original.endswith("\n") else "")
434+
else:
435+
new_text = _STATUS_RE.sub(f"**Status**: {status}", original, count=1)
436+
437+
try:
438+
atomic_write(target, new_text)
439+
except OSError as exc:
440+
return _err(f"Write failed: {exc}")
441+
442+
return {
443+
"success": True,
444+
"feature": feature,
445+
"project": project,
446+
"phase": phase,
447+
"file": target.name,
448+
"status": status,
449+
"path": str(target),
450+
}
451+
452+
358453
# ---------------------------------------------------------------------------
359454
# Public dispatch table
360455
# ---------------------------------------------------------------------------
@@ -368,4 +463,5 @@ def get_dispatch() -> dict[str, Any]:
368463
"gui_get_spec_status": gui_get_spec_status,
369464
"gui_list_living_docs": gui_list_living_docs,
370465
"gui_get_living_doc": gui_get_living_doc,
466+
"gui_set_spec_status": gui_set_spec_status,
371467
}

sidecar/tests/test_mcp_server.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
import pytest
1010

1111

12-
def test_app_initializes_with_phase2_tool_registry() -> None:
12+
def test_app_initializes_with_full_tool_registry() -> None:
1313
from attune_gui.mcp.server import create_server
1414

1515
app = create_server()
@@ -19,6 +19,7 @@ def test_app_initializes_with_phase2_tool_registry() -> None:
1919
"gui_get_spec_status",
2020
"gui_list_living_docs",
2121
"gui_get_living_doc",
22+
"gui_set_spec_status",
2223
}
2324

2425

sidecar/tests/test_mcp_tools.py

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,83 @@ async def test_get_spec_status_rejects_invalid_phase(app, specs_root) -> None:
143143
assert "Invalid phase" in result["error"]
144144

145145

146+
# ---------------------------------------------------------------------------
147+
# gui_set_spec_status (the only write tool)
148+
# ---------------------------------------------------------------------------
149+
150+
151+
@pytest.mark.asyncio
152+
async def test_set_spec_status_persists_to_disk(app, specs_root) -> None:
153+
result = await app.call_tool(
154+
"gui_set_spec_status",
155+
{"feature": "alpha", "phase": "requirements", "status": "complete"},
156+
)
157+
assert result["success"] is True
158+
assert result["status"] == "complete"
159+
# Verify the file actually changed.
160+
content = (specs_root / "alpha" / "requirements.md").read_text(encoding="utf-8")
161+
assert "**Status**: complete" in content
162+
# And that gui_get_spec_status now reports the new value.
163+
follow = await app.call_tool(
164+
"gui_get_spec_status", {"feature": "alpha", "phase": "requirements"}
165+
)
166+
assert follow["status"] == "complete"
167+
168+
169+
@pytest.mark.asyncio
170+
async def test_set_spec_status_rejects_invalid_status(app, specs_root) -> None:
171+
result = await app.call_tool(
172+
"gui_set_spec_status",
173+
{"feature": "alpha", "phase": "requirements", "status": "shipped-it"},
174+
)
175+
assert result["success"] is False
176+
assert "Invalid status" in result["error"]
177+
# File must not have been touched.
178+
content = (specs_root / "alpha" / "requirements.md").read_text(encoding="utf-8")
179+
assert "**Status**: approved" in content
180+
181+
182+
@pytest.mark.asyncio
183+
async def test_set_spec_status_rejects_invalid_phase(app, specs_root) -> None:
184+
result = await app.call_tool(
185+
"gui_set_spec_status",
186+
{"feature": "alpha", "phase": "summary", "status": "complete"},
187+
)
188+
assert result["success"] is False
189+
assert "Invalid phase" in result["error"]
190+
191+
192+
@pytest.mark.asyncio
193+
async def test_set_spec_status_rejects_invalid_feature(app, specs_root) -> None:
194+
result = await app.call_tool(
195+
"gui_set_spec_status",
196+
{"feature": "../etc", "phase": "requirements", "status": "approved"},
197+
)
198+
assert result["success"] is False
199+
assert "Invalid feature" in result["error"]
200+
201+
202+
@pytest.mark.asyncio
203+
async def test_set_spec_status_unknown_feature(app, specs_root) -> None:
204+
result = await app.call_tool(
205+
"gui_set_spec_status",
206+
{"feature": "ghost", "phase": "requirements", "status": "approved"},
207+
)
208+
assert result["success"] is False
209+
assert "'ghost' not found" in result["error"]
210+
211+
212+
@pytest.mark.asyncio
213+
async def test_set_spec_status_missing_phase_file(app, specs_root) -> None:
214+
# design.md is present on alpha (from the fixture); tasks.md is not.
215+
result = await app.call_tool(
216+
"gui_set_spec_status",
217+
{"feature": "alpha", "phase": "tasks", "status": "complete"},
218+
)
219+
assert result["success"] is False
220+
assert "Phase file does not exist" in result["error"]
221+
222+
146223
# ---------------------------------------------------------------------------
147224
# gui_list_living_docs / gui_get_living_doc
148225
# ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)