From 7db39fd2888023bc7c279576e656de6a51f6cfac Mon Sep 17 00:00:00 2001 From: jcy <34598069+jiangchangyue@users.noreply.github.com> Date: Thu, 25 Jun 2026 15:48:03 +0800 Subject: [PATCH 1/2] fix issues #17, #18 --- CHANGELOG.md | 7 ++ README.md | 5 ++ README.zh.md | 5 ++ docs/reference/api.mdx | 4 ++ docs/reference/kit.mdx | 2 + docs/zh/reference/kit.mdx | 1 + qitos/core/tool.py | 7 +- qitos/core/tool_schema.py | 2 + qitos/kit/parser/parser_utils.py | 78 +++++++++++++++++++--- qitos/kit/parser/react_parser.py | 29 +++++++- tests/test_memory_and_parser_and_critic.py | 39 +++++++++++ tests/test_tool_registry_and_toolset.py | 53 +++++++++++++++ 12 files changed, 222 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c0e5ef3..1986db2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,13 @@ How to update: - Move `Unreleased` notes into a dated or versioned section when publishing a release - Prefer user-facing changes, upgrade notes, and important engineering changes over low-level edit logs +## Unreleased + +### Fixed + +- Fixed OpenAI-compatible tool schema export so `Any` and `**kwargs` no longer produce invalid `{"type": "any"}` entries in native `tools=` payloads. +- Fixed `ReActTextParser` so common ReAct variants such as `Action Input` blocks, XML-style action tags, and fenced JSON tool calls no longer fall into parser repair loops. + ## v0.8.0 (2026-06-18) ### Added diff --git a/README.md b/README.md index 7730ba1..f74c2ca 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,11 @@ QitOS core is the small framework. Product-grade applications and showcase agent [Quickstart](https://qitor.mintlify.app/quickstart) · [Tutorial Track](https://qitor.mintlify.app/tutorials) · [Benchmarks](https://qitor.mintlify.app/benchmarks/overview) · [CLI Reference](https://qitor.mintlify.app/reference/cli) · [Changelog](CHANGELOG.md) · [Chinese README](README.zh.md) +## Latest Updates + +- **Native tool schema hardening**: OpenAI-compatible `tools=` payloads no longer export invalid `type: any` schemas for `Any` or `**kwargs` parameters. +- **ReAct parser compatibility**: `ReActTextParser` now accepts common `Action Input`, XML-style action tags, and fenced JSON tool-call variants that some OpenAI-compatible models emit. + ## What's New in v0.8.0 - **Architecture-clean stable release**: v0.8.0 documents package ownership, large-file hotspots, optional dependency boundaries, and release guardrails for contributors. diff --git a/README.zh.md b/README.zh.md index 310163f..6cb09cc 100644 --- a/README.zh.md +++ b/README.zh.md @@ -16,6 +16,11 @@ QitOS 主仓库是小而清晰的核心框架。产品级 / 展示级应用会 [快速开始](https://qitor.mintlify.app/zh/quickstart) · [教程课程](https://qitor.mintlify.app/zh/tutorials) · [基准测试](https://qitor.mintlify.app/zh/benchmarks/overview) · [CLI 参考](https://qitor.mintlify.app/zh/reference/cli) · [更新日志](CHANGELOG.md) · [English README](README.md) +## 最新进展 + +- **原生工具 schema 加固**:OpenAI-compatible `tools=` payload 不再为 `Any` 或 `**kwargs` 参数导出非法的 `type: any` schema。 +- **ReAct 解析兼容性**:`ReActTextParser` 现在可以解析部分 OpenAI-compatible 模型常见的 `Action Input`、XML 风格 action 标签和围栏 JSON tool-call 变体。 + ## v0.8.0 最新进展 - **架构清洁稳定版**:v0.8.0 记录包边界、超大文件热点、可选依赖边界和发布守护规则,方便贡献者判断代码归属。 diff --git a/docs/reference/api.mdx b/docs/reference/api.mdx index abee32b..672e7a3 100644 --- a/docs/reference/api.mdx +++ b/docs/reference/api.mdx @@ -727,6 +727,10 @@ def setup(self, context: Optional[Dict[str, Any]] = None) -> None def teardown(self, context: Optional[Dict[str, Any]] = None) -> None ``` +`get_all_specs()` returns OpenAI-compatible function specs for native tool delivery. +Unbounded `**kwargs` parameters are omitted from the exported schema, and `Any` +parameters are rendered without the invalid JSON Schema type `any`. + **Example** ```python diff --git a/docs/reference/kit.mdx b/docs/reference/kit.mdx index 10fea9e..ca7a8e9 100644 --- a/docs/reference/kit.mdx +++ b/docs/reference/kit.mdx @@ -95,6 +95,8 @@ Prompt format and parser must match exactly. If your prompt asks for `Thought:` Parses ReAct-style text output with labeled blocks such as `Thought:` and `Action:`. +It also accepts common action variants such as `Action Input: {...}`, +XML-style `...` tags, and fenced JSON tool-call objects. ```python from qitos.kit import ReActTextParser diff --git a/docs/zh/reference/kit.mdx b/docs/zh/reference/kit.mdx index b170c06..cab4e72 100644 --- a/docs/zh/reference/kit.mdx +++ b/docs/zh/reference/kit.mdx @@ -75,6 +75,7 @@ from qitos.harness import build_harness_policy, build_model_for_preset 用于解析带 `Thought:`、`Action:`、`Final Answer:` 标记的 ReAct 文本。 +同时兼容常见 action 变体,例如 `Action Input: {...}`、XML 风格的 `...` 标签,以及围栏 JSON tool-call 对象。 ```python from qitos.kit import ReActTextParser diff --git a/qitos/core/tool.py b/qitos/core/tool.py index e367165..a8772e7 100644 --- a/qitos/core/tool.py +++ b/qitos/core/tool.py @@ -520,7 +520,12 @@ def build_tool_spec(func: Callable[..., Any], meta: ToolMeta) -> ToolSpec: "process_ops", }: continue - params[name] = {"type": _type_to_json(p.annotation), "description": ""} + if p.kind is inspect.Parameter.VAR_KEYWORD: + continue + json_type = _type_to_json(p.annotation) + params[name] = {"description": ""} + if json_type != "any": + params[name]["type"] = json_type if p.default is inspect.Parameter.empty: required.append(name) diff --git a/qitos/core/tool_schema.py b/qitos/core/tool_schema.py index fd2d88e..a771a45 100644 --- a/qitos/core/tool_schema.py +++ b/qitos/core/tool_schema.py @@ -42,6 +42,8 @@ def function_schema(func: Any) -> Dict[str, Any]: for name, p in sig.parameters.items(): if name in skip: continue + if p.kind is inspect.Parameter.VAR_KEYWORD: + continue annotation = hints.get(name, p.annotation) schema = type_to_json_schema(annotation) entry: Dict[str, Any] = dict(schema) diff --git a/qitos/kit/parser/parser_utils.py b/qitos/kit/parser/parser_utils.py index 14f24ec..31459b2 100644 --- a/qitos/kit/parser/parser_utils.py +++ b/qitos/kit/parser/parser_utils.py @@ -10,6 +10,9 @@ from qitos.kit.parser.func_parser import parse_first_action_invocation +_ACTION_INPUT_KEYS = ("actioninput", "toolinput", "input", "args", "arguments") +_BARE_ACTION_NAME = re.compile(r"^[A-Za-z_][A-Za-z0-9_.-]*$") + def norm(token: str) -> str: return re.sub(r"[\s_\-]+", "", token.strip().lower()) @@ -53,17 +56,74 @@ def parse_action_any(blob: str) -> Optional[Dict[str, Any]]: if parsed is not None: return parsed - obj = parse_object_like(text) - if isinstance(obj, dict): - name = obj.get("name") - args = obj.get("args", {}) - if isinstance(name, str): - if not isinstance(args, dict): - args = {} - return {"name": name, "args": args} + obj = parse_object_like(strip_code_fences(text)) + action = coerce_action_object(obj) + if action is not None: + return action + + obj, _warnings = parse_jsonish_object(text) + action = coerce_action_object(obj) + if action is not None: + return action + + action = parse_labeled_action_input(text) + if action is not None: + return action return None +def coerce_action_object(obj: Any) -> Optional[Dict[str, Any]]: + if not isinstance(obj, dict): + return None + name = obj.get("name") + args = obj.get("args", {}) + if isinstance(name, str): + if not isinstance(args, dict): + args = {} + return {"name": name, "args": args} + actions = extract_json_actions(obj) + if actions: + return actions[0] + return None + + +def parse_labeled_action_input(text: str) -> Optional[Dict[str, Any]]: + blocks = extract_labeled_blocks(text) + action_text = first_block_value(blocks, ("action", "tool", "call")) + if not action_text: + return None + + parsed = parse_first_action_invocation(f"Action: {action_text}") + if parsed is not None: + return parsed + + nested = parse_action_any(action_text) + if nested is not None: + return nested + + if not _BARE_ACTION_NAME.fullmatch(action_text): + return None + + action_input = first_block_value(blocks, _ACTION_INPUT_KEYS) + return {"name": action_text, "args": parse_action_input_args(action_input)} + + +def parse_action_input_args(action_input: Optional[str]) -> Dict[str, Any]: + if not action_input: + return {} + payload, _warnings = parse_jsonish_object(action_input) + if not isinstance(payload, dict): + return {} + if len(payload) == 1: + only_value = next(iter(payload.values())) + if isinstance(only_value, dict) and norm(next(iter(payload.keys()))) in { + "args", + "arguments", + }: + return only_value + return payload + + def parse_object_like(text: str) -> Optional[Any]: try: return json.loads(text) @@ -266,6 +326,8 @@ def parse_xml_action( parsed = parse_action_any(body) if parsed is not None: return parsed + if _BARE_ACTION_NAME.fullmatch(body): + return {"name": body, "args": {}} return None diff --git a/qitos/kit/parser/react_parser.py b/qitos/kit/parser/react_parser.py index d0f39dc..d2b8e3b 100644 --- a/qitos/kit/parser/react_parser.py +++ b/qitos/kit/parser/react_parser.py @@ -9,8 +9,11 @@ from qitos.kit.parser.parser_utils import ( extract_labeled_blocks, first_block_value, + first_xml_text, norm, parse_action_any, + parse_xml_action, + parse_xml_root, ) @@ -75,7 +78,31 @@ def parse( if final_answer: return Decision.final(answer=final_answer, rationale=thought, meta=meta) - action = parse_action_any(action_blob or text) + if "<" in text and ">" in text: + try: + root = parse_xml_root(text) + except Exception: + root = None + if root is not None: + xml_thought = thought or first_xml_text(root, self.thought_keys) + xml_reflection = reflection or first_xml_text( + root, self.reflection_keys + ) + xml_meta = {"reflection": xml_reflection} if xml_reflection else {} + xml_final = first_xml_text(root, self.final_keys) + if xml_final: + return Decision.final( + answer=xml_final, rationale=xml_thought, meta=xml_meta + ) + xml_action = parse_xml_action(root, self.action_keys) + if xml_action is not None: + return Decision.act( + actions=[xml_action], rationale=xml_thought, meta=xml_meta + ) + + action = parse_action_any(text) + if action is None and action_blob: + action = parse_action_any(action_blob) if action is not None: return Decision.act(actions=[action], rationale=thought, meta=meta) return parser_wait_decision( diff --git a/tests/test_memory_and_parser_and_critic.py b/tests/test_memory_and_parser_and_critic.py index a384db0..eb182ab 100644 --- a/tests/test_memory_and_parser_and_critic.py +++ b/tests/test_memory_and_parser_and_critic.py @@ -103,6 +103,45 @@ def test_json_decision_parser_salvages_wrapped_json_like_blocks(): assert decision.meta["parser_diagnostics"]["salvage_applied"] is True +def test_react_parser_accepts_common_action_variants_from_issue_18(): + parser = ReActTextParser() + + langchain = parser.parse( + 'Thought: I need to inspect the file.\n' + 'Action: read_file\n' + 'Action Input: {"filename": "buggy_module.py"}' + ) + assert langchain.mode == "act" + assert langchain.actions[0] == { + "name": "read_file", + "args": {"filename": "buggy_module.py"}, + } + assert langchain.rationale == "I need to inspect the file." + + xml = parser.parse( + "\n" + "Let me first read buggy_module.py to understand the bug.\n" + "\n\n" + "\n" + "list_files\n" + "" + ) + assert xml.mode == "act" + assert xml.actions[0] == {"name": "list_files", "args": {}} + assert xml.rationale == "Let me first read buggy_module.py to understand the bug." + + fenced_json = parser.parse( + '```json\n' + '{"name": "read_file", "args": {"filename": "buggy_module.py"}}\n' + '```' + ) + assert fenced_json.mode == "act" + assert fenced_json.actions[0] == { + "name": "read_file", + "args": {"filename": "buggy_module.py"}, + } + + def test_func_parser_handles_nested_and_truncated_calls(): s = "a=1, payload={'x':[1,2,3], 'y':'k,v'}, html='
(x)
', flag=true" parts = split_args_robust(s) diff --git a/tests/test_tool_registry_and_toolset.py b/tests/test_tool_registry_and_toolset.py index 0a908dc..72a1610 100644 --- a/tests/test_tool_registry_and_toolset.py +++ b/tests/test_tool_registry_and_toolset.py @@ -4,6 +4,7 @@ import pytest from qitos import Action, AgentModule, Decision, Engine, StateSchema, ToolRegistry, tool +from qitos.core import function_tool from qitos.engine import RuntimeBudget from qitos.kit import tool as tool_pkg from qitos.kit.tool import ( @@ -139,6 +140,58 @@ def test_curated_toolsets_register_cleanly(tmp_path): ), f"{toolset.__class__.__name__} registered no tools" +def test_coding_toolset_openai_specs_do_not_emit_invalid_any_schema(tmp_path): + registry = ToolRegistry() + registry.include(CodingToolSet(workspace_root=str(tmp_path), auto_approve=True)) + + offenders: dict[str, list[str]] = {} + kwargs_tools: dict[str, dict[str, Any]] = {} + for spec in registry.get_all_specs(): + function = spec["function"] + parameters = function.get("parameters") or {} + properties = parameters.get("properties", {}) + any_params = [ + name + for name, schema in properties.items() + if isinstance(schema, dict) and schema.get("type") == "any" + ] + if any_params: + offenders[function["name"]] = any_params + if function["name"] in { + "agent_spawn", + "cron_create", + "cron_delete", + "lsp_query", + }: + kwargs_tools[function["name"]] = parameters + + assert offenders == {} + assert set(kwargs_tools) == { + "agent_spawn", + "cron_create", + "cron_delete", + "lsp_query", + } + for parameters in kwargs_tools.values(): + assert "kwargs" not in parameters.get("properties", {}) + assert "kwargs" not in parameters.get("required", []) + + +def test_function_tool_schema_uses_valid_json_schema_for_any_and_kwargs(): + @function_tool(name="accept_any") + def accept_any(payload: Any, **kwargs: Any) -> dict[str, Any]: + return {"payload": payload, "extra": kwargs} + + spec = accept_any.spec.input_schema + properties = spec["properties"] + + assert "payload" in properties + assert properties["payload"].get("type") != "any" + assert "payload" in spec["required"] + assert "kwargs" not in properties + assert "kwargs" not in spec["required"] + + def test_tool_package_does_not_export_uncurated_cyber_toolsets(): exported = set(getattr(tool_pkg, "__all__", [])) assert "ReportToolSet" in exported From bafa7013044be7ae62c034d4f31d14efcee0f928 Mon Sep 17 00:00:00 2001 From: jcy <34598069+jiangchangyue@users.noreply.github.com> Date: Fri, 26 Jun 2026 14:31:13 +0800 Subject: [PATCH 2/2] fixes issues #21 --- CHANGELOG.md | 1 + README.md | 1 + README.zh.md | 1 + docs/tutorials/mcp-integration.mdx | 4 + docs/zh/tutorials/mcp-integration.mdx | 3 + qitos/mcp/http.py | 55 ++++++++++++- tests/mcp/test_mcp.py | 109 ++++++++++++++++++++++++++ 7 files changed, 173 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1986db2..b677edf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ How to update: ### Fixed +- Fixed `MCPServerStreamableHttp` so Docker MCP Gateway style streaming endpoints that redirect `/mcp/` to `/mcp` and return JSON-RPC inside SSE `data:` frames can complete the HTTP handshake. - Fixed OpenAI-compatible tool schema export so `Any` and `**kwargs` no longer produce invalid `{"type": "any"}` entries in native `tools=` payloads. - Fixed `ReActTextParser` so common ReAct variants such as `Action Input` blocks, XML-style action tags, and fenced JSON tool calls no longer fall into parser repair loops. diff --git a/README.md b/README.md index f74c2ca..097a89e 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ QitOS core is the small framework. Product-grade applications and showcase agent ## Latest Updates +- **MCP Streamable HTTP compatibility**: `MCPServerStreamableHttp` now follows gateway redirects and parses JSON-RPC responses wrapped in SSE `data:` frames. - **Native tool schema hardening**: OpenAI-compatible `tools=` payloads no longer export invalid `type: any` schemas for `Any` or `**kwargs` parameters. - **ReAct parser compatibility**: `ReActTextParser` now accepts common `Action Input`, XML-style action tags, and fenced JSON tool-call variants that some OpenAI-compatible models emit. diff --git a/README.zh.md b/README.zh.md index 6cb09cc..a7f1757 100644 --- a/README.zh.md +++ b/README.zh.md @@ -18,6 +18,7 @@ QitOS 主仓库是小而清晰的核心框架。产品级 / 展示级应用会 ## 最新进展 +- **MCP Streamable HTTP 兼容性**:`MCPServerStreamableHttp` 现在会跟随 gateway 重定向,并解析 SSE `data:` 帧中封装的 JSON-RPC 响应。 - **原生工具 schema 加固**:OpenAI-compatible `tools=` payload 不再为 `Any` 或 `**kwargs` 参数导出非法的 `type: any` schema。 - **ReAct 解析兼容性**:`ReActTextParser` 现在可以解析部分 OpenAI-compatible 模型常见的 `Action Input`、XML 风格 action 标签和围栏 JSON tool-call 变体。 diff --git a/docs/tutorials/mcp-integration.mdx b/docs/tutorials/mcp-integration.mdx index 940b277..250d79e 100644 --- a/docs/tutorials/mcp-integration.mdx +++ b/docs/tutorials/mcp-integration.mdx @@ -31,6 +31,10 @@ server = MCPServerStreamableHttp( await server.connect() ``` +The HTTP transport follows redirects from gateways such as Docker MCP Gateway +and accepts JSON-RPC responses returned either as plain JSON or as Server-Sent +Events `data:` frames. + Both transports perform the MCP initialization handshake automatically during `connect()`. After connecting, you can list available tools: ```python diff --git a/docs/zh/tutorials/mcp-integration.mdx b/docs/zh/tutorials/mcp-integration.mdx index 970b039..54e4087 100644 --- a/docs/zh/tutorials/mcp-integration.mdx +++ b/docs/zh/tutorials/mcp-integration.mdx @@ -31,6 +31,9 @@ server = MCPServerStreamableHttp( await server.connect() ``` +HTTP 传输会跟随 Docker MCP Gateway 等网关返回的重定向,并支持普通 +JSON 响应以及封装在 Server-Sent Events `data:` 帧中的 JSON-RPC 响应。 + 两种传输都会在 `connect()` 期间自动完成 MCP 初始化握手。连接成功后,可以列出可用工具: ```python diff --git a/qitos/mcp/http.py b/qitos/mcp/http.py index 13f15a6..0c29990 100644 --- a/qitos/mcp/http.py +++ b/qitos/mcp/http.py @@ -70,6 +70,7 @@ async def connect(self) -> None: base_url=self._url, headers={**self._headers, "Content-Type": "application/json"}, timeout=httpx.Timeout(30.0), + follow_redirects=True, ) # MCP initialization handshake @@ -149,7 +150,7 @@ async def _send_request(self, method: str, params: Dict[str, Any]) -> Dict[str, response = await self._client.post("", json=payload) response.raise_for_status() - data = response.json() + data = self._decode_response(response) logger.debug("MCP HTTP <- %s", json.dumps(data)[:500]) if "error" in data: @@ -160,6 +161,58 @@ async def _send_request(self, method: str, params: Dict[str, Any]) -> Dict[str, return data.get("result", {}) + def _decode_response(self, response: Any) -> Dict[str, Any]: + content_type = str(response.headers.get("Content-Type", "")).lower() + if "text/event-stream" in content_type: + return self._decode_sse_response(response.text) + return response.json() + + def _decode_sse_response(self, text: str) -> Dict[str, Any]: + data_lines: List[str] = [] + last_error: Optional[json.JSONDecodeError] = None + + for raw_line in text.splitlines(): + line = raw_line.rstrip("\r") + if not line: + if data_lines: + parsed = self._parse_sse_data(data_lines) + if parsed is not None: + return parsed + try: + json.loads("\n".join(data_lines)) + except json.JSONDecodeError as exc: + last_error = exc + data_lines = [] + continue + if line.startswith(":"): + continue + if line.startswith("data:"): + data_lines.append(line[5:].lstrip()) + + if data_lines: + parsed = self._parse_sse_data(data_lines) + if parsed is not None: + return parsed + try: + json.loads("\n".join(data_lines)) + except json.JSONDecodeError as exc: + last_error = exc + + if last_error is not None: + raise ValueError( + "MCP HTTP SSE data frame did not contain valid JSON" + ) from last_error + raise ValueError("MCP HTTP SSE response did not include a data frame") + + def _parse_sse_data(self, data_lines: List[str]) -> Optional[Dict[str, Any]]: + try: + data = json.loads("\n".join(data_lines)) + except json.JSONDecodeError: + return None + if not isinstance(data, dict): + raise ValueError("MCP HTTP SSE data frame did not contain a JSON object") + return data + async def _send_notification(self, method: str, params: Optional[Dict[str, Any]] = None) -> None: """Send a JSON-RPC notification via HTTP POST.""" if self._client is None: diff --git a/tests/mcp/test_mcp.py b/tests/mcp/test_mcp.py index 0ca1449..f7598db 100644 --- a/tests/mcp/test_mcp.py +++ b/tests/mcp/test_mcp.py @@ -22,6 +22,7 @@ import pytest from qitos.core.tool import FunctionTool, ToolSpec +import qitos.mcp.http as http_transport from qitos.mcp import ( MCPServer, MCPToolInfo, @@ -603,6 +604,114 @@ async def test_operations_without_connect_raise(self) -> None: class TestMCPServerStreamableHttp: + def _patch_http_client(self, monkeypatch, handler): + httpx = http_transport.httpx + if httpx is None: + pytest.skip("httpx not installed") + + transport = httpx.MockTransport(handler) + real_async_client = httpx.AsyncClient + + def make_client(*args, **kwargs): + kwargs["transport"] = transport + return real_async_client(*args, **kwargs) + + monkeypatch.setattr(httpx, "AsyncClient", make_client) + return httpx + + def _sse_response(self, httpx, request_id: int, result: Dict[str, Any]): + payload = {"jsonrpc": "2.0", "id": request_id, "result": result} + body = ( + "event: message\n" + f"data: {json.dumps(payload)}\n\n" + ) + return httpx.Response( + 200, + headers={"Content-Type": "text/event-stream"}, + text=body, + ) + + def test_streamable_http_parses_sse_json_rpc_responses(self, monkeypatch) -> None: + def handler(request): + payload = json.loads(request.content or b"{}") + request_id = payload.get("id") + if request_id is None: + return httpx.Response(202) + if payload.get("method") == "initialize": + result = { + "protocolVersion": "2024-11-05", + "capabilities": {"tools": {}}, + "serverInfo": {"name": "sse-repro", "version": "0.0.1"}, + } + elif payload.get("method") == "tools/list": + result = { + "tools": [ + { + "name": "nmap_scan", + "description": "Run nmap", + "inputSchema": {"type": "object"}, + } + ] + } + else: + result = {} + return self._sse_response(httpx, request_id, result) + + httpx = self._patch_http_client(monkeypatch, handler) + server = MCPServerStreamableHttp(url="http://localhost:8080/mcp") + + async def exercise(): + try: + await server.connect() + return await server.list_tools() + finally: + await server.cleanup() + + tools = asyncio.run(exercise()) + assert [tool.name for tool in tools] == ["nmap_scan"] + + def test_streamable_http_follows_redirect_before_sse_response( + self, monkeypatch + ) -> None: + paths: List[str] = [] + + def handler(request): + paths.append(request.url.path) + if request.url.path == "/mcp/": + return httpx.Response(307, headers={"Location": "/mcp"}) + payload = json.loads(request.content or b"{}") + request_id = payload.get("id") + if request_id is None: + return httpx.Response(202) + if payload.get("method") == "initialize": + result = { + "protocolVersion": "2024-11-05", + "capabilities": {"tools": {}}, + "serverInfo": {"name": "redirect-repro", "version": "0.0.1"}, + } + elif payload.get("method") == "tools/list": + result = {"tools": []} + else: + result = {} + return self._sse_response(httpx, request_id, result) + + httpx = self._patch_http_client(monkeypatch, handler) + server = MCPServerStreamableHttp( + url="http://localhost:8080/mcp", + headers={"Authorization": "Bearer reprotoken123"}, + ) + + async def exercise(): + try: + await server.connect() + return await server.list_tools() + finally: + await server.cleanup() + + assert asyncio.run(exercise()) == [] + assert "/mcp/" in paths + assert "/mcp" in paths + def test_construction(self) -> None: try: server = MCPServerStreamableHttp(