Add gated changeset application and graph controls
This commit is contained in:
parent
3c15e26283
commit
78335c8973
20 changed files with 1813 additions and 453 deletions
|
|
@ -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))
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue