feat: establish read-only DocForge MCP foundation
This commit is contained in:
commit
9702ed1265
32 changed files with 3323 additions and 0 deletions
31
tests/fixtures/alpha/.docforge/project.toml
vendored
Normal file
31
tests/fixtures/alpha/.docforge/project.toml
vendored
Normal 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
3
tests/fixtures/alpha/POLICY.md
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# Alpha policy
|
||||
|
||||
Canonical source wins over generated output.
|
||||
12
tests/fixtures/alpha/docs/content/foundation.md
vendored
Normal file
12
tests/fixtures/alpha/docs/content/foundation.md
vendored
Normal 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.
|
||||
11
tests/fixtures/alpha/docs/content/proof.toml
vendored
Normal file
11
tests/fixtures/alpha/docs/content/proof.toml
vendored
Normal 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"]
|
||||
13
tests/fixtures/alpha/docs/content/workflow.md
vendored
Normal file
13
tests/fixtures/alpha/docs/content/workflow.md
vendored
Normal 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.
|
||||
31
tests/fixtures/beta/.docforge/project.toml
vendored
Normal file
31
tests/fixtures/beta/.docforge/project.toml
vendored
Normal 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
12
tests/fixtures/beta/notes/question.md
vendored
Normal 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
295
tests/test_core.py
Normal 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
155
tests/test_mcp_server.py
Normal 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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue