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
This commit is contained in:
2026-01-21 16:43:21 +05:30
parent ce2eafac85
commit 5370a7faa2
8 changed files with 180 additions and 74 deletions

View File

@@ -9,7 +9,7 @@ from typing import Sequence, Optional
import click import click
from docforge.loaders import GriffeLoader, discover_module_paths 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 from docforge.cli.mkdocs import mkdocs_cmd
@@ -118,6 +118,52 @@ def generate(
click.echo(f"Documentation sources generated in {docs_dir}") 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 # build
# --------------------------------------------------------------------- # ---------------------------------------------------------------------

View File

@@ -47,6 +47,34 @@ def generate(
) -> None: ) -> None:
"""Generate documentation source files using MkDocs renderer.""" """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() @cli.command()
@click.option( @click.option(

View File

@@ -1,78 +1,102 @@
import json
from pathlib import Path from pathlib import Path
from typing import Iterable from typing import Dict, List
from docforge.models import Project, Module, DocObject from docforge.models import Project, Module, DocObject
class MCPRenderer: class MCPRenderer:
""" """
Renderer that emits documentation as MCP resources. Renderer that emits MCP-native JSON resources from docforge models.
""" """
name = "mcp" name = "mcp"
def generate_sources(self, project: Project, out_dir: Path) -> None: def generate_sources(self, project: Project, out_dir: Path) -> None:
""" """
Generate MCP-compatible resources for the project. Generate MCP-compatible JSON resources and navigation for the project.
Each module is rendered as a standalone MCP document.
""" """
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(): 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) payload = {
content = self._render_module(module) "module": module.path,
"content": self._render_module(module),
}
file_path = out_dir / resource_path out = modules_dir / f"{module.path}.json"
file_path.parent.mkdir(parents=True, exist_ok=True) out.parent.mkdir(parents=True, exist_ok=True)
file_path.write_text(content, encoding="utf-8") 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] = [] data: Dict = {
"path": module.path,
lines.append(f"# Module `{module.path}`\n") "docstring": module.docstring,
"objects": {},
if module.docstring: }
lines.append(module.docstring.strip() + "\n")
for obj in module.get_all_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 data: Dict = {
lines: list[str] = [] "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: return data
lines.append(f"```python\n{obj.signature}\n```")
if obj.docstring: @staticmethod
lines.append(obj.docstring.strip()) def _json(data: Dict) -> str:
return json.dumps(data, indent=2, ensure_ascii=False)
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")

View File

@@ -1,31 +1,26 @@
from pathlib import Path from pathlib import Path
from typing import Iterable from typing import Dict, List
from docforge.models import Project, Module, DocObject from docforge.models import Project, Module, DocObject
class MCPRenderer: class MCPRenderer:
""" """Renderer that emits MCP-native JSON resources from docforge models."""
Renderer that emits MCP-compatible documentation resources.
"""
name: str name: str
def generate_sources(self, project: Project, out_dir: Path) -> None: 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: def _write_module(self, module: Module, modules_dir: Path) -> None:
"""Write a single module as an MCP resource.""" """Serialize a module into an MCP JSON resource."""
def _render_module(self, module: Module) -> str: def _render_module(self, module: Module) -> Dict:
"""Render a module and its contents into a text document.""" """Render a Module into MCP-friendly structured data."""
def _render_object( def _render_object(self, obj: DocObject) -> Dict:
self, """Recursively render a DocObject into structured MCP data."""
obj: DocObject,
level: int,
) -> Iterable[str]:
"""Recursively render a documented object and its members."""
def _module_resource_path(self, module: Module) -> Path: @staticmethod
"""Compute the MCP resource path for a module.""" def _json(data: Dict) -> str:
"""Serialize structured data to formatted JSON."""

View File

@@ -1,3 +1,4 @@
import json
from pathlib import Path from pathlib import Path
from docforge import MCPRenderer from docforge import MCPRenderer
@@ -13,6 +14,8 @@ def test_mcp_file_content(tmp_path: Path):
renderer.generate_sources(project, out_dir) 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"

View File

@@ -12,9 +12,9 @@ def test_mcp_idempotent(tmp_path: Path):
renderer = MCPRenderer() renderer = MCPRenderer()
renderer.generate_sources(project, out_dir) 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) 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 assert first == second

View File

@@ -5,11 +5,13 @@ from docforge import MCPRenderer
def test_mcp_emits_all_modules(tmp_path: Path) -> None: def test_mcp_emits_all_modules(tmp_path: Path) -> None:
project_root = Path(__file__).resolve().parents[3]
loader = GriffeLoader() loader = GriffeLoader()
discovered_paths = discover_module_paths( discovered_paths = discover_module_paths(
"docforge", "docforge",
Path(r"C:\Users\vishe\WorkSpace\code\aetos\doc-forge"), project_root=project_root,
) )
project = loader.load_project(discovered_paths) project = loader.load_project(discovered_paths)
renderer = MCPRenderer() renderer = MCPRenderer()
@@ -17,13 +19,16 @@ def test_mcp_emits_all_modules(tmp_path: Path) -> None:
emitted = { emitted = {
p.relative_to(tmp_path).as_posix() p.relative_to(tmp_path).as_posix()
for p in tmp_path.rglob("*.md") for p in (tmp_path / "modules").rglob("*.json")
} }
expected = { expected = {
m.path.replace(".", "/") + ".md" f"modules/{m.path}.json"
for m in project.get_all_modules() for m in project.get_all_modules()
} }
missing = expected - emitted 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()

View File

@@ -14,5 +14,10 @@ def test_mcp_directory_structure(tmp_path: Path):
renderer.generate_sources(project, out_dir) renderer.generate_sources(project, out_dir)
assert (out_dir / "testpkg.md").exists() # Bundle-level files
assert (out_dir / "testpkg" / "sub.md").exists() 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()