From 0b1beb0e9f11861a8a34c9e34268488b5c6cc11f Mon Sep 17 00:00:00 2001 From: Rupert Rebentisch Date: Wed, 18 Mar 2026 22:15:27 +0100 Subject: [PATCH 1/5] Fix TypeError for MCP tools with nullable JSON Schema params MCP servers (e.g. Zapier) return JSON Schema union types like `"type": ["string", "null"]` for nullable parameters. The existing `validate_params()` and `cast_params()` methods expected only simple strings as `type`, causing `TypeError: unhashable type: 'list'` on every MCP tool call with nullable parameters. Add `_resolve_type()` helper that extracts the first non-null type from union types, and use it in `_cast_value()` and `_validate()`. Also handle `None` values correctly when the schema declares a nullable type. Co-Authored-By: Claude Opus 4.6 (1M context) --- nanobot/agent/tools/base.py | 22 +++++++++++-- tests/test_tool_validation.py | 61 +++++++++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+), 2 deletions(-) diff --git a/nanobot/agent/tools/base.py b/nanobot/agent/tools/base.py index 06f5bdd..b9bafe7 100644 --- a/nanobot/agent/tools/base.py +++ b/nanobot/agent/tools/base.py @@ -21,6 +21,20 @@ class Tool(ABC): "object": dict, } + @staticmethod + def _resolve_type(t: Any) -> str | None: + """Resolve JSON Schema type to a simple string. + + JSON Schema allows ``"type": ["string", "null"]`` (union types). + We extract the first non-null type so validation/casting works. + """ + if isinstance(t, list): + for item in t: + if item != "null": + return item + return None + return t + @property @abstractmethod def name(self) -> str: @@ -78,7 +92,7 @@ class Tool(ABC): def _cast_value(self, val: Any, schema: dict[str, Any]) -> Any: """Cast a single value according to schema.""" - target_type = schema.get("type") + target_type = self._resolve_type(schema.get("type")) if target_type == "boolean" and isinstance(val, bool): return val @@ -131,7 +145,11 @@ class Tool(ABC): return self._validate(params, {**schema, "type": "object"}, "") def _validate(self, val: Any, schema: dict[str, Any], path: str) -> list[str]: - t, label = schema.get("type"), path or "parameter" + raw_type = schema.get("type") + nullable = isinstance(raw_type, list) and "null" in raw_type + t, label = self._resolve_type(raw_type), path or "parameter" + if nullable and val is None: + return [] if t == "integer" and (not isinstance(val, int) or isinstance(val, bool)): return [f"{label} should be integer"] if t == "number" and ( diff --git a/tests/test_tool_validation.py b/tests/test_tool_validation.py index 1d822b3..e817f37 100644 --- a/tests/test_tool_validation.py +++ b/tests/test_tool_validation.py @@ -406,3 +406,64 @@ async def test_exec_timeout_capped_at_max() -> None: # Should not raise — just clamp to 600 result = await tool.execute(command="echo ok", timeout=9999) assert "Exit code: 0" in result + + +# --- _resolve_type and nullable param tests --- + + +def test_resolve_type_simple_string() -> None: + """Simple string type passes through unchanged.""" + assert Tool._resolve_type("string") == "string" + + +def test_resolve_type_union_with_null() -> None: + """Union type ['string', 'null'] resolves to 'string'.""" + assert Tool._resolve_type(["string", "null"]) == "string" + + +def test_resolve_type_only_null() -> None: + """Union type ['null'] resolves to None (no non-null type).""" + assert Tool._resolve_type(["null"]) is None + + +def test_resolve_type_none_input() -> None: + """None input passes through as None.""" + assert Tool._resolve_type(None) is None + + +def test_validate_nullable_param_accepts_string() -> None: + """Nullable string param should accept a string value.""" + tool = CastTestTool( + { + "type": "object", + "properties": {"name": {"type": ["string", "null"]}}, + } + ) + errors = tool.validate_params({"name": "hello"}) + assert errors == [] + + +def test_validate_nullable_param_accepts_none() -> None: + """Nullable string param should accept None.""" + tool = CastTestTool( + { + "type": "object", + "properties": {"name": {"type": ["string", "null"]}}, + } + ) + errors = tool.validate_params({"name": None}) + assert errors == [] + + +def test_cast_nullable_param_no_crash() -> None: + """cast_params should not crash on nullable type (the original bug).""" + tool = CastTestTool( + { + "type": "object", + "properties": {"name": {"type": ["string", "null"]}}, + } + ) + result = tool.cast_params({"name": "hello"}) + assert result["name"] == "hello" + result = tool.cast_params({"name": None}) + assert result["name"] is None From d70ed0d97a81bca1f9dd2a77793759cd802a9948 Mon Sep 17 00:00:00 2001 From: mamamiyear Date: Fri, 20 Mar 2026 00:41:16 +0800 Subject: [PATCH 2/5] fix: nanobot onboard update config crash when use onboard and choose N, maybe sometimes will be crash and config file will be invalid. --- nanobot/config/loader.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/nanobot/config/loader.py b/nanobot/config/loader.py index 7d309e5..2cd0a7d 100644 --- a/nanobot/config/loader.py +++ b/nanobot/config/loader.py @@ -5,7 +5,6 @@ from pathlib import Path from nanobot.config.schema import Config - # Global variable to store current config path (for multi-instance support) _current_config_path: Path | None = None @@ -59,7 +58,7 @@ def save_config(config: Config, config_path: Path | None = None) -> None: path = config_path or get_config_path() path.parent.mkdir(parents=True, exist_ok=True) - data = config.model_dump(by_alias=True) + data = config.model_dump(mode="json", by_alias=True) with open(path, "w", encoding="utf-8") as f: json.dump(data, f, indent=2, ensure_ascii=False) From 517de6b731018ded4b92c4d0803855d9ea053397 Mon Sep 17 00:00:00 2001 From: JilunSun7274 Date: Thu, 19 Mar 2026 14:25:46 +0800 Subject: [PATCH 3/5] docs: add subagent workspace assignment hint to spawn tool description --- nanobot/agent/tools/spawn.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/nanobot/agent/tools/spawn.py b/nanobot/agent/tools/spawn.py index fc62bf8..30dfab7 100644 --- a/nanobot/agent/tools/spawn.py +++ b/nanobot/agent/tools/spawn.py @@ -32,7 +32,8 @@ class SpawnTool(Tool): return ( "Spawn a subagent to handle a task in the background. " "Use this for complex or time-consuming tasks that can run independently. " - "The subagent will complete the task and report back when done." + "The subagent will complete the task and report back when done.\n " + "For deliverables or existing projects, inspect the workspace and assign/create a dedicated working directory for the subagent." ) @property From e5179aa7db034c02c87be5b86f194df4e6c9bbc5 Mon Sep 17 00:00:00 2001 From: JilunSun7274 Date: Thu, 19 Mar 2026 14:29:42 +0800 Subject: [PATCH 4/5] delete redundant whitespaces in subagent prompts --- nanobot/agent/tools/spawn.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nanobot/agent/tools/spawn.py b/nanobot/agent/tools/spawn.py index 30dfab7..0685712 100644 --- a/nanobot/agent/tools/spawn.py +++ b/nanobot/agent/tools/spawn.py @@ -32,7 +32,7 @@ class SpawnTool(Tool): return ( "Spawn a subagent to handle a task in the background. " "Use this for complex or time-consuming tasks that can run independently. " - "The subagent will complete the task and report back when done.\n " + "The subagent will complete the task and report back when done. " "For deliverables or existing projects, inspect the workspace and assign/create a dedicated working directory for the subagent." ) From c138b2375baecd62e90816890b59aa71124d63d7 Mon Sep 17 00:00:00 2001 From: Xubin Ren Date: Fri, 20 Mar 2026 05:26:39 +0000 Subject: [PATCH 5/5] docs: refine spawn workspace guidance wording Adjust the spawn tool description to keep the workspace-organizing hint while avoiding language that sounds like the system automatically assigns a dedicated working directory for subagents. Made-with: Cursor --- nanobot/agent/tools/spawn.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/nanobot/agent/tools/spawn.py b/nanobot/agent/tools/spawn.py index 0685712..2050eed 100644 --- a/nanobot/agent/tools/spawn.py +++ b/nanobot/agent/tools/spawn.py @@ -33,7 +33,8 @@ class SpawnTool(Tool): "Spawn a subagent to handle a task in the background. " "Use this for complex or time-consuming tasks that can run independently. " "The subagent will complete the task and report back when done. " - "For deliverables or existing projects, inspect the workspace and assign/create a dedicated working directory for the subagent." + "For deliverables or existing projects, inspect the workspace first " + "and use a dedicated subdirectory when helpful." ) @property