Skip to content

Commit 4959b22

Browse files
Generate docs with Jinja (#25)
* Add Jinja2 template for docs * Read version from toml * Drop unused command * Drop print
1 parent d18174c commit 4959b22

File tree

9 files changed

+551
-540
lines changed

9 files changed

+551
-540
lines changed

pyproject.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "saleor-mcp"
3-
version = "0.1.0"
3+
version = "0.1.1"
44
description = "MCP server for Saleor Commerce"
55
readme = "README.md"
66
requires-python = ">=3.12"
@@ -12,6 +12,7 @@ dependencies = [
1212
"graphql-core>=3.2.0",
1313
"ariadne-codegen>=0.15.2",
1414
"python-json-logger>=3.3.0",
15+
"jinja2>=3.1.0",
1516
]
1617

1718
[build-system]

src/saleor_mcp/docs.py

Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
1+
"""Generate static HTML documentation from Jinja2 template."""
2+
3+
import inspect
4+
import logging
5+
import tomllib
6+
from pathlib import Path
7+
from typing import Annotated, Any, get_args, get_origin, get_type_hints
8+
9+
from fastmcp import FastMCP
10+
from jinja2 import Environment, FileSystemLoader
11+
12+
logger = logging.getLogger(__name__)
13+
14+
15+
def get_version_from_pyproject() -> str:
16+
"""Read version from pyproject.toml.
17+
18+
Returns:
19+
Version string from pyproject.toml, or "unknown" if not found.
20+
21+
"""
22+
try:
23+
# Find pyproject.toml - go up from this file to project root
24+
project_root = Path(__file__).parent.parent.parent
25+
pyproject_path = project_root / "pyproject.toml"
26+
27+
if pyproject_path.exists():
28+
with open(pyproject_path, "rb") as f:
29+
data = tomllib.load(f)
30+
return data.get("project", {}).get("version", "unknown")
31+
except Exception:
32+
logger.warning("Failed to read version from pyproject.toml")
33+
34+
return "unknown"
35+
36+
37+
def generate_html(output_path: str | None = None) -> str:
38+
"""Generate HTML documentation from tools.
39+
40+
Automatically discovers all tools from the main MCP server and its mounted
41+
routers, eliminating the need to manually maintain a list of routers.
42+
43+
Args:
44+
output_path: Optional path to write the HTML file to.
45+
If None, returns the HTML string without writing.
46+
47+
Returns:
48+
Generated HTML content as string.
49+
50+
"""
51+
# Import here to avoid circular dependency
52+
from saleor_mcp.main import mcp
53+
54+
# Introspect tools from the MCP server and all mounted routers
55+
tools = introspect_from_mcp_server(mcp)
56+
57+
# Get version from pyproject.toml
58+
version = get_version_from_pyproject()
59+
60+
# Setup Jinja2 environment
61+
template_dir = Path(__file__).parent / "templates"
62+
env = Environment(loader=FileSystemLoader(template_dir))
63+
template = env.get_template("index.html.jinja")
64+
65+
# Render template
66+
html_content = template.render(
67+
tools=tools,
68+
version=version,
69+
)
70+
71+
# Write to file if path provided
72+
if output_path:
73+
output_file = Path(output_path)
74+
output_file.write_text(html_content, encoding="utf-8")
75+
logger.info("Generated HTML documentation at: %s", output_file)
76+
77+
return html_content
78+
79+
80+
def get_type_name(type_hint: Any) -> str:
81+
"""Convert a type hint to a readable string."""
82+
origin = get_origin(type_hint)
83+
84+
if origin is None:
85+
# Simple type like int, str, etc.
86+
if hasattr(type_hint, "__name__"):
87+
return type_hint.__name__
88+
return str(type_hint)
89+
90+
# Handle Union types (including Optional)
91+
if origin is type(None) or str(origin) == "typing.Union":
92+
args = get_args(type_hint)
93+
# Filter out NoneType for Optional
94+
non_none_args = [arg for arg in args if arg is not type(None)]
95+
if len(non_none_args) == 1:
96+
return f"{get_type_name(non_none_args[0])} | None"
97+
return " | ".join(get_type_name(arg) for arg in args)
98+
99+
# Handle generic types like list, dict
100+
if origin in (list, dict, tuple):
101+
args = get_args(type_hint)
102+
if args:
103+
arg_names = ", ".join(get_type_name(arg) for arg in args)
104+
return f"{origin.__name__}[{arg_names}]"
105+
return origin.__name__
106+
107+
return str(type_hint)
108+
109+
110+
def extract_param_info(func: Any) -> list[dict[str, Any]]:
111+
"""Extract parameter information from a function.
112+
113+
Returns a list of dicts with keys: name, type, description, required, default.
114+
115+
"""
116+
params = []
117+
sig = inspect.signature(func)
118+
type_hints = get_type_hints(func, include_extras=True)
119+
120+
for param_name, param in sig.parameters.items():
121+
# Skip 'ctx' parameter as it's internal
122+
if param_name == "ctx":
123+
continue
124+
125+
param_info = {
126+
"name": param_name,
127+
"description": "",
128+
"required": param.default is inspect.Parameter.empty,
129+
"default": (
130+
None if param.default is inspect.Parameter.empty else param.default
131+
),
132+
}
133+
134+
# Get type information
135+
if param_name in type_hints:
136+
type_hint = type_hints[param_name]
137+
138+
# Check if it's Annotated type
139+
if get_origin(type_hint) is Annotated:
140+
args = get_args(type_hint)
141+
if len(args) >= 2:
142+
# First arg is the actual type, second is the description
143+
actual_type = args[0]
144+
param_info["type"] = get_type_name(actual_type)
145+
param_info["description"] = (
146+
args[1] if isinstance(args[1], str) else ""
147+
)
148+
else:
149+
param_info["type"] = get_type_name(type_hint)
150+
else:
151+
param_info["type"] = "Any"
152+
153+
params.append(param_info)
154+
155+
return params
156+
157+
158+
def extract_tool_info(router: FastMCP) -> list[dict[str, Any]]:
159+
"""Extract tool information from a FastMCP router.
160+
161+
Returns a list of tool dictionaries with:
162+
- id: tool identifier (function name)
163+
- name: human-readable name (title case of function name)
164+
- description: from docstring
165+
- arguments: list of parameter info
166+
"""
167+
tools = []
168+
169+
# Access the tool manager's internal tools dictionary
170+
if hasattr(router, "_tool_manager") and hasattr(router._tool_manager, "_tools"):
171+
router_tools = router._tool_manager._tools
172+
173+
for tool_name, tool in router_tools.items():
174+
func = tool.fn
175+
176+
# Get the actual function (unwrap if needed)
177+
if hasattr(func, "__wrapped__"):
178+
func = func.__wrapped__
179+
180+
# Extract docstring
181+
docstring = inspect.getdoc(func) or ""
182+
# Take first two paragraphs as description
183+
description = (
184+
"".join(docstring.split("\n\n")[:2]).replace("\n", " ").strip()
185+
)
186+
187+
# Extract parameters
188+
arguments = extract_param_info(func)
189+
190+
# Create human-readable name from function name
191+
# e.g., "list_orders" -> "List Orders"
192+
name = tool_name.replace("_", " ").title()
193+
194+
tool_info = {
195+
"id": tool_name,
196+
"name": name,
197+
"description": description,
198+
"arguments": arguments,
199+
}
200+
201+
tools.append(tool_info)
202+
203+
return tools
204+
205+
206+
def introspect_from_mcp_server(mcp_server: FastMCP) -> list[dict[str, Any]]:
207+
"""Introspect all tools from a FastMCP server and its mounted routers.
208+
209+
This function automatically discovers all mounted routers in the MCP server
210+
and extracts tool information from them, eliminating the need to manually
211+
maintain a list of routers.
212+
213+
Args:
214+
mcp_server: The main FastMCP server instance
215+
216+
Returns:
217+
Combined list of all tool information from all mounted routers.
218+
219+
"""
220+
all_tools = []
221+
222+
# Get tools from the main server itself
223+
main_tools = extract_tool_info(mcp_server)
224+
all_tools.extend(main_tools)
225+
226+
# Get tools from all mounted routers
227+
if hasattr(mcp_server, "_tool_manager") and hasattr(
228+
mcp_server._tool_manager, "_mounted_servers"
229+
):
230+
mounted_servers = mcp_server._tool_manager._mounted_servers
231+
for mounted_server in mounted_servers:
232+
if hasattr(mounted_server, "server"):
233+
router = mounted_server.server
234+
router_tools = extract_tool_info(router)
235+
all_tools.extend(router_tools)
236+
237+
# Sort tools by name for consistent ordering
238+
all_tools.sort(key=lambda x: x["name"])
239+
240+
return all_tools

src/saleor_mcp/main.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
from fastmcp import FastMCP
22
from fastmcp.server.middleware.timing import DetailedTimingMiddleware
33
from starlette.requests import Request
4-
from starlette.responses import FileResponse, JSONResponse
4+
from starlette.responses import HTMLResponse, JSONResponse
55
from starlette.staticfiles import StaticFiles
66

7+
from saleor_mcp.docs import generate_html
78
from saleor_mcp.tools import (
89
channels_router,
910
customers_router,
@@ -38,8 +39,12 @@ async def index(request: Request):
3839
"img-src 'self'",
3940
"font-src 'self'",
4041
)
41-
return FileResponse(
42-
"src/saleor_mcp/static/index.html",
42+
43+
# Generate HTML dynamically from template
44+
html_content = generate_html()
45+
46+
return HTMLResponse(
47+
content=html_content,
4348
headers={"Content-Security-Policy": "; ".join(csp_policies)},
4449
)
4550

0 commit comments

Comments
 (0)