Skip to content

Commit 9297026

Browse files
dsarnoclaude
andcommitted
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>
1 parent 675cea8 commit 9297026

18 files changed

Lines changed: 1193 additions & 21 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 # 89 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 (89 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 + 89 Python = 133 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
---

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 (ConnectionError, 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 (ConnectionError, Exception) as e:
27+
return json.dumps({"error": str(e), "connected": False})

src/godot_ai/resources/project.py

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

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,14 +26,31 @@ async def editor_selection_get(ctx: Context) -> dict:
2626
return await app.client.send("get_selection")
2727

2828
@mcp.tool()
29-
async def logs_read(ctx: Context, count: int = 50) -> dict:
29+
async def logs_read(
30+
ctx: Context,
31+
count: int = 50,
32+
offset: int = 0,
33+
) -> dict:
3034
"""Read recent log lines from the Godot editor console.
3135
32-
Returns the most recent log lines captured by the MCP plugin,
36+
Returns paginated log lines captured by the MCP plugin,
3337
including MCP command traffic when logging is enabled.
3438
3539
Args:
36-
count: Number of recent lines to return. Default 50.
40+
count: Maximum number of lines to return. Default 50.
41+
offset: Number of lines to skip from the start. Default 0.
3742
"""
3843
app = ctx.lifespan_context
39-
return await app.client.send("get_logs", {"count": count})
44+
# Request more lines than needed to support offset
45+
total_request = offset + count
46+
result = await app.client.send("get_logs", {"count": total_request})
47+
lines = result.get("lines", [])
48+
total_count = len(lines)
49+
page = lines[offset : offset + count]
50+
return {
51+
"lines": page,
52+
"total_count": total_count,
53+
"offset": offset,
54+
"limit": count,
55+
"has_more": offset + count < total_count,
56+
}

src/godot_ai/tools/node.py

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,22 +36,36 @@ async def node_find(
3636
name: str = "",
3737
type: str = "",
3838
group: str = "",
39+
offset: int = 0,
40+
limit: int = 100,
3941
) -> dict:
4042
"""Find nodes in the scene tree by name, type, or group.
4143
4244
At least one filter must be provided. Filters are combined with AND
43-
logic — a node must match all specified filters.
45+
logic — a node must match all specified filters. Results are paginated.
4446
4547
Args:
4648
name: Substring match on node name (case-insensitive).
4749
type: Exact Godot class name (e.g. "MeshInstance3D").
4850
group: Group name the node must belong to.
51+
offset: Number of results to skip. Default 0.
52+
limit: Maximum number of results to return. Default 100.
4953
"""
5054
app = ctx.lifespan_context
51-
return await app.client.send(
55+
result = await app.client.send(
5256
"find_nodes",
5357
{"name": name, "type": type, "group": group},
5458
)
59+
nodes = result.get("nodes", [])
60+
total_count = len(nodes)
61+
page = nodes[offset : offset + limit]
62+
return {
63+
"nodes": page,
64+
"total_count": total_count,
65+
"offset": offset,
66+
"limit": limit,
67+
"has_more": offset + limit < total_count,
68+
}
5569

5670
@mcp.tool()
5771
async def node_get_properties(ctx: Context, path: str) -> dict:

0 commit comments

Comments
 (0)