Skip to content

Commit 716ab2b

Browse files
authored
Merge pull request #41 from rdmueller/feature/manipulation-api
feat(api): Add Manipulation API endpoints (Issue #9)
2 parents 9e73001 + 058101a commit 716ab2b

File tree

4 files changed

+712
-1
lines changed

4 files changed

+712
-1
lines changed

src/mcp_server/api/app.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from fastapi import FastAPI
77

88
from mcp_server import __version__
9-
from mcp_server.api import content, navigation
9+
from mcp_server.api import content, manipulation, navigation
1010
from mcp_server.structure_index import StructureIndex
1111

1212

@@ -32,9 +32,11 @@ def create_app(index: StructureIndex | None = None) -> FastAPI:
3232
if index is not None:
3333
navigation.set_index(index)
3434
content.set_index(index)
35+
manipulation.set_index(index)
3536

3637
# Include routers
3738
app.include_router(navigation.router)
3839
app.include_router(content.router)
40+
app.include_router(manipulation.router)
3941

4042
return app

src/mcp_server/api/manipulation.py

Lines changed: 284 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,284 @@
1+
"""Manipulation API router.
2+
3+
Provides endpoints for modifying document content:
4+
- PUT /section/{path} - Update a section's content
5+
- POST /section/{path}/insert - Insert content relative to a section
6+
"""
7+
8+
from fastapi import APIRouter, HTTPException, Path
9+
10+
from mcp_server.api.models import (
11+
ErrorDetail,
12+
ErrorResponse,
13+
InsertContentRequest,
14+
InsertContentResponse,
15+
LocationResponse,
16+
UpdateSectionRequest,
17+
UpdateSectionResponse,
18+
)
19+
from mcp_server.file_handler import FileReadError, FileSystemHandler, FileWriteError
20+
from mcp_server.structure_index import StructureIndex
21+
22+
router = APIRouter(prefix="/api/v1", tags=["Manipulation"])
23+
24+
# Global index reference - will be set by create_app
25+
_index: StructureIndex | None = None
26+
27+
# File handler for atomic operations
28+
_file_handler: FileSystemHandler = FileSystemHandler()
29+
30+
31+
def set_index(index: StructureIndex) -> None:
32+
"""Set the global structure index."""
33+
global _index
34+
_index = index
35+
36+
37+
def get_index() -> StructureIndex:
38+
"""Get the global structure index."""
39+
if _index is None:
40+
raise HTTPException(
41+
status_code=503,
42+
detail=ErrorResponse(
43+
error=ErrorDetail(
44+
code="INDEX_NOT_READY",
45+
message="Server index is not initialized",
46+
)
47+
).model_dump(),
48+
)
49+
return _index
50+
51+
52+
def _find_section_end_line(index: StructureIndex, section_path: str) -> int:
53+
"""Find the end line of a section's direct content.
54+
55+
Since SourceLocation only has a start line, we estimate the end line by:
56+
1. Finding the next section's (child or sibling) start line - 1, giving us
57+
the end of this section's direct content
58+
2. Or using a reasonable default if no later section exists
59+
60+
This is a simplified implementation - proper end_line tracking
61+
should be added to the parser (see tech-debt).
62+
"""
63+
section = index.get_section(section_path)
64+
if section is None:
65+
return -1
66+
67+
start_line = section.source_location.line
68+
file_path = section.source_location.file
69+
70+
# Find all sections in the same file, sorted by line number
71+
all_sections = []
72+
for path, sec in index._path_to_section.items():
73+
if sec.source_location.file == file_path:
74+
all_sections.append((sec.source_location.line, path))
75+
76+
all_sections.sort()
77+
78+
# Find the section after this one
79+
for i, (line, path) in enumerate(all_sections):
80+
if path == section_path:
81+
if i + 1 < len(all_sections):
82+
# Next section starts at this line, our section ends before
83+
return all_sections[i + 1][0] - 1
84+
else:
85+
# This is the last section, read to end of file
86+
try:
87+
content = _file_handler.read_file(file_path)
88+
return len(content.splitlines())
89+
except FileReadError:
90+
return start_line + 10 # Fallback
91+
92+
return start_line + 10 # Fallback
93+
94+
95+
@router.put(
96+
"/section/{path:path}",
97+
response_model=UpdateSectionResponse,
98+
responses={
99+
404: {"model": ErrorResponse, "description": "Section not found"},
100+
500: {"model": ErrorResponse, "description": "Write operation failed"},
101+
},
102+
summary="Update section content",
103+
description="Updates the content of a specific section.",
104+
)
105+
def update_section(
106+
path: str = Path(description="Hierarchical path to the section"),
107+
request: UpdateSectionRequest = ...,
108+
) -> UpdateSectionResponse:
109+
"""Update a section's content."""
110+
index = get_index()
111+
112+
# Normalize path
113+
normalized_path = f"/{path}" if not path.startswith("/") else path
114+
115+
# Find the section
116+
section = index.get_section(normalized_path)
117+
if section is None:
118+
raise HTTPException(
119+
status_code=404,
120+
detail={
121+
"error": {
122+
"code": "PATH_NOT_FOUND",
123+
"message": f"Section '{normalized_path}' not found",
124+
}
125+
},
126+
)
127+
128+
file_path = section.source_location.file
129+
start_line = section.source_location.line
130+
end_line = _find_section_end_line(index, normalized_path)
131+
132+
# Prepare content
133+
new_content = request.content
134+
if request.preserve_title:
135+
stripped_content = new_content.lstrip()
136+
has_explicit_title = stripped_content.startswith("=") or stripped_content.startswith("#")
137+
if not has_explicit_title:
138+
# Prepend the original title line
139+
level_markers = "=" * (section.level + 1)
140+
new_content = f"{level_markers} {section.title}\n\n{new_content}"
141+
142+
# Ensure content ends with newline
143+
if not new_content.endswith("\n"):
144+
new_content += "\n"
145+
146+
# Perform atomic update
147+
try:
148+
_file_handler.update_section(
149+
path=file_path,
150+
start_line=start_line,
151+
end_line=end_line,
152+
new_content=new_content,
153+
)
154+
except FileWriteError as e:
155+
raise HTTPException(
156+
status_code=500,
157+
detail={
158+
"error": {
159+
"code": "WRITE_FAILED",
160+
"message": "Failed to write changes to file",
161+
"details": {
162+
"file": str(file_path),
163+
"reason": str(e),
164+
},
165+
}
166+
},
167+
)
168+
169+
return UpdateSectionResponse(
170+
success=True,
171+
path=normalized_path,
172+
location=LocationResponse(
173+
file=str(file_path),
174+
line=start_line,
175+
),
176+
)
177+
178+
179+
@router.post(
180+
"/section/{path:path}/insert",
181+
response_model=InsertContentResponse,
182+
responses={
183+
404: {"model": ErrorResponse, "description": "Section not found"},
184+
500: {"model": ErrorResponse, "description": "Write operation failed"},
185+
},
186+
summary="Insert content relative to section",
187+
description="Inserts content before, after, or appended to a section.",
188+
)
189+
def insert_content(
190+
path: str = Path(description="Hierarchical path to the reference section"),
191+
request: InsertContentRequest = ...,
192+
) -> InsertContentResponse:
193+
"""Insert content relative to a section."""
194+
index = get_index()
195+
196+
# Normalize path
197+
normalized_path = f"/{path}" if not path.startswith("/") else path
198+
199+
# Find the section
200+
section = index.get_section(normalized_path)
201+
if section is None:
202+
raise HTTPException(
203+
status_code=404,
204+
detail={
205+
"error": {
206+
"code": "PATH_NOT_FOUND",
207+
"message": f"Section '{normalized_path}' not found",
208+
}
209+
},
210+
)
211+
212+
file_path = section.source_location.file
213+
start_line = section.source_location.line
214+
end_line = _find_section_end_line(index, normalized_path)
215+
216+
# Determine insert position
217+
content = request.content
218+
if not content.endswith("\n"):
219+
content += "\n"
220+
221+
try:
222+
file_content = _file_handler.read_file(file_path)
223+
lines = file_content.splitlines(keepends=True)
224+
225+
if request.position == "before":
226+
# Insert before the section starts
227+
insert_line = start_line
228+
new_lines = (
229+
lines[: start_line - 1]
230+
+ [content]
231+
+ lines[start_line - 1 :]
232+
)
233+
elif request.position == "after":
234+
# Insert after the section ends
235+
insert_line = end_line + 1
236+
new_lines = (
237+
lines[:end_line]
238+
+ [content]
239+
+ lines[end_line:]
240+
)
241+
else: # append
242+
# Append content at end of section (before the last line/children)
243+
insert_line = end_line
244+
new_lines = (
245+
lines[: end_line - 1]
246+
+ [content]
247+
+ lines[end_line - 1 :]
248+
)
249+
250+
new_file_content = "".join(new_lines)
251+
_file_handler.write_file(file_path, new_file_content)
252+
253+
except FileReadError as e:
254+
raise HTTPException(
255+
status_code=500,
256+
detail={
257+
"error": {
258+
"code": "READ_FAILED",
259+
"message": f"Failed to read file: {e}",
260+
}
261+
},
262+
)
263+
except FileWriteError as e:
264+
raise HTTPException(
265+
status_code=500,
266+
detail={
267+
"error": {
268+
"code": "WRITE_FAILED",
269+
"message": "Failed to write changes to file",
270+
"details": {
271+
"file": str(file_path),
272+
"reason": str(e),
273+
},
274+
}
275+
},
276+
)
277+
278+
return InsertContentResponse(
279+
success=True,
280+
inserted_at=LocationResponse(
281+
file=str(file_path),
282+
line=insert_line,
283+
),
284+
)

src/mcp_server/api/models.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
and Content Access API as specified in 02_api_specification.adoc.
55
"""
66

7+
from typing import Literal
8+
79
from pydantic import BaseModel, Field
810

911
# Valid element types for GET /elements endpoint
@@ -131,5 +133,44 @@ class ElementsResponse(BaseModel):
131133
count: int = Field(description="Number of elements returned")
132134

133135

136+
# ============================================================================
137+
# Manipulation API Models
138+
# ============================================================================
139+
140+
141+
class UpdateSectionRequest(BaseModel):
142+
"""Request body for PUT /section/{path} endpoint."""
143+
144+
content: str = Field(description="New section content")
145+
preserve_title: bool = Field(
146+
default=True,
147+
description="Keep original title if content doesn't include one",
148+
)
149+
150+
151+
class UpdateSectionResponse(BaseModel):
152+
"""Response for PUT /section/{path} endpoint."""
153+
154+
success: bool = Field(default=True)
155+
path: str = Field(description="Section path that was updated")
156+
location: LocationResponse
157+
158+
159+
class InsertContentRequest(BaseModel):
160+
"""Request body for POST /section/{path}/insert endpoint."""
161+
162+
position: Literal["before", "after", "append"] = Field(
163+
description="Insert position: 'before', 'after', or 'append'"
164+
)
165+
content: str = Field(description="Content to insert")
166+
167+
168+
class InsertContentResponse(BaseModel):
169+
"""Response for POST /section/{path}/insert endpoint."""
170+
171+
success: bool = Field(default=True)
172+
inserted_at: LocationResponse
173+
174+
134175
# Allow forward references
135176
SectionResponse.model_rebuild()

0 commit comments

Comments
 (0)