Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions src/godot_ai/tools/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,32 @@
`DEFER_META` marks a tool as deferred-loading for clients using Anthropic
tool search. Core tools (always loaded: editor_state, scene_get_hierarchy,
node_get_properties, session_list, session_activate) omit it.

`JsonCoerced` is a pydantic `BeforeValidator` that JSON-decodes string
inputs before list/dict validation runs. Some MCP clients (Claude Code
as of 2026-04) stringify complex-typed tool arguments before sending
them over the wire, so a `list[dict]` parameter arrives as its JSON
representation. Annotating such params with this validator lets the
tool accept both the real structure and the stringified form. See #11.
"""

from __future__ import annotations

import json
from typing import Any

from pydantic import BeforeValidator

DEFER_META: dict[str, object] = {"defer_loading": True}


def _coerce_json(value: Any) -> Any:
if isinstance(value, str):
try:
return json.loads(value)
except json.JSONDecodeError:
return value
return value


JsonCoerced = BeforeValidator(_coerce_json)
6 changes: 4 additions & 2 deletions src/godot_ai/tools/batch.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,20 @@

from __future__ import annotations

from typing import Annotated

from fastmcp import Context, FastMCP

from godot_ai.handlers import batch as batch_handlers
from godot_ai.runtime.direct import DirectRuntime
from godot_ai.tools import DEFER_META
from godot_ai.tools import DEFER_META, JsonCoerced


def register_batch_tools(mcp: FastMCP) -> None:
@mcp.tool(meta=DEFER_META)
async def batch_execute(
ctx: Context,
commands: list[dict],
commands: Annotated[list[dict], JsonCoerced],
undo: bool = True,
session_id: str = "",
) -> dict:
Expand Down
8 changes: 5 additions & 3 deletions src/godot_ai/tools/editor.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,13 @@

from __future__ import annotations

from typing import Annotated

from fastmcp import Context, FastMCP

from godot_ai.handlers import editor as editor_handlers
from godot_ai.runtime.direct import DirectRuntime
from godot_ai.tools import DEFER_META
from godot_ai.tools import DEFER_META, JsonCoerced


def register_editor_tools(mcp: FastMCP) -> None:
Expand Down Expand Up @@ -136,7 +138,7 @@ async def editor_screenshot(
@mcp.tool(meta=DEFER_META)
async def performance_monitors_get(
ctx: Context,
monitors: list[str] | None = None,
monitors: Annotated[list[str] | None, JsonCoerced] = None,
session_id: str = "",
) -> dict:
"""Get Godot performance monitor values (FPS, memory, draw calls, frame time).
Expand Down Expand Up @@ -209,7 +211,7 @@ async def editor_reload_plugin(ctx: Context, session_id: str = "") -> dict:
@mcp.tool(meta=DEFER_META)
async def editor_selection_set(
ctx: Context,
paths: list[str],
paths: Annotated[list[str], JsonCoerced],
session_id: str = "",
) -> dict:
"""Select nodes in the Godot editor by their scene paths.
Expand Down
6 changes: 4 additions & 2 deletions src/godot_ai/tools/filesystem.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,13 @@

from __future__ import annotations

from typing import Annotated

from fastmcp import Context, FastMCP

from godot_ai.handlers import filesystem as filesystem_handlers
from godot_ai.runtime.direct import DirectRuntime
from godot_ai.tools import DEFER_META
from godot_ai.tools import DEFER_META, JsonCoerced


def register_filesystem_tools(mcp: FastMCP) -> None:
Expand Down Expand Up @@ -52,7 +54,7 @@ async def filesystem_write_text(
@mcp.tool(meta=DEFER_META)
async def filesystem_reimport(
ctx: Context,
paths: list[str],
paths: Annotated[list[str], JsonCoerced],
session_id: str = "",
) -> dict:
"""Force reimport of specific files / assets in the Godot project.
Expand Down
137 changes: 137 additions & 0 deletions tests/integration/test_mcp_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -2557,3 +2557,140 @@ async def test_session_id_respects_target_readiness(self, mcp_stack):
assert "EDITOR_NOT_READY" in str(result.content)
finally:
await plugin_b.close()


# ---------------------------------------------------------------------------
# JSON-string coercion for list params (issue #11 — Claude Code MCP client
# stringifies complex-typed args before sending)
# ---------------------------------------------------------------------------


class TestJsonStringParamCoercion:
async def test_batch_execute_accepts_stringified_commands(self, mcp_stack):
client, plugin = mcp_stack

async def respond():
cmd = await plugin.recv_command()
assert cmd["command"] == "batch_execute"
assert cmd["params"]["commands"] == [
{"command": "create_node", "params": {"type": "Node3D", "name": "X"}}
]
await plugin.send_response(
cmd["request_id"],
{
"succeeded": 1,
"stopped_at": None,
"results": [
{"command": "create_node", "status": "ok", "data": {"undoable": True}}
],
"undo": True,
"rolled_back": False,
"undoable": True,
},
)

task = asyncio.create_task(respond())
result = await client.call_tool(
"batch_execute",
{
"commands": json.dumps(
[{"command": "create_node", "params": {"type": "Node3D", "name": "X"}}]
)
},
)
await task
assert not result.is_error
assert result.data["succeeded"] == 1

async def test_editor_selection_set_accepts_stringified_paths(self, mcp_stack):
client, plugin = mcp_stack

async def respond():
cmd = await plugin.recv_command()
assert cmd["params"]["paths"] == ["/Main/Camera3D", "/Main/World"]
await plugin.send_response(
cmd["request_id"],
{
"selected": ["/Main/Camera3D", "/Main/World"],
"not_found": [],
"count": 2,
"undoable": False,
},
)

task = asyncio.create_task(respond())
result = await client.call_tool(
"editor_selection_set",
{"paths": json.dumps(["/Main/Camera3D", "/Main/World"])},
)
await task
assert result.data["count"] == 2

async def test_filesystem_reimport_accepts_stringified_paths(self, mcp_stack):
client, plugin = mcp_stack

async def respond():
cmd = await plugin.recv_command()
assert cmd["params"]["paths"] == ["res://a.png", "res://b.png"]
await plugin.send_response(
cmd["request_id"],
{"reimported": ["res://a.png", "res://b.png"], "count": 2},
)

task = asyncio.create_task(respond())
result = await client.call_tool(
"filesystem_reimport",
{"paths": json.dumps(["res://a.png", "res://b.png"])},
)
await task
assert result.data["count"] == 2

async def test_performance_monitors_get_accepts_stringified_monitors(self, mcp_stack):
client, plugin = mcp_stack

async def respond():
cmd = await plugin.recv_command()
assert cmd["params"]["monitors"] == ["time/fps"]
await plugin.send_response(
cmd["request_id"], {"monitors": {"time/fps": 60}, "missing": []}
)

task = asyncio.create_task(respond())
result = await client.call_tool(
"performance_monitors_get",
{"monitors": json.dumps(["time/fps"])},
)
await task
assert result.data["monitors"]["time/fps"] == 60

async def test_real_list_still_works(self, mcp_stack):
"""Regression check — passing actual lists must continue to work."""
client, plugin = mcp_stack

async def respond():
cmd = await plugin.recv_command()
assert cmd["params"]["paths"] == ["res://x.png"]
await plugin.send_response(
cmd["request_id"], {"reimported": ["res://x.png"], "count": 1}
)

task = asyncio.create_task(respond())
result = await client.call_tool(
"filesystem_reimport",
{"paths": ["res://x.png"]},
)
await task
assert result.data["count"] == 1

async def test_malformed_json_string_still_raises_validation(self, mcp_stack):
"""A non-JSON string must fall through and fail pydantic validation."""
client, _plugin = mcp_stack
result = await client.call_tool(
"filesystem_reimport",
{"paths": "not-json-at-all"},
raise_on_error=False,
)
assert result.is_error
error_text = str(result.content).lower()
assert "paths" in error_text
assert "input should be a valid list" in error_text or "list_type" in error_text