Skip to content

Commit 8ca5fcd

Browse files
dsarnoclaude
andauthored
Add MCP resources and pagination (#1)
* Add MCP resources, pagination, and full-stack test coverage Add 6 new MCP resources (scene/current, scene/hierarchy, selection/current, project/info, project/settings, logs/recent) alongside the existing sessions resource. Add offset/limit pagination to scene_get_hierarchy, logs_read, node_find, and filesystem_search tools. Fix all resources to return JSON strings (FastMCP requires str, not dict). New full-stack integration tests exercise every tool and resource through the complete FastMCP Client → MCP server → WebSocket → mock Godot plugin pipeline. Coverage up from 68% to 87%. 89 Python tests, 44 Godot-side tests (133 total). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Simplify: extract shared pagination, fix exceptions, dedupe tests - Extract _paginate to shared tools/_pagination.py with key parameter; all 4 paginated tools now use it instead of inline copies - Fix redundant except (ConnectionError, Exception) -> except Exception - Parallelize project settings resource with asyncio.gather (was 8 sequential WebSocket round-trips) - Deduplicate mcp_stack fixture into conftest.py - Remove redundant test_resources.py (covered by test_mcp_resources.py) - Remove duplicate _paginate unit tests from integration file 81 Python tests, 44 Godot-side (125 total). Coverage: 87%. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Fix logs_read pagination: fetch full buffer for accurate metadata logs_read was requesting offset+count lines from the GDScript handler, but get_recent() returns the tail of the buffer — not a window. This made total_count reflect the request size, not the actual buffer size, and has_more was always wrong for non-zero offsets. Now fetches the full buffer (up to 500 lines) and lets paginate() slice correctly with accurate total_count and has_more. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Bump fastmcp floor to >=3.0.0 FastMCP 2.x lacks Context.lifespan_context and the async list_resources() API that this codebase relies on. The loose >=2.0.0 floor allowed installs that would fail at runtime. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 675cea8 commit 8ca5fcd

20 files changed

Lines changed: 990 additions & 22 deletions

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ python -m godot_ai --transport streamable-http --port 8000 --reload
5454

5555
### Python tests
5656
```bash
57-
pytest -v # 38 unit + integration tests
57+
pytest -v # 81 unit + integration tests
5858
```
5959

6060
### Godot-side tests

README.md

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,18 @@ claude mcp add --scope user --transport http godot-ai http://127.0.0.1:8000/mcp
9595
| `client_configure` | Configure an MCP client (Claude Code / Antigravity) |
9696
| `client_status` | Check which clients are configured |
9797

98+
## Resources
99+
100+
| Resource URI | Description |
101+
|-------------|-------------|
102+
| `godot://sessions` | All connected editor sessions with metadata |
103+
| `godot://scene/current` | Current scene path, project name, play state |
104+
| `godot://scene/hierarchy` | Full scene tree (nodes, types, paths) |
105+
| `godot://selection/current` | Currently selected nodes |
106+
| `godot://project/info` | Project name, Godot version, paths |
107+
| `godot://project/settings` | Common settings (display, physics, rendering) |
108+
| `godot://logs/recent` | Last 100 log lines from the editor console |
109+
98110
## Ports
99111

100112
| Port | Purpose |
@@ -113,7 +125,7 @@ claude mcp add --scope user --transport http godot-ai http://127.0.0.1:8000/mcp
113125
# Setup (handles macOS Python 3.13 .pth fix automatically)
114126
script/setup-dev
115127

116-
# Run Python tests (38 unit + integration)
128+
# Run Python tests (81 unit + integration)
117129
pytest -v
118130

119131
# Run Godot-side tests (44 handler tests, requires editor running)
@@ -126,6 +138,22 @@ ruff check src/ tests/
126138
python -m godot_ai --transport streamable-http --port 8000 --reload
127139
```
128140

141+
### Contributing
142+
143+
Work on feature branches and open PRs against `main`:
144+
145+
```bash
146+
git checkout -b feature/my-feature
147+
# ... make changes ...
148+
pytest -v # Python tests must pass
149+
ruff check src/ tests/ # Lint must pass
150+
# Also run Godot-side tests via the run_tests MCP tool
151+
git push -u origin feature/my-feature
152+
gh pr create
153+
```
154+
155+
PRs should include tests for new functionality (both Python and Godot-side where applicable).
156+
129157
## License
130158

131159
TBD

docs/implementation-plan.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -448,10 +448,10 @@ Large results (scene trees with 1000+ nodes, long log buffers) need pagination:
448448
- [x] Batch 1: Session and editor tools
449449
- [x] Batch 2: Scene read tools (6 tools)
450450
- [x] Batch 3: Project reads (project_settings.get, filesystem.search)
451-
- [ ] Batch 4: MCP Resources
451+
- [x] Batch 4: MCP Resources (7 resources: sessions, scene/current, scene/hierarchy, selection/current, project/info, project/settings, logs/recent)
452452
- [x] Batch 5: Editor dock panel with setup status
453-
- [x] Batch 6: Test harness (35 Godot-side + 32 Python = 67 total tests)
454-
- [ ] Pagination for large results
453+
- [x] Batch 6: Test harness (44 Godot-side + 81 Python = 125 total tests)
454+
- [x] Pagination for large results (offset/limit on scene_get_hierarchy, logs_read, node_find, filesystem_search)
455455
- [ ] Manual test: Claude describes the open scene
456456

457457
---

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ description = "Production-grade MCP server and AI tools for the Godot engine"
55
readme = "README.md"
66
requires-python = ">=3.11"
77
dependencies = [
8-
"fastmcp>=2.0.0",
8+
"fastmcp>=3.0.0",
99
"websockets>=13.0",
1010
"pydantic>=2.0",
1111
]

src/godot_ai/resources/editor.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
"""MCP resources for editor state — selection and logs."""
2+
3+
from __future__ import annotations
4+
5+
import json
6+
7+
from fastmcp import Context, FastMCP
8+
9+
10+
def register_editor_resources(mcp: FastMCP) -> None:
11+
@mcp.resource("godot://selection/current", mime_type="application/json")
12+
async def get_current_selection(ctx: Context) -> str:
13+
"""Currently selected nodes in the Godot editor."""
14+
app = ctx.lifespan_context
15+
try:
16+
return json.dumps(await app.client.send("get_selection"))
17+
except Exception as e:
18+
return json.dumps({"error": str(e), "connected": False})
19+
20+
@mcp.resource("godot://logs/recent", mime_type="application/json")
21+
async def get_recent_logs(ctx: Context) -> str:
22+
"""Last 100 log lines from the Godot editor console."""
23+
app = ctx.lifespan_context
24+
try:
25+
return json.dumps(await app.client.send("get_logs", {"count": 100}))
26+
except Exception as e:
27+
return json.dumps({"error": str(e), "connected": False})

src/godot_ai/resources/project.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
"""MCP resources for project info and settings."""
2+
3+
from __future__ import annotations
4+
5+
import asyncio
6+
import json
7+
8+
from fastmcp import Context, FastMCP
9+
10+
COMMON_SETTINGS = [
11+
"application/config/name",
12+
"application/config/description",
13+
"application/run/main_scene",
14+
"display/window/size/viewport_width",
15+
"display/window/size/viewport_height",
16+
"rendering/renderer/rendering_method",
17+
"physics/2d/default_gravity",
18+
"physics/3d/default_gravity",
19+
]
20+
21+
22+
def register_project_resources(mcp: FastMCP) -> None:
23+
@mcp.resource("godot://project/info", mime_type="application/json")
24+
async def get_project_info(ctx: Context) -> str:
25+
"""Project name, Godot version, paths, and play state."""
26+
app = ctx.lifespan_context
27+
session = app.registry.get_active()
28+
if session is None:
29+
return json.dumps({"error": "No active Godot session", "connected": False})
30+
31+
info = session.to_dict()
32+
info.pop("connected_at", None)
33+
return json.dumps(info)
34+
35+
@mcp.resource("godot://project/settings", mime_type="application/json")
36+
async def get_project_settings(ctx: Context) -> str:
37+
"""Common project settings subset (display, physics, rendering)."""
38+
app = ctx.lifespan_context
39+
40+
async def _fetch(key: str) -> tuple[str, object | None, str | None]:
41+
try:
42+
result = await app.client.send("get_project_setting", {"key": key})
43+
return key, result.get("value"), None
44+
except Exception as e:
45+
return key, None, str(e)
46+
47+
results = await asyncio.gather(*[_fetch(key) for key in COMMON_SETTINGS])
48+
settings = {}
49+
errors = []
50+
for key, value, error in results:
51+
if error:
52+
errors.append({"key": key, "error": error})
53+
else:
54+
settings[key] = value
55+
56+
return json.dumps({"settings": settings, "errors": errors if errors else None})

src/godot_ai/resources/scenes.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
"""MCP resources for scene data."""
2+
3+
from __future__ import annotations
4+
5+
import json
6+
7+
from fastmcp import Context, FastMCP
8+
9+
10+
def register_scene_resources(mcp: FastMCP) -> None:
11+
@mcp.resource("godot://scene/current", mime_type="application/json")
12+
async def get_current_scene(ctx: Context) -> str:
13+
"""Current scene path and root node info from the active Godot editor."""
14+
app = ctx.lifespan_context
15+
try:
16+
state = await app.client.send("get_editor_state")
17+
return json.dumps({
18+
"current_scene": state.get("current_scene", ""),
19+
"project_name": state.get("project_name", ""),
20+
"is_playing": state.get("is_playing", False),
21+
})
22+
except Exception as e:
23+
return json.dumps({"error": str(e), "connected": False})
24+
25+
@mcp.resource("godot://scene/hierarchy", mime_type="application/json")
26+
async def get_scene_hierarchy(ctx: Context) -> str:
27+
"""Full scene tree hierarchy from the active Godot editor."""
28+
app = ctx.lifespan_context
29+
try:
30+
return json.dumps(await app.client.send("get_scene_tree", {"depth": 10}))
31+
except Exception as e:
32+
return json.dumps({"error": str(e), "connected": False})

src/godot_ai/resources/sessions.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,19 @@
22

33
from __future__ import annotations
44

5+
import json
6+
57
from fastmcp import Context, FastMCP
68

79

810
def register_session_resources(mcp: FastMCP) -> None:
9-
@mcp.resource("godot://sessions")
10-
def get_sessions(ctx: Context) -> dict:
11+
@mcp.resource("godot://sessions", mime_type="application/json")
12+
def get_sessions(ctx: Context) -> str:
1113
"""All connected Godot editor sessions and their metadata."""
1214
app = ctx.lifespan_context
1315
sessions = app.registry.list_all()
1416
active_id = app.registry.active_session_id
15-
return {
17+
return json.dumps({
1618
"sessions": [{**s.to_dict(), "is_active": s.session_id == active_id} for s in sessions],
1719
"count": len(sessions),
18-
}
20+
})

src/godot_ai/server.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@
1111
from fastmcp import FastMCP
1212

1313
from godot_ai.godot_client.client import GodotClient
14+
from godot_ai.resources.editor import register_editor_resources
15+
from godot_ai.resources.project import register_project_resources
16+
from godot_ai.resources.scenes import register_scene_resources
1417
from godot_ai.resources.sessions import register_session_resources
1518
from godot_ai.sessions.registry import SessionRegistry
1619
from godot_ai.tools.client import register_client_tools
@@ -69,5 +72,8 @@ async def _lifespan(server: FastMCP) -> AsyncIterator[AppContext]:
6972
register_client_tools(mcp)
7073
register_testing_tools(mcp)
7174
register_session_resources(mcp)
75+
register_scene_resources(mcp)
76+
register_editor_resources(mcp)
77+
register_project_resources(mcp)
7278

7379
return mcp

src/godot_ai/tools/_pagination.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
"""Shared pagination helper for MCP tools."""
2+
3+
from __future__ import annotations
4+
5+
6+
def paginate(items: list, offset: int, limit: int, *, key: str = "items") -> dict:
7+
"""Apply offset/limit pagination to a list.
8+
9+
Returns a dict with the paginated slice under the given key,
10+
plus total_count, offset, limit, and has_more metadata.
11+
"""
12+
total_count = len(items)
13+
page = items[offset : offset + limit]
14+
return {
15+
key: page,
16+
"total_count": total_count,
17+
"offset": offset,
18+
"limit": limit,
19+
"has_more": offset + limit < total_count,
20+
}

0 commit comments

Comments
 (0)