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

feat: establish read-only DocForge MCP foundation

This commit is contained in:
Andraxion 2026-07-22 01:29:32 -04:00
commit 9702ed1265
32 changed files with 3323 additions and 0 deletions

View file

@ -0,0 +1,31 @@
schema_version = 1
project_id = "alpha-docs"
title = "Alpha Documentation"
adapter = "generic"
[sources]
content_roots = ["docs/content"]
authority_files = ["POLICY.md"]
[derived]
cache_root = ".docforge/cache"
index = ".docforge/cache/index.sqlite3"
[graph]
allowed_relations = ["depends_on", "proves", "supersedes", "relates_to", "returns_to"]
[limits]
max_source_bytes = 100000
max_nodes = 100
max_query_chars = 200
max_results = 20
max_traversal_depth = 4
max_context_tokens = 2000
[[profiles]]
id = "active"
families = ["guide", "proof"]
statuses = ["active", "approved"]
required_nodes = ["guide.workflow"]
token_budget = 600
dependency_depth = 2

3
tests/fixtures/alpha/POLICY.md vendored Normal file
View file

@ -0,0 +1,3 @@
# Alpha policy
Canonical source wins over generated output.

View file

@ -0,0 +1,12 @@
+++
schema_version = 1
id = "guide.foundation"
title = "Documentation foundation"
family = "guide"
authority = "authoritative"
status = "approved"
tags = ["foundation", "canonical"]
summary = "Defines which Alpha files own documentation facts."
+++
Alpha stores canonical facts in its content directory. Derived indexes may be deleted and rebuilt.

View file

@ -0,0 +1,11 @@
[[nodes]]
schema_version = 1
id = "proof.validation"
title = "Validation proof"
family = "proof"
authority = "derived"
status = "approved"
tags = ["proof", "validation"]
summary = "Records the fixture validation result."
content = "The fixture contains unique nodes, valid relationships, and confined source paths."
proves = ["guide.workflow"]

View file

@ -0,0 +1,13 @@
+++
schema_version = 1
id = "guide.workflow"
title = "Editing workflow"
family = "guide"
authority = "approved_plan"
status = "active"
tags = ["workflow", "editing"]
summary = "Requires validation before documentation changes are integrated."
depends_on = ["guide.foundation"]
+++
Editors change canonical nodes, validate relationships, inspect the diff, and rebuild derived output.

View file

@ -0,0 +1,31 @@
schema_version = 1
project_id = "beta-notes"
title = "Beta Research Notes"
adapter = "generic"
[sources]
content_roots = ["notes"]
authority_files = []
[derived]
cache_root = ".docforge/cache"
index = ".docforge/cache/index.sqlite3"
[graph]
allowed_relations = ["depends_on", "relates_to"]
[limits]
max_source_bytes = 100000
max_nodes = 50
max_query_chars = 200
max_results = 20
max_traversal_depth = 3
max_context_tokens = 1000
[[profiles]]
id = "overview"
families = ["research"]
statuses = ["current"]
required_nodes = ["research.question"]
token_budget = 400
dependency_depth = 1

12
tests/fixtures/beta/notes/question.md vendored Normal file
View file

@ -0,0 +1,12 @@
+++
schema_version = 1
id = "research.question"
title = "Primary question"
family = "research"
authority = "authoritative"
status = "current"
tags = ["question"]
summary = "Defines the question currently being investigated."
+++
Which material retains heat longest under the controlled fixture conditions?

295
tests/test_core.py Normal file
View file

@ -0,0 +1,295 @@
from __future__ import annotations
import contextlib
import io
import json
import shutil
import sqlite3
import sys
import tempfile
import unittest
from pathlib import Path
from unittest import mock
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "src"))
from docforge.cli import main # noqa: E402
from docforge.context import compile_context # noqa: E402
from docforge.errors import DocForgeError # noqa: E402
from docforge.index import ProjectIndex # noqa: E402
from docforge.project import Project # noqa: E402
FIXTURES = ROOT / "tests" / "fixtures"
class DocForgeCoreTests(unittest.TestCase):
def copy_fixture(self, name: str, destination: Path) -> Path:
root = destination / name
shutil.copytree(FIXTURES / name, root)
return root
def test_two_projects_load_distinct_confined_graphs(self) -> None:
alpha = Project.open(FIXTURES / "alpha").load()
beta = Project.open(FIXTURES / "beta").load()
self.assertEqual("alpha-docs", alpha.descriptor.project_id)
self.assertEqual(
["guide.foundation", "guide.workflow", "proof.validation"],
[node.node_id for node in alpha.nodes],
)
self.assertEqual(["research.question"], [node.node_id for node in beta.nodes])
self.assertNotEqual(alpha.source_hash, beta.source_hash)
self.assertNotIn("research.question", {node.node_id for node in alpha.nodes})
def test_missing_revision_tool_does_not_block_project_loading(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = self.copy_fixture("alpha", Path(directory))
with mock.patch("docforge.project.subprocess.run", side_effect=OSError):
snapshot = Project.open(root).load()
self.assertEqual("unversioned", snapshot.revision)
def test_descriptor_rejects_parent_and_absolute_paths(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = self.copy_fixture("alpha", Path(directory))
descriptor = root / ".docforge" / "project.toml"
original = descriptor.read_text(encoding="utf-8")
for unsafe in ("../outside", "/tmp/outside"):
with self.subTest(unsafe=unsafe):
descriptor.write_text(
original.replace(
'content_roots = ["docs/content"]', f'content_roots = ["{unsafe}"]'
),
encoding="utf-8",
)
with self.assertRaisesRegex(DocForgeError, "inside the project root"):
Project.open(root)
def test_descriptor_rejects_symbolic_link_escape(self) -> None:
with tempfile.TemporaryDirectory() as directory:
parent = Path(directory)
root = self.copy_fixture("alpha", parent)
outside = parent / "outside"
outside.mkdir()
link = root / "escaped"
link.symlink_to(outside, target_is_directory=True)
descriptor = root / ".docforge" / "project.toml"
descriptor.write_text(
descriptor.read_text(encoding="utf-8").replace(
'content_roots = ["docs/content"]', 'content_roots = ["escaped"]'
),
encoding="utf-8",
)
with self.assertRaisesRegex(DocForgeError, "outside the project root"):
Project.open(root)
def test_descriptor_rejects_unknown_fields_cache_overlap_and_mid_session_change(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = self.copy_fixture("alpha", Path(directory))
descriptor = root / ".docforge" / "project.toml"
original = descriptor.read_text(encoding="utf-8")
descriptor.write_text(original + "\nunknown_setting = true\n", encoding="utf-8")
with self.assertRaisesRegex(DocForgeError, "unknown fields"):
Project.open(root)
descriptor.write_text(
original.replace(
'cache_root = ".docforge/cache"', 'cache_root = "docs/content/cache"'
).replace(
'index = ".docforge/cache/index.sqlite3"',
'index = "docs/content/cache/index.sqlite3"',
),
encoding="utf-8",
)
with self.assertRaisesRegex(DocForgeError, "must not overlap"):
Project.open(root)
descriptor.write_text(original, encoding="utf-8")
project = Project.open(root)
descriptor.write_text(original + "\n", encoding="utf-8")
with self.assertRaisesRegex(DocForgeError, "changed after"):
project.load()
with self.assertRaisesRegex(DocForgeError, "does not exist"):
Project.open(root / "missing")
def test_duplicate_nodes_broken_edges_and_dependency_cycles_fail(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = self.copy_fixture("alpha", Path(directory))
content = root / "docs" / "content"
duplicate = content / "duplicate.md"
duplicate.write_text((content / "foundation.md").read_text(), encoding="utf-8")
with self.assertRaisesRegex(DocForgeError, "unique"):
Project.open(root).load()
duplicate.unlink()
workflow = content / "workflow.md"
original = workflow.read_text(encoding="utf-8")
workflow.write_text(
original.replace(
'depends_on = ["guide.foundation"]', 'depends_on = ["missing.node"]'
),
encoding="utf-8",
)
with self.assertRaisesRegex(DocForgeError, "missing nodes"):
Project.open(root).load()
workflow.write_text(original, encoding="utf-8")
foundation = content / "foundation.md"
foundation.write_text(
foundation.read_text(encoding="utf-8").replace(
'summary = "Defines which Alpha files own documentation facts."',
'summary = "Defines which Alpha files own documentation facts."\n'
'depends_on = ["guide.workflow"]',
),
encoding="utf-8",
)
with self.assertRaisesRegex(DocForgeError, "cycle"):
Project.open(root).load()
def test_index_build_is_repeatable_and_validates_every_row(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = self.copy_fixture("alpha", Path(directory))
index = ProjectIndex(Project.open(root))
first = index.build()
second = index.build()
validated = index.check()
for key in ("source_hash", "node_hash", "node_count", "edge_hash", "edge_count"):
self.assertEqual(first[key], second[key])
self.assertEqual(first[key], validated[key])
self.assertEqual(3, validated["node_count"])
self.assertEqual(2, validated["edge_count"])
def test_index_rejects_tampered_rows_and_another_project_cache(self) -> None:
with tempfile.TemporaryDirectory() as directory:
parent = Path(directory)
alpha_root = self.copy_fixture("alpha", parent)
beta_root = self.copy_fixture("beta", parent)
alpha = ProjectIndex(Project.open(alpha_root))
alpha.build()
with contextlib.closing(sqlite3.connect(alpha.path)) as connection:
connection.execute(
"UPDATE nodes SET title = 'Tampered' WHERE node_id = 'guide.workflow'"
)
connection.commit()
with self.assertRaisesRegex(DocForgeError, "do not match"):
alpha.check()
alpha.build()
beta = ProjectIndex(Project.open(beta_root))
beta.path.parent.mkdir(parents=True)
shutil.copy2(alpha.path, beta.path)
with self.assertRaisesRegex(DocForgeError, "does not match"):
beta.check()
def test_stale_source_fails_closed_and_failed_rebuild_preserves_index(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = self.copy_fixture("alpha", Path(directory))
index = ProjectIndex(Project.open(root))
index.build()
index_bytes = index.path.read_bytes()
workflow = root / "docs" / "content" / "workflow.md"
workflow.write_text(
workflow.read_text() + "\nChanged after indexing.\n", encoding="utf-8"
)
with self.assertRaisesRegex(DocForgeError, "does not match"):
index.check()
workflow.write_text("invalid", encoding="utf-8")
with self.assertRaises(DocForgeError):
index.build()
self.assertEqual(index_bytes, index.path.read_bytes())
def test_lookup_search_filter_and_traversal_are_deterministic(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = self.copy_fixture("alpha", Path(directory))
index = ProjectIndex(Project.open(root))
index.build()
node = index.get_node("guide.workflow")["node"]
self.assertEqual("Editing workflow", node["title"])
search = index.search("canonical nodes", limit=10)
self.assertEqual(["guide.workflow"], [item["node_id"] for item in search["results"]])
filtered = index.filter_nodes(family="proof", tag="validation")
self.assertEqual(
["proof.validation"], [item["node_id"] for item in filtered["results"]]
)
dependencies = index.dependencies("guide.workflow", depth=2)
self.assertEqual(
["guide.foundation"], [item["node_id"] for item in dependencies["results"]]
)
backlinks = index.backlinks("guide.workflow")
self.assertEqual(
["proof.validation"], [edge["source_id"] for edge in backlinks["edges"]]
)
impact = index.impact("guide.foundation", depth=2)
self.assertEqual(
["guide.workflow", "proof.validation"],
[item["node_id"] for item in impact["results"]],
)
def test_query_rechecks_source_identity_before_returning(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = self.copy_fixture("alpha", Path(directory))
index = ProjectIndex(Project.open(root))
checked = index.build()
changed = {**checked, "source_hash": "0" * 64}
with (
mock.patch.object(index, "check", side_effect=[checked, changed]),
self.assertRaisesRegex(DocForgeError, "changed during the query"),
):
index.get_node("guide.workflow")
def test_source_set_change_during_load_fails_closed(self) -> None:
project = Project.open(FIXTURES / "alpha")
sources = project._canonical_source_paths()
invented = project.descriptor.root / "docs" / "content" / "invented.md"
with (
mock.patch.object(
project, "_canonical_source_paths", side_effect=[sources, (*sources, invented)]
),
self.assertRaisesRegex(DocForgeError, "source set changed"),
):
project.load()
def test_context_is_bounded_cited_deterministic_and_reports_omissions(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = self.copy_fixture("alpha", Path(directory))
index = ProjectIndex(Project.open(root))
index.build()
first = compile_context(index, "active", budget=180)
second = compile_context(index, "active", budget=180)
self.assertEqual(first, second)
self.assertLessEqual(first["estimated_tokens"], 180)
self.assertEqual("guide.workflow", first["entries"][0]["node_id"])
self.assertEqual("required by profile", first["entries"][0]["reason"])
self.assertTrue(first["entries"][0]["source_path"])
self.assertTrue(first["entries"][0]["content_hash"])
self.assertTrue(first["omissions"])
with self.assertRaisesRegex(DocForgeError, "required node"):
compile_context(index, "active", budget=10)
def test_cli_json_is_repeatable_and_project_scoped(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = self.copy_fixture("beta", Path(directory))
outputs: list[str] = []
for _ in range(2):
stream = io.StringIO()
with contextlib.redirect_stdout(stream):
self.assertEqual(0, main(["--project-root", str(root), "validate"]))
outputs.append(stream.getvalue())
self.assertEqual(outputs[0], outputs[1])
result = json.loads(outputs[0])
self.assertEqual("beta-notes", result["project_id"])
self.assertEqual(1, result["node_count"])
if __name__ == "__main__":
unittest.main()

155
tests/test_mcp_server.py Normal file
View file

@ -0,0 +1,155 @@
from __future__ import annotations
import shutil
import sys
import tempfile
import unittest
from pathlib import Path
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
from docforge.mcp_server import CONTENT_WARNING, READ_TOOLS, create_server
from docforge.project import Project
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
async def test_protocol_lists_only_the_fixed_read_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)
self.assertEqual(READ_TOOLS, names)
self.assertFalse(
any(
token in name
for name in names
for token in ("write", "apply", "commit", "push", "deploy", "propose")
)
)
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_search", {"query": "canonical nodes", "limit": 5}),
("docforge_filter_nodes", {"family": "proof", "tag": "validation"}),
("docforge_backlinks", {"node_id": "guide.workflow"}),
("docforge_dependencies", {"node_id": "guide.workflow", "depth": 2}),
("docforge_impact", {"node_id": "guide.foundation", "depth": 2}),
("docforge_get_context", {"profile": "active", "budget": 180}),
("docforge_validate_project", {}),
("docforge_render_status", {}),
)
async with create_connected_server_and_client_session(
create_server(root), raise_exceptions=True
) as session:
results = [await session.call_tool(name, arguments) for name, arguments in calls]
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"])
context = results[8].structuredContent
self.assertLessEqual(context["estimated_tokens"], 180)
self.assertTrue(context["omissions"])
async def test_missing_node_and_stale_index_are_structured_failures(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",
)
stale = await session.call_tool("docforge_get_node", {"node_id": "guide.workflow"})
self.assertEqual("missing_node", missing.structuredContent["error"]["code"])
self.assertEqual("stale_index", stale.structuredContent["error"]["code"])
self.assertEqual("current", missing.structuredContent["staleness"])
self.assertEqual("stale", stale.structuredContent["staleness"])
self.assertTrue(missing.structuredContent["source_hash"])
self.assertTrue(stale.structuredContent["source_hash"])
self.assertFalse(missing.isError)
self.assertFalse(stale.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)
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()