From 427e407d265e3f910e620662a6ad008d463a7781 Mon Sep 17 00:00:00 2001 From: Vishesh 'ironeagle' Bangotra Date: Wed, 21 Jan 2026 16:18:25 +0530 Subject: [PATCH] added mcp_renderer --- docforge.nav.yml | 3 +- docforge/__init__.py | 3 +- docforge/__init__.pyi | 3 +- docforge/renderers/__init__.py | 2 + docforge/renderers/__init__.pyi | 2 + docforge/renderers/mcp_renderer.py | 78 +++++++++++++++++++ docforge/renderers/mcp_renderer.pyi | 31 ++++++++ docs/docforge/renderers/mcp_renderer.md | 3 + docs/docforge/renderers/mkdocs.md | 3 - docs/docforge/renderers/mkdocs_renderer.md | 3 + mkdocs.yml | 3 +- tests/renderers/mcp/__init__.py | 0 tests/renderers/mcp/test_mcp_content.py | 18 +++++ tests/renderers/mcp/test_mcp_idempotency.py | 20 +++++ .../renderers/mcp/test_mcp_module_coverage.py | 29 +++++++ tests/renderers/mcp/test_mcp_structure.py | 18 +++++ tests/renderers/mkdocs/__init__.py | 0 17 files changed, 212 insertions(+), 7 deletions(-) create mode 100644 docforge/renderers/mcp_renderer.py create mode 100644 docforge/renderers/mcp_renderer.pyi create mode 100644 docs/docforge/renderers/mcp_renderer.md delete mode 100644 docs/docforge/renderers/mkdocs.md create mode 100644 docs/docforge/renderers/mkdocs_renderer.md create mode 100644 tests/renderers/mcp/__init__.py create mode 100644 tests/renderers/mcp/test_mcp_content.py create mode 100644 tests/renderers/mcp/test_mcp_idempotency.py create mode 100644 tests/renderers/mcp/test_mcp_module_coverage.py create mode 100644 tests/renderers/mcp/test_mcp_structure.py create mode 100644 tests/renderers/mkdocs/__init__.py diff --git a/docforge.nav.yml b/docforge.nav.yml index 1fd3d8a..a22b7f7 100644 --- a/docforge.nav.yml +++ b/docforge.nav.yml @@ -16,7 +16,8 @@ groups: Renderers: - docforge/renderers/index.md - docforge/renderers/base.md - - docforge/renderers/mkdocs.md + - docforge/renderers/mkdocs_renderer.md + - docforge/renderers/mcp_renderer.md CLI: - docforge/cli/index.md - docforge/cli/main.md diff --git a/docforge/__init__.py b/docforge/__init__.py index 6bd3653..87ba0a8 100644 --- a/docforge/__init__.py +++ b/docforge/__init__.py @@ -52,7 +52,7 @@ pip install doc-forge """ from .loaders import GriffeLoader, discover_module_paths -from .renderers import MkDocsRenderer +from .renderers import MkDocsRenderer, MCPRenderer from .cli import main from . import models @@ -60,6 +60,7 @@ __all__ = [ "GriffeLoader", "discover_module_paths", "MkDocsRenderer", + "MCPRenderer", "models", "main", ] diff --git a/docforge/__init__.pyi b/docforge/__init__.pyi index dd3697c..5823fb1 100644 --- a/docforge/__init__.pyi +++ b/docforge/__init__.pyi @@ -1,5 +1,5 @@ from .loaders import GriffeLoader, discover_module_paths -from .renderers import MkDocsRenderer +from .renderers import MkDocsRenderer, MCPRenderer from .cli import main from . import models @@ -7,6 +7,7 @@ __all__ = [ "GriffeLoader", "discover_module_paths", "MkDocsRenderer", + "MCPRenderer", "models", "main", ] diff --git a/docforge/renderers/__init__.py b/docforge/renderers/__init__.py index 7c2e73b..e2dc17a 100644 --- a/docforge/renderers/__init__.py +++ b/docforge/renderers/__init__.py @@ -18,7 +18,9 @@ To add a new renderer, implement the `DocRenderer` protocol defined in """ from .mkdocs_renderer import MkDocsRenderer +from .mcp_renderer import MCPRenderer __all__ = [ "MkDocsRenderer", + "MCPRenderer", ] diff --git a/docforge/renderers/__init__.pyi b/docforge/renderers/__init__.pyi index dc195d3..f0135ff 100644 --- a/docforge/renderers/__init__.pyi +++ b/docforge/renderers/__init__.pyi @@ -1,5 +1,7 @@ from .mkdocs_renderer import MkDocsRenderer +from .mcp_renderer import MCPRenderer __all__ = [ "MkDocsRenderer", + "MCPRenderer", ] diff --git a/docforge/renderers/mcp_renderer.py b/docforge/renderers/mcp_renderer.py new file mode 100644 index 0000000..ce592f8 --- /dev/null +++ b/docforge/renderers/mcp_renderer.py @@ -0,0 +1,78 @@ +from pathlib import Path +from typing import Iterable + +from docforge.models import Project, Module, DocObject + + +class MCPRenderer: + """ + Renderer that emits documentation as MCP resources. + """ + + 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. + """ + out_dir.mkdir(parents=True, exist_ok=True) + + for module in project.get_all_modules(): + self._write_module(module, out_dir) + + def _write_module(self, module: Module, out_dir: Path) -> None: + """ + Render a module and all contained objects. + """ + resource_path = self._module_resource_path(module) + 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") + + def _render_module(self, module: Module) -> str: + """ + Render a module into MCP-friendly Markdown. + """ + lines: list[str] = [] + + lines.append(f"# Module `{module.path}`\n") + + if module.docstring: + lines.append(module.docstring.strip() + "\n") + + for obj in module.get_all_objects(): + lines.extend(self._render_object(obj, level=2)) + + return "\n".join(lines).strip() + "\n" + + def _render_object(self, obj: DocObject, level: int) -> Iterable[str]: + """ + Recursively render DocObjects. + """ + prefix = "#" * level + lines: list[str] = [] + + lines.append(f"{prefix} {obj.kind} `{obj.name}`") + + if obj.signature: + lines.append(f"```python\n{obj.signature}\n```") + + 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") diff --git a/docforge/renderers/mcp_renderer.pyi b/docforge/renderers/mcp_renderer.pyi new file mode 100644 index 0000000..0ec1375 --- /dev/null +++ b/docforge/renderers/mcp_renderer.pyi @@ -0,0 +1,31 @@ +from pathlib import Path +from typing import Iterable + +from docforge.models import Project, Module, DocObject + + +class MCPRenderer: + """ + Renderer that emits MCP-compatible documentation resources. + """ + + name: str + + def generate_sources(self, project: Project, out_dir: Path) -> None: + """Generate MCP resources for the given project.""" + + def _write_module(self, module: Module, out_dir: Path) -> None: + """Write a single module as an MCP resource.""" + + def _render_module(self, module: Module) -> str: + """Render a module and its contents into a text document.""" + + def _render_object( + self, + obj: DocObject, + level: int, + ) -> Iterable[str]: + """Recursively render a documented object and its members.""" + + def _module_resource_path(self, module: Module) -> Path: + """Compute the MCP resource path for a module.""" diff --git a/docs/docforge/renderers/mcp_renderer.md b/docs/docforge/renderers/mcp_renderer.md new file mode 100644 index 0000000..02f97d1 --- /dev/null +++ b/docs/docforge/renderers/mcp_renderer.md @@ -0,0 +1,3 @@ +# Mcp Renderer + +::: docforge.renderers.mcp_renderer diff --git a/docs/docforge/renderers/mkdocs.md b/docs/docforge/renderers/mkdocs.md deleted file mode 100644 index 274738c..0000000 --- a/docs/docforge/renderers/mkdocs.md +++ /dev/null @@ -1,3 +0,0 @@ -# Mkdocs - -::: docforge.renderers.mkdocs diff --git a/docs/docforge/renderers/mkdocs_renderer.md b/docs/docforge/renderers/mkdocs_renderer.md new file mode 100644 index 0000000..f60486e --- /dev/null +++ b/docs/docforge/renderers/mkdocs_renderer.md @@ -0,0 +1,3 @@ +# Mkdocs Renderer + +::: docforge.renderers.mkdocs_renderer diff --git a/mkdocs.yml b/mkdocs.yml index 2a65949..36df731 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -52,7 +52,8 @@ nav: - Renderers: - docforge/renderers/index.md - docforge/renderers/base.md - - docforge/renderers/mkdocs.md + - docforge/renderers/mkdocs_renderer.md + - docforge/renderers/mcp_renderer.md - CLI: - docforge/cli/index.md - docforge/cli/main.md diff --git a/tests/renderers/mcp/__init__.py b/tests/renderers/mcp/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/renderers/mcp/test_mcp_content.py b/tests/renderers/mcp/test_mcp_content.py new file mode 100644 index 0000000..1e60ead --- /dev/null +++ b/tests/renderers/mcp/test_mcp_content.py @@ -0,0 +1,18 @@ +from pathlib import Path + +from docforge import MCPRenderer +from docforge.models import Project, Module + + +def test_mcp_file_content(tmp_path: Path): + project = Project("testpkg") + project.add_module(Module("testpkg.mod")) + + out_dir = tmp_path / "mcp" + renderer = MCPRenderer() + + renderer.generate_sources(project, out_dir) + + content = (out_dir / "testpkg" / "mod.md").read_text() + + assert "# Module `testpkg.mod`" in content diff --git a/tests/renderers/mcp/test_mcp_idempotency.py b/tests/renderers/mcp/test_mcp_idempotency.py new file mode 100644 index 0000000..4b95c00 --- /dev/null +++ b/tests/renderers/mcp/test_mcp_idempotency.py @@ -0,0 +1,20 @@ +from pathlib import Path + +from docforge import MCPRenderer +from docforge.models import Project, Module + + +def test_mcp_idempotent(tmp_path: Path): + project = Project("testpkg") + project.add_module(Module("testpkg.mod")) + + out_dir = tmp_path / "mcp" + renderer = MCPRenderer() + + renderer.generate_sources(project, out_dir) + first = (out_dir / "testpkg" / "mod.md").read_text() + + renderer.generate_sources(project, out_dir) + second = (out_dir / "testpkg" / "mod.md").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 new file mode 100644 index 0000000..9164b5a --- /dev/null +++ b/tests/renderers/mcp/test_mcp_module_coverage.py @@ -0,0 +1,29 @@ +from pathlib import Path + +from docforge.loaders import GriffeLoader, discover_module_paths +from docforge import MCPRenderer + + +def test_mcp_emits_all_modules(tmp_path: Path) -> None: + loader = GriffeLoader() + discovered_paths = discover_module_paths( + "docforge", + Path(r"C:\Users\vishe\WorkSpace\code\aetos\doc-forge"), + ) + project = loader.load_project(discovered_paths) + + renderer = MCPRenderer() + renderer.generate_sources(project, tmp_path) + + emitted = { + p.relative_to(tmp_path).as_posix() + for p in tmp_path.rglob("*.md") + } + + expected = { + m.path.replace(".", "/") + ".md" + for m in project.get_all_modules() + } + + missing = expected - emitted + assert not missing, f"Missing MCP resources for modules: {missing}" diff --git a/tests/renderers/mcp/test_mcp_structure.py b/tests/renderers/mcp/test_mcp_structure.py new file mode 100644 index 0000000..c0197f2 --- /dev/null +++ b/tests/renderers/mcp/test_mcp_structure.py @@ -0,0 +1,18 @@ +from pathlib import Path + +from docforge import MCPRenderer +from docforge.models import Project, Module + + +def test_mcp_directory_structure(tmp_path: Path): + project = Project("testpkg") + project.add_module(Module("testpkg")) + project.add_module(Module("testpkg.sub")) + + out_dir = tmp_path / "mcp" + renderer = MCPRenderer() + + renderer.generate_sources(project, out_dir) + + assert (out_dir / "testpkg.md").exists() + assert (out_dir / "testpkg" / "sub.md").exists() diff --git a/tests/renderers/mkdocs/__init__.py b/tests/renderers/mkdocs/__init__.py new file mode 100644 index 0000000..e69de29