From 5370a7faa228f11c3160bcb11abdf39bbd3344c3 Mon Sep 17 00:00:00 2001 From: Vishesh 'ironeagle' Bangotra Date: Wed, 21 Jan 2026 16:43:21 +0530 Subject: [PATCH] feat(mcp): add MCP JSON renderer and CLI support, update tests accordingly - Add MCPRenderer to generate MCP-native JSON bundles (index.json, nav.json, modules/*.json) - Expose MCPRenderer via public API and CLI (`generate-mcp` command) - Replace Markdown-based MCP output with structured JSON resources - Update MCP renderer type stubs to match new JSON-based implementation - Refactor MCP tests to validate JSON content, bundle structure, and navigation - Fix MCP module coverage test to use explicit project_root for reliable discovery --- docforge/cli/main.py | 48 +++++++- docforge/cli/main.pyi | 28 +++++ docforge/renderers/mcp_renderer.py | 116 +++++++++++------- docforge/renderers/mcp_renderer.pyi | 29 ++--- tests/renderers/mcp/test_mcp_content.py | 7 +- tests/renderers/mcp/test_mcp_idempotency.py | 4 +- .../renderers/mcp/test_mcp_module_coverage.py | 13 +- tests/renderers/mcp/test_mcp_structure.py | 9 +- 8 files changed, 180 insertions(+), 74 deletions(-) diff --git a/docforge/cli/main.py b/docforge/cli/main.py index 6205d3f..9d49664 100644 --- a/docforge/cli/main.py +++ b/docforge/cli/main.py @@ -9,7 +9,7 @@ from typing import Sequence, Optional import click from docforge.loaders import GriffeLoader, discover_module_paths -from docforge.renderers.mkdocs_renderer import MkDocsRenderer +from docforge.renderers import MkDocsRenderer, MCPRenderer from docforge.cli.mkdocs import mkdocs_cmd @@ -118,6 +118,52 @@ def generate( click.echo(f"Documentation sources generated in {docs_dir}") +# --------------------------------------------------------------------- +# mcp-build +# --------------------------------------------------------------------- + +@cli.command(name="generate-mcp") +@click.option( + "--module", + required=True, + help="Python module import path to document", +) +@click.option( + "--project-name", + help="Project name (defaults to first module)", +) +@click.option( + "--out-dir", + type=click.Path(path_type=Path), + default=Path("mcp_docs"), +) +def generate_mcp( + module: str, + project_name: str | None, + out_dir: Path, +) -> None: + """ + Generate MCP-compatible documentation resources for the specified module. + + Args: + module: The primary module path to document. + project_name: Optional project name override. + out_dir: Directory where MCP resources will be written. + """ + loader = GriffeLoader() + discovered_paths = discover_module_paths(module) + + project = loader.load_project( + discovered_paths, + project_name, + ) + + renderer = MCPRenderer() + renderer.generate_sources(project, out_dir) + + click.echo(f"MCP documentation resources generated in {out_dir}") + + # --------------------------------------------------------------------- # build # --------------------------------------------------------------------- diff --git a/docforge/cli/main.pyi b/docforge/cli/main.pyi index b16ff29..287bc51 100644 --- a/docforge/cli/main.pyi +++ b/docforge/cli/main.pyi @@ -47,6 +47,34 @@ def generate( ) -> None: """Generate documentation source files using MkDocs renderer.""" +@cli.command(name="generate-mcp") +@click.option( + "--module", + required=True, + help="Python module import path to document", +) +@click.option( + "--project-name", + help="Project name (defaults to first module)", +) +@click.option( + "--out-dir", + type=click.Path(path_type=Path), + default=Path("mcp_docs"), +) +def generate_mcp( + module: str, + project_name: str | None, + out_dir: Path, +) -> None: + """ + Generate MCP-compatible documentation resources for the specified module. + + Args: + module: The primary module path to document. + project_name: Optional project name override. + out_dir: Directory where MCP resources will be written. + """ @cli.command() @click.option( diff --git a/docforge/renderers/mcp_renderer.py b/docforge/renderers/mcp_renderer.py index ce592f8..650323e 100644 --- a/docforge/renderers/mcp_renderer.py +++ b/docforge/renderers/mcp_renderer.py @@ -1,78 +1,102 @@ +import json from pathlib import Path -from typing import Iterable +from typing import Dict, List from docforge.models import Project, Module, DocObject class MCPRenderer: """ - Renderer that emits documentation as MCP resources. + Renderer that emits MCP-native JSON resources from docforge models. """ name = "mcp" def generate_sources(self, project: Project, out_dir: Path) -> None: """ - Generate MCP-compatible resources for the project. - - Each module is rendered as a standalone MCP document. + Generate MCP-compatible JSON resources and navigation for the project. """ - out_dir.mkdir(parents=True, exist_ok=True) + modules_dir = out_dir / "modules" + modules_dir.mkdir(parents=True, exist_ok=True) + + nav: List[Dict[str, str]] = [] for module in project.get_all_modules(): - self._write_module(module, out_dir) + self._write_module(module, modules_dir) - def _write_module(self, module: Module, out_dir: Path) -> None: + nav.append({ + "module": module.path, + "resource": f"mcp://modules/{module.path}", + }) + + # Write nav.json + (out_dir / "nav.json").write_text( + self._json(nav), + encoding="utf-8", + ) + + # Write index.json + index = { + "project": project.name, + "type": "docforge-model", + "modules_count": len(nav), + "source": "docforge", + } + + (out_dir / "index.json").write_text( + self._json(index), + encoding="utf-8", + ) + + def _write_module(self, module: Module, modules_dir: Path) -> None: """ - Render a module and all contained objects. + Serialize a module into an MCP JSON resource. """ - resource_path = self._module_resource_path(module) - content = self._render_module(module) + payload = { + "module": module.path, + "content": self._render_module(module), + } - file_path = out_dir / resource_path - file_path.parent.mkdir(parents=True, exist_ok=True) - file_path.write_text(content, encoding="utf-8") + out = modules_dir / f"{module.path}.json" + out.parent.mkdir(parents=True, exist_ok=True) + out.write_text(self._json(payload), encoding="utf-8") - def _render_module(self, module: Module) -> str: + def _render_module(self, module: Module) -> Dict: """ - Render a module into MCP-friendly Markdown. + Render a Module into MCP-friendly structured data. """ - lines: list[str] = [] - - lines.append(f"# Module `{module.path}`\n") - - if module.docstring: - lines.append(module.docstring.strip() + "\n") + data: Dict = { + "path": module.path, + "docstring": module.docstring, + "objects": {}, + } for obj in module.get_all_objects(): - lines.extend(self._render_object(obj, level=2)) + data["objects"][obj.name] = self._render_object(obj) - return "\n".join(lines).strip() + "\n" + return data - def _render_object(self, obj: DocObject, level: int) -> Iterable[str]: + def _render_object(self, obj: DocObject) -> Dict: """ - Recursively render DocObjects. + Recursively render a DocObject into structured MCP data. """ - prefix = "#" * level - lines: list[str] = [] + data: Dict = { + "name": obj.name, + "kind": obj.kind, + "path": obj.path, + "signature": obj.signature, + "docstring": obj.docstring, + } - lines.append(f"{prefix} {obj.kind} `{obj.name}`") + members = list(obj.get_all_members()) + if members: + data["members"] = { + member.name: self._render_object(member) + for member in members + } - if obj.signature: - lines.append(f"```python\n{obj.signature}\n```") + return data - if obj.docstring: - lines.append(obj.docstring.strip()) - - for member in obj.get_all_members(): - lines.extend(self._render_object(member, level + 1)) - - return lines - - def _module_resource_path(self, module: Module) -> Path: - """ - Convert a module path into an MCP resource path. - Example: - docforge.models.module -> docforge/models/module.md - """ - return Path(module.path.replace(".", "/") + ".md") + @staticmethod + def _json(data: Dict) -> str: + return json.dumps(data, indent=2, ensure_ascii=False) diff --git a/docforge/renderers/mcp_renderer.pyi b/docforge/renderers/mcp_renderer.pyi index 0ec1375..3025fee 100644 --- a/docforge/renderers/mcp_renderer.pyi +++ b/docforge/renderers/mcp_renderer.pyi @@ -1,31 +1,26 @@ from pathlib import Path -from typing import Iterable +from typing import Dict, List from docforge.models import Project, Module, DocObject class MCPRenderer: - """ - Renderer that emits MCP-compatible documentation resources. - """ + """Renderer that emits MCP-native JSON resources from docforge models.""" name: str def generate_sources(self, project: Project, out_dir: Path) -> None: - """Generate MCP resources for the given project.""" + """Generate MCP-compatible JSON resources and navigation for the project.""" - def _write_module(self, module: Module, out_dir: Path) -> None: - """Write a single module as an MCP resource.""" + def _write_module(self, module: Module, modules_dir: Path) -> None: + """Serialize a module into an MCP JSON resource.""" - def _render_module(self, module: Module) -> str: - """Render a module and its contents into a text document.""" + def _render_module(self, module: Module) -> Dict: + """Render a Module into MCP-friendly structured data.""" - def _render_object( - self, - obj: DocObject, - level: int, - ) -> Iterable[str]: - """Recursively render a documented object and its members.""" + def _render_object(self, obj: DocObject) -> Dict: + """Recursively render a DocObject into structured MCP data.""" - def _module_resource_path(self, module: Module) -> Path: - """Compute the MCP resource path for a module.""" + @staticmethod + def _json(data: Dict) -> str: + """Serialize structured data to formatted JSON.""" diff --git a/tests/renderers/mcp/test_mcp_content.py b/tests/renderers/mcp/test_mcp_content.py index 1e60ead..1a6b5f6 100644 --- a/tests/renderers/mcp/test_mcp_content.py +++ b/tests/renderers/mcp/test_mcp_content.py @@ -1,3 +1,4 @@ +import json from pathlib import Path from docforge import MCPRenderer @@ -13,6 +14,8 @@ def test_mcp_file_content(tmp_path: Path): renderer.generate_sources(project, out_dir) - content = (out_dir / "testpkg" / "mod.md").read_text() + module_file = out_dir / "modules" / "testpkg.mod.json" + payload = json.loads(module_file.read_text()) - assert "# Module `testpkg.mod`" in content + assert payload["module"] == "testpkg.mod" + assert payload["content"]["path"] == "testpkg.mod" diff --git a/tests/renderers/mcp/test_mcp_idempotency.py b/tests/renderers/mcp/test_mcp_idempotency.py index 4b95c00..2b215bb 100644 --- a/tests/renderers/mcp/test_mcp_idempotency.py +++ b/tests/renderers/mcp/test_mcp_idempotency.py @@ -12,9 +12,9 @@ def test_mcp_idempotent(tmp_path: Path): renderer = MCPRenderer() renderer.generate_sources(project, out_dir) - first = (out_dir / "testpkg" / "mod.md").read_text() + first = (out_dir / "modules" / "testpkg.mod.json").read_text() renderer.generate_sources(project, out_dir) - second = (out_dir / "testpkg" / "mod.md").read_text() + second = (out_dir / "modules" / "testpkg.mod.json").read_text() assert first == second diff --git a/tests/renderers/mcp/test_mcp_module_coverage.py b/tests/renderers/mcp/test_mcp_module_coverage.py index 9164b5a..b7623d4 100644 --- a/tests/renderers/mcp/test_mcp_module_coverage.py +++ b/tests/renderers/mcp/test_mcp_module_coverage.py @@ -5,11 +5,13 @@ from docforge import MCPRenderer def test_mcp_emits_all_modules(tmp_path: Path) -> None: + project_root = Path(__file__).resolve().parents[3] loader = GriffeLoader() discovered_paths = discover_module_paths( "docforge", - Path(r"C:\Users\vishe\WorkSpace\code\aetos\doc-forge"), + project_root=project_root, ) + project = loader.load_project(discovered_paths) renderer = MCPRenderer() @@ -17,13 +19,16 @@ def test_mcp_emits_all_modules(tmp_path: Path) -> None: emitted = { p.relative_to(tmp_path).as_posix() - for p in tmp_path.rglob("*.md") + for p in (tmp_path / "modules").rglob("*.json") } expected = { - m.path.replace(".", "/") + ".md" + f"modules/{m.path}.json" for m in project.get_all_modules() } missing = expected - emitted - assert not missing, f"Missing MCP resources for modules: {missing}" + assert not missing, f"Missing MCP module JSON files: {missing}" + + # also assert nav.json exists + assert (tmp_path / "nav.json").exists() diff --git a/tests/renderers/mcp/test_mcp_structure.py b/tests/renderers/mcp/test_mcp_structure.py index c0197f2..341488f 100644 --- a/tests/renderers/mcp/test_mcp_structure.py +++ b/tests/renderers/mcp/test_mcp_structure.py @@ -14,5 +14,10 @@ def test_mcp_directory_structure(tmp_path: Path): renderer.generate_sources(project, out_dir) - assert (out_dir / "testpkg.md").exists() - assert (out_dir / "testpkg" / "sub.md").exists() + # Bundle-level files + assert (out_dir / "index.json").exists() + assert (out_dir / "nav.json").exists() + + # Module resources + assert (out_dir / "modules" / "testpkg.json").exists() + assert (out_dir / "modules" / "testpkg.sub.json").exists()