1
0
Fork 0
Code Issues Pull requests Projects Releases 2 Packages Wiki Activity Actions Pages

Add gated changeset application and graph controls

This commit is contained in:
Andraxion 2026-07-25 16:00:19 -04:00
parent 3c15e26283
commit 78335c8973
20 changed files with 1813 additions and 453 deletions

View file

@ -17,6 +17,7 @@ from mcp.shared.memory import create_connected_server_and_client_session
from docforge.index import ProjectIndex
from docforge.mcp_server import (
ALL_TOOLS,
APPLICATION_TOOLS,
CONTENT_WARNING,
PROPOSAL_TOOLS,
DocForgeService,
@ -130,7 +131,7 @@ class DocForgeMcpTests(unittest.IsolatedAsyncioTestCase):
visualization = results[11].structuredContent["visualization"]
self.assertTrue(visualization["read_only"])
self.assertTrue(visualization["project_bound"])
self.assertEqual("graph-browser@11", visualization["template"])
self.assertEqual("graph-browser@12", visualization["template"])
self.assertEqual("managed_idle", visualization["lifetime"]["policy"])
self.assertEqual("docforge_stop_visualization", visualization["lifetime"]["stop_tool"])
self.assertTrue(visualization["url"].startswith("http://127.0.0.1:"))
@ -343,6 +344,73 @@ class DocForgeMcpTests(unittest.IsolatedAsyncioTestCase):
self.assertEqual("proposal_access_disabled", result.structuredContent["error"]["code"])
self.assertFalse((root / ".docforge/changesets/disabled.json").exists())
async def test_canonical_apply_tool_is_opt_in_hash_bound_and_refreshes_outputs(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = self.copy_fixture("alpha", Path(directory))
project = Project.open(root)
ProjectIndex(project).build()
node_hash = next(
node.content_hash
for node in project.load().nodes
if node.node_id == "guide.workflow"
)
async with create_connected_server_and_client_session(
create_server(
root,
"alpha-editor",
canonical_applier_id="alpha-editor",
),
raise_exceptions=True,
) as session:
names = tuple(tool.name for tool in (await session.list_tools()).tools)
contract = await session.call_tool("docforge_get_contract", {})
created = await session.call_tool(
"docforge_create_changeset",
{"changeset_id": "mcp-apply"},
)
updated = await session.call_tool(
"docforge_propose_node_update",
{
"changeset_id": "mcp-apply",
"expected_changeset_hash": created.structuredContent["changeset_hash"],
"node_id": "guide.workflow",
"expected_content_hash": node_hash,
"metadata": {"summary": "Applied through the gated MCP tool."},
"content": None,
"relationship_changes": [],
"rationale": "Verify canonical MCP application.",
},
)
wrong = await session.call_tool(
"docforge_apply_changeset",
{
"changeset_id": "mcp-apply",
"expected_changeset_hash": "0" * 64,
},
)
applied = await session.call_tool(
"docforge_apply_changeset",
{
"changeset_id": "mcp-apply",
"expected_changeset_hash": updated.structuredContent["changeset_hash"],
},
)
self.assertEqual((*ALL_TOOLS, *APPLICATION_TOOLS), names)
self.assertTrue(contract.structuredContent["canonical_writes_allowed"])
self.assertTrue(contract.structuredContent["canonical_application_access"]["enabled"])
self.assertNotIn(
"canonical_changeset_application",
contract.structuredContent["excluded_operations"],
)
self.assertEqual("changeset_conflict", wrong.structuredContent["error"]["code"])
self.assertTrue(applied.structuredContent["applied"])
workflow = next(
node for node in project.load().nodes if node.node_id == "guide.workflow"
)
self.assertEqual("Applied through the gated MCP tool.", workflow.summary)
self.assertTrue((root / ".docforge/rendered/manual.html").is_file())
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))