Skip to content

Commit 45a1380

Browse files
raifdmuellerrdmuellerclaude
authored
feat(api): Add Navigation API endpoints (Issue #7) (#36)
Implement the Navigation API with three endpoints: - GET /api/v1/structure - Hierarchical document structure - GET /api/v1/section/{path} - Get specific section by path - GET /api/v1/sections?level=N - Get sections at level Features: - Pydantic response models for type-safe responses - max_depth parameter for structure depth limiting - 404 errors with PATH_NOT_FOUND code for missing sections - Format detection (asciidoc/markdown) based on file extension Acceptance Criteria: - AC-NAV-01: Get full structure ✓ - AC-NAV-02: Get structure with depth limit ✓ - AC-NAV-03: Read existing section ✓ - AC-NAV-04: Section not found → 404 ✓ - AC-NAV-05: Get sections by level ✓ Co-authored-by: Ralf D. Müller <ralf.d.mueller@gmail.com> Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 834bf64 commit 45a1380

File tree

5 files changed

+574
-0
lines changed

5 files changed

+574
-0
lines changed

src/mcp_server/api/__init__.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
"""API module for MCP Documentation Server."""
2+
3+
from mcp_server.api.app import create_app
4+
5+
__all__ = ["create_app"]

src/mcp_server/api/app.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
"""FastAPI application factory.
2+
3+
Creates and configures the FastAPI application with all routers.
4+
"""
5+
6+
from fastapi import FastAPI
7+
8+
from mcp_server import __version__
9+
from mcp_server.api import navigation
10+
from mcp_server.structure_index import StructureIndex
11+
12+
13+
def create_app(index: StructureIndex | None = None) -> FastAPI:
14+
"""Create and configure the FastAPI application.
15+
16+
Args:
17+
index: Optional pre-configured StructureIndex.
18+
If None, the index must be set later.
19+
20+
Returns:
21+
Configured FastAPI application
22+
"""
23+
app = FastAPI(
24+
title="MCP Documentation Server",
25+
description="LLM interaction with large documentation projects via MCP",
26+
version=__version__,
27+
docs_url="/docs",
28+
redoc_url="/redoc",
29+
)
30+
31+
# Set the index for the navigation router
32+
if index is not None:
33+
navigation.set_index(index)
34+
35+
# Include routers
36+
app.include_router(navigation.router)
37+
38+
return app

src/mcp_server/api/models.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
"""Pydantic models for API responses.
2+
3+
These models define the JSON response structure for the Navigation API
4+
as specified in 02_api_specification.adoc.
5+
"""
6+
7+
from pydantic import BaseModel, Field
8+
9+
10+
class LocationResponse(BaseModel):
11+
"""Location of a section in a source file."""
12+
13+
file: str = Field(description="Relative path to the file")
14+
line: int = Field(description="1-based line number")
15+
16+
17+
class SectionResponse(BaseModel):
18+
"""Section response for structure endpoint."""
19+
20+
path: str = Field(description="Hierarchical path (e.g., '/chapter-1/section-2')")
21+
title: str = Field(description="Section title")
22+
level: int = Field(description="Nesting depth (1 = chapter)")
23+
location: LocationResponse
24+
children: list["SectionResponse"] = Field(default_factory=list)
25+
26+
27+
class StructureResponse(BaseModel):
28+
"""Response for GET /structure endpoint."""
29+
30+
sections: list[SectionResponse]
31+
total_sections: int = Field(description="Total number of sections in index")
32+
33+
34+
class SectionDetailResponse(BaseModel):
35+
"""Detailed section response for GET /section/{path}."""
36+
37+
path: str
38+
title: str
39+
level: int
40+
location: LocationResponse
41+
format: str = Field(description="Document format: 'asciidoc' or 'markdown'")
42+
43+
44+
class SectionSummary(BaseModel):
45+
"""Summary of a section (without children)."""
46+
47+
path: str
48+
title: str
49+
50+
51+
class SectionsAtLevelResponse(BaseModel):
52+
"""Response for GET /sections endpoint."""
53+
54+
level: int
55+
sections: list[SectionSummary]
56+
count: int
57+
58+
59+
class ErrorDetail(BaseModel):
60+
"""Error detail in error response."""
61+
62+
code: str = Field(description="Error code (e.g., 'PATH_NOT_FOUND')")
63+
message: str = Field(description="Human-readable error message")
64+
details: dict | None = Field(default=None, description="Additional details")
65+
66+
67+
class ErrorResponse(BaseModel):
68+
"""Standardized error response."""
69+
70+
error: ErrorDetail
71+
72+
73+
# Allow forward references
74+
SectionResponse.model_rebuild()

src/mcp_server/api/navigation.py

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
"""Navigation API router.
2+
3+
Provides endpoints for navigating the document structure:
4+
- GET /structure - Get hierarchical document structure
5+
- GET /section/{path} - Get a specific section
6+
- GET /sections - Get sections at a specific level
7+
"""
8+
9+
from fastapi import APIRouter, HTTPException, Path, Query
10+
11+
from mcp_server.api.models import (
12+
ErrorDetail,
13+
ErrorResponse,
14+
LocationResponse,
15+
SectionDetailResponse,
16+
SectionResponse,
17+
SectionsAtLevelResponse,
18+
SectionSummary,
19+
StructureResponse,
20+
)
21+
from mcp_server.structure_index import StructureIndex
22+
23+
router = APIRouter(prefix="/api/v1", tags=["Navigation"])
24+
25+
# Global index reference - will be set by create_app
26+
_index: StructureIndex | None = None
27+
28+
29+
def set_index(index: StructureIndex) -> None:
30+
"""Set the global structure index."""
31+
global _index
32+
_index = index
33+
34+
35+
def get_index() -> StructureIndex:
36+
"""Get the global structure index."""
37+
if _index is None:
38+
raise HTTPException(
39+
status_code=503,
40+
detail=ErrorResponse(
41+
error=ErrorDetail(
42+
code="INDEX_NOT_READY",
43+
message="Server index is not initialized",
44+
)
45+
).model_dump(),
46+
)
47+
return _index
48+
49+
50+
@router.get(
51+
"/structure",
52+
response_model=StructureResponse,
53+
summary="Get document structure",
54+
description="Returns the hierarchical document structure.",
55+
)
56+
def get_structure(
57+
max_depth: int | None = Query(
58+
default=None,
59+
description="Maximum depth of returned structure. None for unlimited.",
60+
ge=1,
61+
),
62+
) -> StructureResponse:
63+
"""Get the hierarchical document structure."""
64+
index = get_index()
65+
structure = index.get_structure(max_depth=max_depth)
66+
67+
sections = [_section_dict_to_response(s) for s in structure["sections"]]
68+
69+
return StructureResponse(
70+
sections=sections,
71+
total_sections=structure["total_sections"],
72+
)
73+
74+
75+
@router.get(
76+
"/section/{path:path}",
77+
response_model=SectionDetailResponse,
78+
responses={
79+
404: {"model": ErrorResponse, "description": "Section not found"},
80+
},
81+
summary="Get section by path",
82+
description="Returns a specific section by its hierarchical path.",
83+
)
84+
def get_section(
85+
path: str = Path(description="Hierarchical path to the section"),
86+
) -> SectionDetailResponse:
87+
"""Get a specific section by path."""
88+
index = get_index()
89+
90+
# Normalize path - ensure it starts with /
91+
normalized_path = f"/{path}" if not path.startswith("/") else path
92+
93+
section = index.get_section(normalized_path)
94+
95+
if section is None:
96+
raise HTTPException(
97+
status_code=404,
98+
detail={
99+
"error": {
100+
"code": "PATH_NOT_FOUND",
101+
"message": f"Section '{normalized_path}' not found",
102+
"details": {
103+
"requested_path": normalized_path,
104+
},
105+
}
106+
},
107+
)
108+
109+
# Determine format from file extension
110+
file_path = str(section.source_location.file)
111+
if file_path.endswith(".md"):
112+
format_type = "markdown"
113+
else:
114+
format_type = "asciidoc"
115+
116+
return SectionDetailResponse(
117+
path=section.path,
118+
title=section.title,
119+
level=section.level,
120+
location=LocationResponse(
121+
file=file_path,
122+
line=section.source_location.line,
123+
),
124+
format=format_type,
125+
)
126+
127+
128+
@router.get(
129+
"/sections",
130+
response_model=SectionsAtLevelResponse,
131+
summary="Get sections at level",
132+
description="Returns all sections at a specific nesting level.",
133+
)
134+
def get_sections(
135+
level: int = Query(
136+
description="Nesting level (1 = chapter, 2 = section, etc.)",
137+
ge=1,
138+
),
139+
) -> SectionsAtLevelResponse:
140+
"""Get all sections at a specific level."""
141+
index = get_index()
142+
143+
sections = index.get_sections_at_level(level)
144+
145+
section_summaries = [
146+
SectionSummary(path=s.path, title=s.title) for s in sections
147+
]
148+
149+
return SectionsAtLevelResponse(
150+
level=level,
151+
sections=section_summaries,
152+
count=len(section_summaries),
153+
)
154+
155+
156+
def _section_dict_to_response(section_dict: dict) -> SectionResponse:
157+
"""Convert a section dictionary from index to response model."""
158+
children = [_section_dict_to_response(c) for c in section_dict.get("children", [])]
159+
160+
return SectionResponse(
161+
path=section_dict["path"],
162+
title=section_dict["title"],
163+
level=section_dict["level"],
164+
location=LocationResponse(
165+
file=section_dict["location"]["file"],
166+
line=section_dict["location"]["line"],
167+
),
168+
children=children,
169+
)

0 commit comments

Comments
 (0)