feat: add isolated proposal changesets
This commit is contained in:
parent
9702ed1265
commit
8c75f4f44d
22 changed files with 2314 additions and 64 deletions
|
|
@ -11,7 +11,7 @@ from mcp.client.stdio import stdio_client
|
|||
from mcp.shared.memory import create_connected_server_and_client_session
|
||||
|
||||
from docforge.index import ProjectIndex
|
||||
from docforge.mcp_server import CONTENT_WARNING, READ_TOOLS, create_server
|
||||
from docforge.mcp_server import ALL_TOOLS, CONTENT_WARNING, PROPOSAL_TOOLS, create_server
|
||||
from docforge.project import Project
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
|
@ -24,7 +24,7 @@ class DocForgeMcpTests(unittest.IsolatedAsyncioTestCase):
|
|||
shutil.copytree(FIXTURES / name, root)
|
||||
return root
|
||||
|
||||
async def test_protocol_lists_only_the_fixed_read_surface(self) -> None:
|
||||
async def test_protocol_lists_only_the_fixed_safe_surface(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = self.copy_fixture("alpha", Path(directory))
|
||||
ProjectIndex(Project.open(root)).build()
|
||||
|
|
@ -34,12 +34,13 @@ class DocForgeMcpTests(unittest.IsolatedAsyncioTestCase):
|
|||
response = await session.list_tools()
|
||||
|
||||
names = tuple(tool.name for tool in response.tools)
|
||||
self.assertEqual(READ_TOOLS, names)
|
||||
self.assertEqual(ALL_TOOLS, names)
|
||||
self.assertEqual(9, len(PROPOSAL_TOOLS))
|
||||
self.assertFalse(
|
||||
any(
|
||||
token in name
|
||||
for name in names
|
||||
for token in ("write", "apply", "commit", "push", "deploy", "propose")
|
||||
for token in ("apply", "commit", "push", "deploy", "publish", "shell")
|
||||
)
|
||||
)
|
||||
|
||||
|
|
@ -78,6 +79,8 @@ class DocForgeMcpTests(unittest.IsolatedAsyncioTestCase):
|
|||
self.assertFalse(contract["canonical_writes_allowed"])
|
||||
self.assertFalse(contract["project_switching_allowed"])
|
||||
self.assertIn("canonical_writes", contract["excluded_operations"])
|
||||
self.assertFalse(contract["isolated_changeset_writes_allowed"])
|
||||
self.assertFalse(contract["proposal_access"]["enabled"])
|
||||
context = results[8].structuredContent
|
||||
self.assertLessEqual(context["estimated_tokens"], 180)
|
||||
self.assertTrue(context["omissions"])
|
||||
|
|
@ -131,6 +134,142 @@ class DocForgeMcpTests(unittest.IsolatedAsyncioTestCase):
|
|||
self.assertEqual("result_too_large", payload["error"]["code"])
|
||||
self.assertNotIn("canonical_paths", payload)
|
||||
|
||||
async def test_proposal_tools_use_fixed_writer_and_never_change_canonical_content(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = self.copy_fixture("alpha", Path(directory))
|
||||
project = Project.open(root)
|
||||
ProjectIndex(project).build()
|
||||
canonical_before = {
|
||||
path.relative_to(root).as_posix(): path.read_bytes()
|
||||
for path in (root / "docs/content").glob("*")
|
||||
if path.is_file()
|
||||
}
|
||||
node_hashes = {node.node_id: node.content_hash for node in project.load().nodes}
|
||||
async with create_connected_server_and_client_session(
|
||||
create_server(root, "alpha-editor"), raise_exceptions=True
|
||||
) as session:
|
||||
contract = await session.call_tool("docforge_get_contract", {})
|
||||
created = await session.call_tool(
|
||||
"docforge_create_changeset", {"changeset_id": "mcp-update"}
|
||||
)
|
||||
updated = await session.call_tool(
|
||||
"docforge_propose_node_update",
|
||||
{
|
||||
"changeset_id": "mcp-update",
|
||||
"expected_changeset_hash": created.structuredContent["changeset_hash"],
|
||||
"node_id": "guide.workflow",
|
||||
"expected_content_hash": node_hashes["guide.workflow"],
|
||||
"metadata": {"summary": "A proposal written through MCP."},
|
||||
"content": None,
|
||||
"relationship_changes": [],
|
||||
"rationale": "Prove fixed-writer isolated proposal access.",
|
||||
},
|
||||
)
|
||||
created_node = await session.call_tool(
|
||||
"docforge_propose_node_create",
|
||||
{
|
||||
"changeset_id": "mcp-update",
|
||||
"expected_changeset_hash": updated.structuredContent["changeset_hash"],
|
||||
"node_id": "guide.mcp-node",
|
||||
"target_source": "docs/content/mcp-node.md",
|
||||
"metadata": {
|
||||
"title": "MCP proposal node",
|
||||
"family": "guide",
|
||||
"authority": "proposal",
|
||||
"status": "active",
|
||||
"tags": ["mcp", "proposal"],
|
||||
"summary": "A node creation proposed through MCP.",
|
||||
},
|
||||
"content": "This node is not canonical until external integration.",
|
||||
"relationship_changes": [],
|
||||
"rationale": "Prove isolated MCP creation.",
|
||||
},
|
||||
)
|
||||
moved = await session.call_tool(
|
||||
"docforge_propose_node_move",
|
||||
{
|
||||
"changeset_id": "mcp-update",
|
||||
"expected_changeset_hash": created_node.structuredContent["changeset_hash"],
|
||||
"node_id": "guide.foundation",
|
||||
"expected_content_hash": node_hashes["guide.foundation"],
|
||||
"target_source": "docs/content/foundation-moved.md",
|
||||
"rationale": "Prove isolated MCP movement.",
|
||||
},
|
||||
)
|
||||
deleted = await session.call_tool(
|
||||
"docforge_propose_node_delete",
|
||||
{
|
||||
"changeset_id": "mcp-update",
|
||||
"expected_changeset_hash": moved.structuredContent["changeset_hash"],
|
||||
"node_id": "proof.validation",
|
||||
"expected_content_hash": node_hashes["proof.validation"],
|
||||
"relationship_changes": [
|
||||
{
|
||||
"action": "remove",
|
||||
"source_id": "proof.validation",
|
||||
"relation": "proves",
|
||||
"target_id": "guide.workflow",
|
||||
}
|
||||
],
|
||||
"rationale": "Prove isolated MCP deletion.",
|
||||
},
|
||||
)
|
||||
validated = await session.call_tool(
|
||||
"docforge_validate_changeset", {"changeset_id": "mcp-update"}
|
||||
)
|
||||
listed = await session.call_tool("docforge_list_changesets", {})
|
||||
inspected = await session.call_tool(
|
||||
"docforge_get_changeset", {"changeset_id": "mcp-update"}
|
||||
)
|
||||
diff = await session.call_tool(
|
||||
"docforge_get_changeset_diff", {"changeset_id": "mcp-update"}
|
||||
)
|
||||
|
||||
self.assertTrue(contract.structuredContent["proposal_access"]["enabled"])
|
||||
self.assertEqual(
|
||||
"alpha-editor", contract.structuredContent["proposal_access"]["writer"]
|
||||
)
|
||||
self.assertTrue(contract.structuredContent["isolated_changeset_writes_allowed"])
|
||||
self.assertFalse(contract.structuredContent["canonical_writes_allowed"])
|
||||
self.assertEqual("alpha-editor", created.structuredContent["creator"])
|
||||
self.assertEqual(1, updated.structuredContent["operation_count"])
|
||||
self.assertEqual(4, deleted.structuredContent["operation_count"])
|
||||
self.assertTrue(validated.structuredContent["valid"])
|
||||
self.assertEqual(1, listed.structuredContent["count"])
|
||||
self.assertEqual(
|
||||
"mcp-update", listed.structuredContent["changesets"][0]["changeset_id"]
|
||||
)
|
||||
self.assertEqual("current", inspected.structuredContent["base_state"])
|
||||
self.assertEqual(4, inspected.structuredContent["operation_count"])
|
||||
self.assertEqual(
|
||||
["update", "create", "move", "delete"],
|
||||
[change["operation"] for change in diff.structuredContent["changes"]],
|
||||
)
|
||||
canonical_after = {
|
||||
path.relative_to(root).as_posix(): path.read_bytes()
|
||||
for path in (root / "docs/content").glob("*")
|
||||
if path.is_file()
|
||||
}
|
||||
self.assertEqual(canonical_before, canonical_after)
|
||||
self.assertFalse((root / "docs/content/mcp-node.md").exists())
|
||||
self.assertTrue((root / ".docforge/changesets/mcp-update.json").is_file())
|
||||
|
||||
async def test_server_without_writer_rejects_proposal_mutation_structurally(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = self.copy_fixture("alpha", Path(directory))
|
||||
ProjectIndex(Project.open(root)).build()
|
||||
async with create_connected_server_and_client_session(
|
||||
create_server(root), raise_exceptions=True
|
||||
) as session:
|
||||
result = await session.call_tool(
|
||||
"docforge_create_changeset", {"changeset_id": "disabled"}
|
||||
)
|
||||
|
||||
self.assertFalse(result.isError)
|
||||
self.assertEqual("error", result.structuredContent["status"])
|
||||
self.assertEqual("proposal_access_disabled", result.structuredContent["error"]["code"])
|
||||
self.assertFalse((root / ".docforge/changesets/disabled.json").exists())
|
||||
|
||||
async def test_stdio_transport_serves_the_same_project_bound_contract(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = self.copy_fixture("beta", Path(directory))
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue