diff --git a/nanobot/agent/tools/mcp.py b/nanobot/agent/tools/mcp.py index 7902e66b..73d66b0d 100644 --- a/nanobot/agent/tools/mcp.py +++ b/nanobot/agent/tools/mcp.py @@ -317,13 +317,17 @@ def _extract_nullable_branch(options: Any) -> tuple[dict[str, Any], bool] | None def _resolve_local_schema_ref(root: dict[str, Any], ref: str) -> Any: """Resolve a local JSON Pointer without accepting remote references.""" - if ref == "#": + if not ref.startswith("#"): + raise ValueError("not a local JSON Pointer") + + pointer = urllib.parse.unquote(ref[1:], errors="strict") + if not pointer: return root - if not ref.startswith("#/"): + if not pointer.startswith("/"): raise ValueError("not a local JSON Pointer") current: Any = root - for raw_part in ref[2:].split("/"): + for raw_part in pointer[1:].split("/"): part = raw_part.replace("~1", "/").replace("~0", "~") if isinstance(current, dict): current = current[part] @@ -347,15 +351,22 @@ def _rewrite_local_schema_refs(schema: dict[str, Any]) -> dict[str, Any]: rewritten = dict(value) ref = rewritten.get("$ref") - is_rewritable_ref = isinstance(ref, str) and ( - ref == "#" or (ref.startswith("#/") and not ref.startswith("#/$defs/")) - ) + is_rewritable_ref = False + if isinstance(ref, str) and not ref.startswith("#/$defs/"): + try: + pointer = urllib.parse.unquote(ref[1:], errors="strict") + except (UnicodeDecodeError, ValueError): + pass + else: + is_rewritable_ref = ref.startswith("#") and ( + not pointer or pointer.startswith("/") + ) if is_rewritable_ref: name = rewritten_refs.get(ref) if name is None: try: target = _resolve_local_schema_ref(schema, ref) - except (KeyError, IndexError, TypeError, ValueError): + except (KeyError, IndexError, TypeError, UnicodeDecodeError, ValueError): logger.warning("MCP tool schema contains an unresolved local $ref: {}", ref) else: assert isinstance(ref, str) diff --git a/tests/tools/test_mcp_tool.py b/tests/tools/test_mcp_tool.py index 233c51fa..05f57ea4 100644 --- a/tests/tools/test_mcp_tool.py +++ b/tests/tools/test_mcp_tool.py @@ -309,6 +309,27 @@ def test_wrapper_preserves_existing_defs_refs() -> None: assert wrapper.parameters["$defs"]["value"]["type"] == "string" +def test_wrapper_resolves_uri_encoded_json_pointer() -> None: + tool_def = SimpleNamespace( + name="demo", + description="demo tool", + inputSchema={ + "type": "object", + "properties": { + "space name/value": {"type": "string"}, + "alias": {"$ref": "#/properties/space%20name~1value"}, + }, + }, + ) + + wrapper = MCPToolWrapper(SimpleNamespace(call_tool=None), "test", tool_def) + + generated_ref = wrapper.parameters["properties"]["alias"]["$ref"] + assert generated_ref.startswith("#/$defs/ref_") + generated_name = generated_ref.removeprefix("#/$defs/") + assert wrapper.parameters["$defs"][generated_name] == {"type": "string"} + + def test_normalize_windows_stdio_command_is_noop_off_windows( monkeypatch: pytest.MonkeyPatch, ) -> None: