1
0
Fork 0
Code Issues Pull requests Projects Releases 2 Packages Wiki Activity Actions Pages
DocForge2/tests/test_mcp_server.py

817 lines
39 KiB
Python
Raw Normal View History

from __future__ import annotations
2026-07-29 04:24:06 -04:00
import json
import os
import shutil
import sys
import tempfile
import threading
import time
import unittest
from contextlib import contextmanager
from pathlib import Path
2026-07-29 04:42:55 -04:00
from unittest import mock
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
from mcp.shared.memory import create_connected_server_and_client_session
from docforge.index import ProjectIndex
2026-07-24 21:43:11 -04:00
from docforge.mcp_server import (
ALL_TOOLS,
APPLICATION_TOOLS,
2026-07-24 21:43:11 -04:00
CONTENT_WARNING,
PROPOSAL_TOOLS,
DocForgeService,
_create_bound_server,
create_server,
)
from docforge.project import Project
from docforge.viewer_manager import ViewerManager
ROOT = Path(__file__).resolve().parents[1]
FIXTURES = ROOT / "tests" / "fixtures"
class DocForgeMcpTests(unittest.IsolatedAsyncioTestCase):
def copy_fixture(self, name: str, destination: Path) -> Path:
root = destination / name
shutil.copytree(FIXTURES / name, root)
return root
@contextmanager
def running_manager(self, state_path: Path):
manager = ViewerManager(state_path, check_interval_seconds=0.02)
thread = threading.Thread(target=manager.serve_forever, daemon=True)
previous = os.environ.get("DOCFORGE_VIEWER_MANAGER_STATE")
os.environ["DOCFORGE_VIEWER_MANAGER_STATE"] = str(state_path)
thread.start()
deadline = time.monotonic() + 2
while not state_path.exists() and time.monotonic() < deadline:
time.sleep(0.01)
self.assertTrue(state_path.exists())
try:
yield
finally:
manager.shutdown()
thread.join(timeout=2)
if previous is None:
os.environ.pop("DOCFORGE_VIEWER_MANAGER_STATE", None)
else:
os.environ["DOCFORGE_VIEWER_MANAGER_STATE"] = previous
2026-07-22 02:58:51 -04:00
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()
async with create_connected_server_and_client_session(
create_server(root), raise_exceptions=True
) as session:
response = await session.list_tools()
names = tuple(tool.name for tool in response.tools)
2026-07-22 02:58:51 -04:00
self.assertEqual(ALL_TOOLS, names)
self.assertEqual(14, len(PROPOSAL_TOOLS))
2026-07-29 04:15:13 -04:00
tools = {tool.name: tool for tool in response.tools}
for name in (
"docforge_backlinks",
"docforge_dependencies",
"docforge_impact",
):
self.assertIn("limit", tools[name].inputSchema["properties"])
self.assertNotIn("limit", tools[name].inputSchema.get("required", []))
2026-07-29 04:42:55 -04:00
self.assertIn(
"deep",
tools["docforge_render_status"].inputSchema["properties"],
)
self.assertFalse(
any(
token in name
for name in names
2026-07-22 02:58:51 -04:00
for token in ("apply", "commit", "push", "deploy", "publish", "shell")
)
)
2026-07-29 02:59:15 -04:00
async def test_no_ast_binding_preserves_adapter_and_blocks_logic(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = self.copy_fixture("alpha", Path(directory))
project = Project.open(root)
ProjectIndex(project).build()
service = DocForgeService(project, no_ast=True)
self.assertIs(service.index, service.application.index)
self.assertFalse(service.application.index.allow_logic)
2026-07-29 02:59:15 -04:00
async with create_connected_server_and_client_session(
create_server(root, no_ast=True), raise_exceptions=True
) as session:
bootstrap = await session.call_tool("docforge_bootstrap", {})
contract = await session.call_tool("docforge_get_contract", {})
logic = await session.call_tool(
"docforge_get_logic", {"owner_node_id": "guide.workflow"}
)
policy = bootstrap.structuredContent["adapter_policy"]
self.assertEqual("preserve-no-ast", policy["mode"])
self.assertEqual("forbidden", policy["ast_analysis"])
self.assertEqual("forbidden", policy["logic_projection"])
self.assertEqual("allowed", policy["incremental_extraction"])
self.assertEqual(["docforge_get_logic"], policy["blocked_tools"])
self.assertEqual(
policy,
bootstrap.structuredContent["binding"]["adapter_policy"],
)
self.assertIn(
"preserve the current adapter",
bootstrap.structuredContent["recommended_workflow"][1],
)
self.assertEqual(policy, contract.structuredContent["adapter_policy"])
self.assertIn(
"adapter_ast_upgrade",
contract.structuredContent["excluded_operations"],
)
self.assertEqual("error", logic.structuredContent["status"])
self.assertEqual(
"adapter_policy_forbids_logic",
logic.structuredContent["error"]["code"],
)
async def test_every_read_tool_returns_scoped_structured_results(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = self.copy_fixture("alpha", Path(directory))
ProjectIndex(Project.open(root)).build()
calls = (
("docforge_project_info", {}),
("docforge_get_contract", {}),
("docforge_get_node", {"node_id": "guide.workflow"}),
("docforge_get_logic", {"owner_node_id": "guide.workflow"}),
("docforge_search", {"query": "canonical nodes", "limit": 5}),
("docforge_filter_nodes", {"family": "proof", "tag": "validation"}),
2026-07-29 04:09:28 -04:00
("docforge_backlinks", {"node_id": "guide.workflow", "limit": 5}),
(
"docforge_dependencies",
{"node_id": "guide.workflow", "depth": 2, "limit": 5},
),
(
"docforge_impact",
{"node_id": "guide.foundation", "depth": 2, "limit": 5},
),
("docforge_get_context", {"profile": "active", "budget": 180}),
("docforge_validate_project", {}),
("docforge_render_status", {}),
2026-07-24 16:01:03 -04:00
("docforge_visualize", {"node_id": "guide.workflow", "depth": 1}),
("docforge_stop_visualization", {}),
("docforge_visualization_status", {}),
("docforge_bootstrap", {}),
("docforge_sync", {}),
)
with self.running_manager(Path(directory) / "viewer-manager.json"):
service = DocForgeService(Project.open(root))
try:
async with create_connected_server_and_client_session(
_create_bound_server(service, read_only=True), raise_exceptions=True
) as session:
results = [
await session.call_tool(name, arguments) for name, arguments in calls
]
finally:
service.visualization.stop()
for result in results:
self.assertFalse(result.isError)
self.assertIsNotNone(result.structuredContent)
payload = result.structuredContent
self.assertEqual("ok", payload["status"])
self.assertEqual("alpha-docs", payload["project_id"])
self.assertEqual(CONTENT_WARNING, payload["content_warning"])
self.assertTrue(payload["project_root_fingerprint"])
self.assertEqual("current", payload["staleness"])
contract = results[1].structuredContent
self.assertFalse(contract["canonical_writes_allowed"])
self.assertFalse(contract["project_switching_allowed"])
self.assertIn("canonical_writes", contract["excluded_operations"])
self.assertIn("arbitrary_renderer_execution", contract["excluded_operations"])
2026-07-22 02:58:51 -04:00
self.assertFalse(contract["isolated_changeset_writes_allowed"])
self.assertFalse(contract["proposal_access"]["enabled"])
self.assertFalse(results[3].structuredContent["available"])
self.assertTrue(results[11].structuredContent["configured"])
2026-07-29 04:42:55 -04:00
self.assertEqual("receipt", results[11].structuredContent["verification"])
self.assertEqual("stale", results[11].structuredContent["state"])
visualization = results[12].structuredContent["visualization"]
2026-07-24 16:01:03 -04:00
self.assertTrue(visualization["read_only"])
self.assertTrue(visualization["project_bound"])
2026-07-25 22:29:15 -04:00
self.assertEqual("graph-browser@17", visualization["template"])
self.assertEqual("managed_idle", visualization["lifetime"]["policy"])
self.assertEqual("docforge_stop_visualization", visualization["lifetime"]["stop_tool"])
2026-07-24 16:01:03 -04:00
self.assertTrue(visualization["url"].startswith("http://127.0.0.1:"))
self.assertEqual("stopped", results[13].structuredContent["state"])
self.assertEqual("not_running", results[14].structuredContent["state"])
context = results[9].structuredContent
self.assertLessEqual(context["estimated_tokens"], 180)
self.assertTrue(context["omissions"])
2026-07-29 04:15:13 -04:00
async def test_invalid_traversal_limit_is_a_structured_domain_error(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_impact",
{"node_id": "guide.foundation", "limit": 0},
)
self.assertEqual("error", result.structuredContent["status"])
self.assertEqual(
"invalid_limit",
result.structuredContent["error"]["code"],
)
2026-07-29 04:42:55 -04:00
async def test_render_status_error_never_loads_or_synchronizes_project(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = self.copy_fixture("alpha", Path(directory))
project = Project.open(root)
service = DocForgeService(project)
with (
mock.patch.object(
project,
"load",
side_effect=AssertionError("status error decoration must remain cheap"),
),
mock.patch.object(
service.index,
"synchronize",
side_effect=AssertionError("status must not synchronize"),
),
):
result = service.render_status("not-a-view")
self.assertEqual("error", result["status"])
self.assertEqual("unknown_render_view", result["error"]["code"])
self.assertEqual("unknown", result["revision"])
self.assertIsNone(result["source_hash"])
self.assertEqual("unknown", result["staleness"])
async def test_sync_register_rebase_apply_and_lifecycle_are_one_bound_workflow(
self,
) -> None:
with tempfile.TemporaryDirectory() as directory:
root = self.copy_fixture("alpha", Path(directory))
project = Project.open(root)
ProjectIndex(project).build()
async with create_connected_server_and_client_session(
create_server(
root,
"alpha-editor",
canonical_applier_id="alpha-editor",
),
raise_exceptions=True,
) as session:
bootstrap = await session.call_tool("docforge_bootstrap", {})
self.assertEqual("current", bootstrap.structuredContent["staleness"])
self.assertEqual(
str(root),
bootstrap.structuredContent["binding"]["project_root"],
)
proof = root / "docs/content/proof.toml"
proof.write_text(
proof.read_text(encoding="utf-8") + "\n# Current validation evidence.\n",
encoding="utf-8",
)
synchronized = await session.call_tool("docforge_sync", {})
self.assertEqual(
"rebuilt",
synchronized.structuredContent["synchronization"]["action"],
)
registered = await session.call_tool(
"docforge_register_changes",
{
"changeset_id": "bound-workflow",
"operations": [
{
"operation": "update",
"node_id": "guide.workflow",
"metadata": {
"summary": "Registered and applied in one bound workflow."
},
"rationale": "Verify atomic registration without caller hashes.",
}
],
},
)
self.assertTrue(registered.structuredContent["ready_for_review"])
self.assertEqual("ready", registered.structuredContent["lifecycle"])
foundation = root / "docs/content/foundation.md"
foundation.write_text(
foundation.read_text(encoding="utf-8") + "\nUnrelated current fact.\n",
encoding="utf-8",
)
rebased = await session.call_tool(
"docforge_rebase_changeset",
{
"changeset_id": "bound-workflow",
"expected_changeset_hash": registered.structuredContent["changeset_hash"],
},
)
self.assertTrue(rebased.structuredContent["rebased"])
difference = await session.call_tool(
"docforge_get_changeset_diff",
{"changeset_id": "bound-workflow"},
)
self.assertEqual("ok", difference.structuredContent["status"])
applied = await session.call_tool(
"docforge_apply_changeset",
{
"changeset_id": "bound-workflow",
"expected_changeset_hash": rebased.structuredContent["changeset_hash"],
},
)
self.assertEqual(
"applied",
applied.structuredContent["lifecycle"]["status"],
)
closed = await session.call_tool(
"docforge_rebase_changeset",
{
"changeset_id": "bound-workflow",
"expected_changeset_hash": rebased.structuredContent["changeset_hash"],
},
)
active = await session.call_tool("docforge_list_changesets", {})
history = await session.call_tool(
"docforge_list_changesets",
{"include_history": True, "status": "applied"},
)
self.assertEqual(
"changeset_closed",
closed.structuredContent["error"]["code"],
)
self.assertEqual(0, active.structuredContent["count"])
self.assertEqual(1, history.structuredContent["count"])
self.assertEqual(
"applied",
history.structuredContent["changesets"][0]["lifecycle"]["status"],
)
async def test_missing_node_fails_and_stale_index_self_heals(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = self.copy_fixture("alpha", Path(directory))
ProjectIndex(Project.open(root)).build()
server = create_server(root)
async with create_connected_server_and_client_session(
server, raise_exceptions=True
) as session:
missing = await session.call_tool(
"docforge_get_node", {"node_id": "research.question"}
)
workflow = root / "docs" / "content" / "workflow.md"
workflow.write_text(
workflow.read_text(encoding="utf-8") + "\nChanged after startup.\n",
encoding="utf-8",
)
repaired = await session.call_tool(
"docforge_get_node", {"node_id": "guide.workflow"}
)
self.assertEqual("missing_node", missing.structuredContent["error"]["code"])
self.assertEqual("ok", repaired.structuredContent["status"])
self.assertEqual("current", missing.structuredContent["staleness"])
self.assertEqual("current", repaired.structuredContent["staleness"])
self.assertTrue(missing.structuredContent["source_hash"])
self.assertTrue(repaired.structuredContent["source_hash"])
self.assertEqual(
"rebuilt",
repaired.structuredContent["synchronization"]["action"],
)
self.assertFalse(missing.isError)
self.assertFalse(repaired.isError)
async def test_output_limit_fails_without_returning_partial_content(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = self.copy_fixture("alpha", Path(directory))
descriptor = root / ".docforge" / "project.toml"
descriptor.write_text(
descriptor.read_text(encoding="utf-8").replace(
"max_context_tokens = 2000",
"max_context_tokens = 2000\nmax_tool_output_chars = 700",
),
encoding="utf-8",
)
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_get_contract", {})
payload = result.structuredContent
self.assertEqual("error", payload["status"])
self.assertEqual("result_too_large", payload["error"]["code"])
self.assertNotIn("canonical_paths", payload)
2026-07-29 04:24:06 -04:00
async def test_mutation_overflow_returns_exact_compact_success_receipts(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = self.copy_fixture("alpha", Path(directory))
descriptor = root / ".docforge" / "project.toml"
descriptor.write_text(
descriptor.read_text(encoding="utf-8").replace(
"max_context_tokens = 2000",
"max_context_tokens = 2000\nmax_tool_output_chars = 1600",
),
encoding="utf-8",
)
project = Project.open(root)
ProjectIndex(project).build()
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",
canonical_applier_id="alpha-editor",
),
raise_exceptions=True,
) as session:
created = await session.call_tool(
"docforge_create_changeset",
{"changeset_id": "compact-mutation"},
)
first = await session.call_tool(
"docforge_propose_node_update",
{
"changeset_id": "compact-mutation",
"expected_changeset_hash": created.structuredContent["changeset_hash"],
"node_id": "guide.workflow",
"expected_content_hash": node_hashes["guide.workflow"],
"metadata": None,
"content": "Updated workflow.\n\n" + ("bounded receipt evidence " * 200),
"relationship_changes": [],
"rationale": "Exercise exact compact append receipts.",
},
)
second = await session.call_tool(
"docforge_propose_node_update",
{
"changeset_id": "compact-mutation",
"expected_changeset_hash": first.structuredContent["changeset_hash"],
"node_id": "guide.foundation",
"expected_content_hash": node_hashes["guide.foundation"],
"metadata": None,
"content": "Updated foundation.\n\n" + ("second exact receipt " * 200),
"relationship_changes": [],
"rationale": "Prove the returned hash supports the next append.",
},
)
applied = await session.call_tool(
"docforge_apply_changeset",
{
"changeset_id": "compact-mutation",
"expected_changeset_hash": second.structuredContent["changeset_hash"],
},
)
for result in (first, second, applied):
payload = result.structuredContent
self.assertEqual("ok", payload["status"])
self.assertTrue(payload["mutation_committed"])
self.assertEqual("receipt", payload["result_mode"])
self.assertLessEqual(
len(json.dumps(payload, sort_keys=True, separators=(",", ":"))),
1600,
)
self.assertNotEqual(
first.structuredContent["changeset_hash"],
second.structuredContent["changeset_hash"],
)
self.assertTrue(applied.structuredContent["applied"])
self.assertEqual(
"applied",
applied.structuredContent["lifecycle"]["status"],
)
self.assertEqual(
"ok",
applied.structuredContent["derived_refresh"]["status"],
)
self.assertIn(
"Updated workflow.",
(root / "docs/content/workflow.md").read_text(encoding="utf-8"),
)
async def test_mutation_preflight_rejects_before_writing(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = self.copy_fixture("alpha", Path(directory))
descriptor = root / ".docforge" / "project.toml"
descriptor.write_text(
descriptor.read_text(encoding="utf-8").replace(
"max_context_tokens = 2000",
"max_context_tokens = 2000\nmax_tool_output_chars = 700",
),
encoding="utf-8",
)
ProjectIndex(Project.open(root)).build()
changeset_id = "must-not-exist-" + ("x" * 100)
async with create_connected_server_and_client_session(
create_server(root, "alpha-editor"),
raise_exceptions=True,
) as session:
result = await session.call_tool(
"docforge_create_changeset",
{"changeset_id": changeset_id},
)
payload = result.structuredContent
self.assertEqual("error", payload["status"])
self.assertEqual("result_too_large", payload["error"]["code"])
self.assertEqual("preflight", payload["error"]["details"]["stage"])
self.assertFalse(payload["error"]["details"]["mutation_committed"])
self.assertFalse((root / f".docforge/changesets/{changeset_id}.json").exists())
2026-07-22 02:58:51 -04:00
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"}
)
preview = await session.call_tool(
"docforge_preview_changeset",
{"changeset_id": "mcp-update", "view_id": "manual"},
)
undeclared = await session.call_tool(
"docforge_preview_changeset",
{"changeset_id": "mcp-update", "view_id": "not-declared"},
)
2026-07-22 02:58:51 -04:00
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"]],
)
self.assertEqual("current", preview.structuredContent["state"])
self.assertEqual(
".docforge/previews/mcp-update/manual.html",
preview.structuredContent["preview"]["path"],
)
self.assertTrue(preview.structuredContent["preview_identity"])
self.assertEqual("error", undeclared.structuredContent["status"])
self.assertEqual("unknown_render_view", undeclared.structuredContent["error"]["code"])
2026-07-22 02:58:51 -04:00
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())
self.assertTrue((root / ".docforge/previews/mcp-update/manual.html").is_file())
self.assertFalse((root / ".docforge/rendered/manual.html").exists())
async def test_relationship_only_mcp_tool_queues_no_content_rewrite(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = self.copy_fixture("alpha", Path(directory))
project = Project.open(root)
ProjectIndex(project).build()
workflow = next(
node 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"), raise_exceptions=True
) as session:
created = await session.call_tool(
"docforge_create_changeset", {"changeset_id": "mcp-relationship"}
)
proposed = await session.call_tool(
"docforge_propose_relationship_update",
{
"changeset_id": "mcp-relationship",
"expected_changeset_hash": created.structuredContent["changeset_hash"],
"node_id": "guide.workflow",
"expected_content_hash": workflow.content_hash,
"relationship_changes": [
{
"action": "remove",
"source_id": "guide.workflow",
"relation": "depends_on",
"target_id": "guide.foundation",
}
],
"rationale": "Exercise the relationship-only MCP boundary.",
},
)
diff = await session.call_tool(
"docforge_get_changeset_diff",
{"changeset_id": "mcp-relationship"},
)
self.assertEqual("ok", proposed.structuredContent["status"])
self.assertEqual("", diff.structuredContent["changes"][0]["content_diff"])
self.assertEqual({}, diff.structuredContent["changes"][0]["metadata"])
self.assertTrue((root / ".docforge/changesets/mcp-relationship.json").is_file())
self.assertFalse((root / ".docforge/rendered/manual.html").exists())
2026-07-22 02:58:51 -04:00
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_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))
ProjectIndex(Project.open(root)).build()
parameters = StdioServerParameters(
command=sys.executable,
args=["-m", "docforge.mcp_server", "--project-root", str(root)],
)
async with (
stdio_client(parameters) as (read, write),
ClientSession(read, write) as session,
):
await session.initialize()
result = await session.call_tool("docforge_project_info", {})
self.assertFalse(result.isError)
self.assertEqual("beta-notes", result.structuredContent["project_id"])
self.assertEqual(1, result.structuredContent["node_count"])
if __name__ == "__main__":
unittest.main()