diff --git a/README.md b/README.md index 7b80a92..b3df9c6 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,12 @@ path without modification. Adapters gain incremental performance only when they implement the source manifest and extraction methods. Incremental adapters must retain `load_projection()` as their clean-rebuild fallback and equivalence oracle. +MCP bindings can additionally select `--no-ast` when an owner wants to preserve an existing +non-AST adapter. The binding advertises that policy to clients, forbids adapter rewrites that add +AST, Tree-sitter, compiler-AST, or function-Logic extraction, blocks the Logic tool, and rejects +nonempty Logic publication. Complete-projection adapters continue unchanged, and non-AST +incremental fingerprinting and caching remain allowed. + ## Graph views The browser presents the primary architecture graph through three complementary views and loads a diff --git a/docs/MCP_CONTRACT.md b/docs/MCP_CONTRACT.md index ff20dc3..a9a10f3 100644 --- a/docs/MCP_CONTRACT.md +++ b/docs/MCP_CONTRACT.md @@ -162,3 +162,32 @@ does not expose canonical application. DocForge pins the official stable Python MCP SDK to the compatible `mcp>=1.28,<2` release line. Migration to a later major release requires a separate contract and protocol compatibility review. + +## Preserved no-AST bindings + +An owner may start the generic MCP server with `--no-ast`: + +```bash +docforge-mcp --project-root /absolute/project --no-ast +``` + +Project-owned integrations select the same immutable process policy with +`create_project_server(..., no_ast=True)` or `create_read_only_server(..., no_ast=True)`. + +The policy preserves the current adapter extraction strategy. It does not require an adapter API +migration and does not disable complete-projection loading or non-AST incremental fingerprinting, +invalidation, and caching. + +Both `docforge_bootstrap` and `docforge_get_contract` report the exact policy. MCP server +instructions tell clients not to add Python AST, Tree-sitter, compiler-AST, or function-Logic +extraction. Under this binding: + +- `docforge_get_logic` returns `adapter_policy_forbids_logic`; +- a nonempty Logic projection is rejected before index publication; +- `adapter_ast_upgrade` and `function_logic_extraction` appear as excluded operations; and +- changing the policy requires changing the process configuration and starting a new MCP process. + +The policy governs the DocForge binding and conforming MCP clients. DocForge still exposes no +filesystem sandbox and cannot prevent an unrelated process with direct repository write access +from editing adapter files. Repository permissions and project instructions remain responsible for +that broader boundary. diff --git a/docs/USER_MANUAL.md b/docs/USER_MANUAL.md index 6048c36..9cecd79 100644 --- a/docs/USER_MANUAL.md +++ b/docs/USER_MANUAL.md @@ -656,6 +656,25 @@ traversal. See [Incremental Adapter Indexing](INCREMENTAL_INDEXING.md) for the complete contract, cache invalidation rules, manual-application lifecycle, and lazy Logic boundary. +### Preserving an older non-AST adapter + +Use `--no-ast` on the MCP binding when the project owner wants the existing adapter preserved +without AST, Tree-sitter, compiler-AST, or function-Logic upgrades: + +```bash +docforge-mcp --project-root /absolute/project --no-ast +``` + +For a project-owned server, pass `no_ast=True` to `create_project_server()` or +`create_read_only_server()`. Bootstrap and contract responses then expose +`mode=preserve-no-ast`. The Logic tool is blocked, and DocForge refuses to publish nonempty Logic +projections. + +This policy does not disable the Release 1 `load_projection()` path. It also permits incremental +fingerprinting and caching when those mechanisms do not add AST analysis. The adapter can +therefore benefit from current synchronization, proposals, application, rendering, and graph tools +without a source-analysis rewrite. + ## Troubleshooting ### `stale_index` or `visualization_stale` diff --git a/src/docforge/index.py b/src/docforge/index.py index 3e4c9cf..a2e9a79 100644 --- a/src/docforge/index.py +++ b/src/docforge/index.py @@ -107,8 +107,9 @@ def _status( class ProjectIndex: """A disposable index that always checks current canonical source before queries.""" - def __init__(self, project: ProjectService) -> None: + def __init__(self, project: ProjectService, *, allow_logic: bool = True) -> None: self.project = project + self.allow_logic = allow_logic self._verified_index_signature: tuple[int, int, int, int, int] | None = None @property @@ -391,7 +392,16 @@ class ProjectIndex: def _logic_projections(self) -> tuple[LogicProjection, ...]: if isinstance(self.project, LogicProject): - return self.project.logic_projections() + projections = self.project.logic_projections() + if projections and not self.allow_logic: + raise DocForgeError( + "adapter_policy_forbids_logic", + ( + "This index preserves a no-AST adapter and refuses function-Logic " + "publication" + ), + ) + return projections return () def check(self, *, verify_rows: bool = True) -> dict[str, object]: diff --git a/src/docforge/mcp_server.py b/src/docforge/mcp_server.py index 15a2c7b..ba249f2 100644 --- a/src/docforge/mcp_server.py +++ b/src/docforge/mcp_server.py @@ -112,9 +112,10 @@ class DocForgeService: context_provider: ContextProvider = compile_context, tool_surface: tuple[str, ...] | None = None, binding_metadata: Mapping[str, object] | None = None, + no_ast: bool = False, ) -> None: self.project = project - self.index = ProjectIndex(self.project) + self.index = ProjectIndex(self.project, allow_logic=not no_ast) self.changesets = ChangesetStore(self.project, proposal_writer) self.rendering = RenderService(self.project, self.changesets) self.application = CanonicalApplicationService( @@ -125,11 +126,37 @@ class DocForgeService: self.visualization = ViewerManagerClient(self.index) self.context_provider = context_provider self.binding_metadata = dict(binding_metadata or {}) + self.no_ast = no_ast self.tool_surface = tool_surface or ( *ALL_TOOLS, *(APPLICATION_TOOLS if self.application.enabled else ()), ) + def adapter_policy(self) -> dict[str, object]: + """Return the immutable adapter-evolution policy for this MCP binding.""" + + if not self.no_ast: + return { + "mode": "standard", + "ast_analysis": "allowed", + "logic_projection": "allowed", + "incremental_extraction": "allowed", + "adapter_rewrite": "not_requested", + } + return { + "mode": "preserve-no-ast", + "ast_analysis": "forbidden", + "logic_projection": "forbidden", + "incremental_extraction": "allowed", + "adapter_rewrite": "forbidden", + "blocked_tools": ["docforge_get_logic"], + "instruction": ( + "Preserve the existing adapter extraction strategy. Do not add Python AST, " + "Tree-sitter, compiler-AST, or function-Logic extraction. Non-AST incremental " + "fingerprinting and caching remain allowed." + ), + } + def invoke( self, operation: Callable[[], dict[str, object]], @@ -238,7 +265,25 @@ class DocForgeService: "index_path": str(snapshot.descriptor.index_path), "changeset_root": str(snapshot.descriptor.changeset_root), **self.binding_metadata, + "adapter_policy": self.adapter_policy(), } + recommended_workflow = [ + "docforge_get_context or targeted read tools", + "make and verify one coherent implementation slice", + "docforge_sync", + "docforge_register_changes", + "docforge_get_changeset_diff", + "docforge_apply_changeset", + "docforge_bootstrap", + ] + if self.no_ast: + recommended_workflow.insert( + 1, + ( + "preserve the current adapter; do not add AST, Tree-sitter, " + "compiler-AST, or function-Logic extraction" + ), + ) return { "status": "ok", "project_id": snapshot.descriptor.project_id, @@ -249,18 +294,11 @@ class DocForgeService: "source_hash": snapshot.source_hash, "binding": binding, "canonical_paths": [str(path) for path in snapshot.descriptor.content_roots], + "adapter_policy": self.adapter_policy(), "proposal_access": self.changesets.access(), "canonical_application_access": self.application.access(), "synchronization": synchronized["synchronization"], - "recommended_workflow": [ - "docforge_get_context or targeted read tools", - "make and verify one coherent implementation slice", - "docforge_sync", - "docforge_register_changes", - "docforge_get_changeset_diff", - "docforge_apply_changeset", - "docforge_bootstrap", - ], + "recommended_workflow": recommended_workflow, } return self.invoke(operation, synchronize=False) @@ -310,6 +348,7 @@ class DocForgeService: "authority_rule": ( "Canonical project files own facts; DocForge results are derived." ), + "adapter_policy": self.adapter_policy(), "canonical_paths": [ *(relative(path) for path in snapshot.descriptor.content_roots), *(relative(path) for path in snapshot.descriptor.authority_files), @@ -349,6 +388,7 @@ class DocForgeService: else () ) + (READ_ONLY_EXCLUDED_OPERATIONS if self.tool_surface == READ_TOOLS else ()) + + (("adapter_ast_upgrade", "function_logic_extraction") if self.no_ast else ()) ), "proposal_access": self.changesets.access(), "canonical_application_access": self.application.access(), @@ -359,6 +399,23 @@ class DocForgeService: return self.invoke(operation) + def get_logic(self, owner_node_id: str) -> dict[str, object]: + """Return one Logic projection unless the binding preserves a no-AST adapter.""" + + if self.no_ast: + + def forbidden() -> dict[str, object]: + raise DocForgeError( + "adapter_policy_forbids_logic", + ( + "This MCP binding preserves a no-AST adapter and forbids function-Logic " + "extraction" + ), + ) + + return self.invoke(forbidden, synchronize=False) + return self.invoke(lambda: self.index.get_logic(owner_node_id)) + def validate_project(self) -> dict[str, object]: def operation() -> dict[str, object]: snapshot = self.project.load() @@ -437,6 +494,13 @@ def _create_bound_server(service: DocForgeService, *, read_only: bool) -> FastMC "docforge_register_changes creates a complete proposal atomically. " "This server exposes no arbitrary renderer, shell, Git, deployment, publication, " "or project switching." + + ( + " This binding preserves the existing adapter and forbids AST, Tree-sitter, " + "compiler-AST, and function-Logic extraction changes. Do not rewrite or upgrade " + "the adapter to add those capabilities." + if service.no_ast + else "" + ) ), json_response=True, ) @@ -475,7 +539,7 @@ def _create_bound_server(service: DocForgeService, *, read_only: bool) -> FastMC def get_logic(owner_node_id: str) -> dict[str, Any]: """Return the lazy control-flow projection owned by one function or method.""" - return service.invoke(lambda: service.index.get_logic(owner_node_id)) + return service.get_logic(owner_node_id) @server.tool(name="docforge_search") def search(query: str, limit: int | None = None) -> dict[str, Any]: @@ -822,6 +886,7 @@ def create_server( proposal_writer: str | None = None, *, canonical_applier_id: str | None = None, + no_ast: bool = False, ) -> FastMCP: project = Project.open(project_root) return create_project_server( @@ -835,6 +900,7 @@ def create_server( "server_module": "docforge.mcp_server", "adapter_mode": "generic", }, + no_ast=no_ast, ) @@ -846,6 +912,7 @@ def create_project_server( canonical_applier: CanonicalApplier | None = None, context_provider: ContextProvider = compile_context, binding_metadata: Mapping[str, object] | None = None, + no_ast: bool = False, ) -> FastMCP: """Create the full fixed MCP surface for one explicitly configured project service.""" @@ -856,6 +923,7 @@ def create_project_server( canonical_applier=canonical_applier, context_provider=context_provider, binding_metadata=binding_metadata, + no_ast=no_ast, ) return _create_bound_server(service, read_only=False) @@ -865,6 +933,7 @@ def create_read_only_server( *, context_provider: ContextProvider = compile_context, binding_metadata: Mapping[str, object] | None = None, + no_ast: bool = False, ) -> FastMCP: """Create an adapter-capable MCP server exposing only the fixed read tool surface.""" @@ -873,6 +942,7 @@ def create_read_only_server( context_provider=context_provider, tool_surface=READ_TOOLS, binding_metadata=binding_metadata, + no_ast=no_ast, ) return _create_bound_server(service, read_only=True) @@ -882,11 +952,20 @@ def main() -> None: parser.add_argument("--project-root", type=Path, required=True) parser.add_argument("--proposal-writer") parser.add_argument("--canonical-applier") + parser.add_argument( + "--no-ast", + action="store_true", + help=( + "Preserve the existing adapter and forbid AST, Tree-sitter, compiler-AST, " + "and function-Logic extraction changes" + ), + ) arguments = parser.parse_args() create_server( arguments.project_root, arguments.proposal_writer, canonical_applier_id=arguments.canonical_applier, + no_ast=arguments.no_ast, ).run(transport="stdio") diff --git a/tests/test_adapter_contract.py b/tests/test_adapter_contract.py index 5ac3ba1..f4319d8 100644 --- a/tests/test_adapter_contract.py +++ b/tests/test_adapter_contract.py @@ -366,6 +366,7 @@ class AdapterContractTests(unittest.TestCase): self.assertTrue(visual_logic["available"]) self.assertEqual("entry", visual_logic["root"]) self.assertEqual("return", visual_logic["edges"][0]["relation"]) + loader.extract_calls.clear() cache_path = root / ".cache" / "incremental" / "extractions.json" cache_modified = cache_path.stat().st_mtime_ns @@ -403,6 +404,20 @@ class AdapterContractTests(unittest.TestCase): index.get_node("guide.workflow")["node"]["source_path"], ) + def test_no_ast_index_policy_rejects_logic_publication(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory).resolve() + project = AdapterProject( + IncrementalLoader(root), + cache_root=root / ".cache" / "no-ast", + ) + + with self.assertRaises(DocForgeError) as captured: + ProjectIndex(project, allow_logic=False).build() + + self.assertEqual("adapter_policy_forbids_logic", captured.exception.code) + self.assertFalse(project.descriptor.index_path.exists()) + def test_fast_incremental_reads_reverify_a_changed_index_file(self) -> None: with tempfile.TemporaryDirectory() as directory: root = Path(directory).resolve() diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index 8193344..63601d9 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -78,6 +78,44 @@ class DocForgeMcpTests(unittest.IsolatedAsyncioTestCase): ) ) + 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)) + ProjectIndex(Project.open(root)).build() + 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))