Skip to content

Commit c3d6b80

Browse files
authored
Merge pull request #111 from 117503445/fix/mcp-remote-proxy-streamable-endpoint
feat(tool): 推断 MCP session_affinity 以支持 MCP_REMOTE proxy 模式
2 parents 1d2736f + 2656ee2 commit c3d6b80

3 files changed

Lines changed: 163 additions & 6 deletions

File tree

agentrun/tool/__tool_async_template.py

Lines changed: 46 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,40 @@ def _parse_protocol_spec_mcp_url(self) -> Tuple[str, str, Dict[str, str]]:
261261

262262
return url, session_affinity, spec_headers
263263

264+
def _infer_protocol_spec_mcp_session_affinity(self) -> Optional[str]:
265+
"""从 protocol_spec 推断 MCP session_affinity / Infer MCP session_affinity from protocol_spec
266+
267+
用于 MCP_REMOTE + proxy_enabled=true 且 mcp_config.session_affinity
268+
为空的场景。proxy 模式仍使用数据面 URL,不使用 protocol_spec 中的
269+
上游 URL 和 headers。
270+
Used when MCP_REMOTE proxy is enabled but mcp_config.session_affinity
271+
is empty. Proxy mode still uses data endpoint URL, not upstream URL
272+
or headers from protocol_spec.
273+
274+
Returns:
275+
Optional[str]: MCP_STREAMABLE、MCP_SSE 或 None
276+
"""
277+
if not self.protocol_spec:
278+
return None
279+
280+
try:
281+
spec = json.loads(self.protocol_spec)
282+
except (json.JSONDecodeError, TypeError):
283+
return None
284+
285+
mcp_servers = spec.get("mcpServers")
286+
if not mcp_servers or not isinstance(mcp_servers, dict):
287+
return None
288+
289+
first_server = next(iter(mcp_servers.values()), None)
290+
if not first_server or not isinstance(first_server, dict):
291+
return None
292+
293+
transport_type = first_server.get("transportType", "sse")
294+
if transport_type == "streamable-http":
295+
return "MCP_STREAMABLE"
296+
return "MCP_SSE"
297+
264298
def _get_mcp_endpoint(
265299
self, config: Optional[Config] = None
266300
) -> Optional[Tuple[str, str, Dict[str, str]]]:
@@ -290,9 +324,18 @@ def _get_mcp_endpoint(
290324
if not data_endpoint or not effective_name:
291325
return None
292326

293-
session_affinity = pydash.get(
294-
self, "mcp_config.session_affinity", "MCP_SSE"
295-
)
327+
session_affinity = pydash.get(self, "mcp_config.session_affinity")
328+
if not session_affinity:
329+
is_mcp_remote_with_proxy = (
330+
self.create_method == "MCP_REMOTE"
331+
and pydash.get(self, "mcp_config.proxy_enabled", False)
332+
)
333+
if is_mcp_remote_with_proxy:
334+
session_affinity = (
335+
self._infer_protocol_spec_mcp_session_affinity()
336+
)
337+
if not session_affinity:
338+
session_affinity = "MCP_SSE"
296339

297340
if session_affinity == "MCP_STREAMABLE":
298341
return (

agentrun/tool/tool.py

Lines changed: 46 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -286,6 +286,40 @@ def _parse_protocol_spec_mcp_url(self) -> Tuple[str, str, Dict[str, str]]:
286286

287287
return url, session_affinity, spec_headers
288288

289+
def _infer_protocol_spec_mcp_session_affinity(self) -> Optional[str]:
290+
"""从 protocol_spec 推断 MCP session_affinity / Infer MCP session_affinity from protocol_spec
291+
292+
用于 MCP_REMOTE + proxy_enabled=true 且 mcp_config.session_affinity
293+
为空的场景。proxy 模式仍使用数据面 URL,不使用 protocol_spec 中的
294+
上游 URL 和 headers。
295+
Used when MCP_REMOTE proxy is enabled but mcp_config.session_affinity
296+
is empty. Proxy mode still uses data endpoint URL, not upstream URL
297+
or headers from protocol_spec.
298+
299+
Returns:
300+
Optional[str]: MCP_STREAMABLE、MCP_SSE 或 None
301+
"""
302+
if not self.protocol_spec:
303+
return None
304+
305+
try:
306+
spec = json.loads(self.protocol_spec)
307+
except (json.JSONDecodeError, TypeError):
308+
return None
309+
310+
mcp_servers = spec.get("mcpServers")
311+
if not mcp_servers or not isinstance(mcp_servers, dict):
312+
return None
313+
314+
first_server = next(iter(mcp_servers.values()), None)
315+
if not first_server or not isinstance(first_server, dict):
316+
return None
317+
318+
transport_type = first_server.get("transportType", "sse")
319+
if transport_type == "streamable-http":
320+
return "MCP_STREAMABLE"
321+
return "MCP_SSE"
322+
289323
def _get_mcp_endpoint(
290324
self, config: Optional[Config] = None
291325
) -> Optional[Tuple[str, str, Dict[str, str]]]:
@@ -315,9 +349,18 @@ def _get_mcp_endpoint(
315349
if not data_endpoint or not effective_name:
316350
return None
317351

318-
session_affinity = pydash.get(
319-
self, "mcp_config.session_affinity", "MCP_SSE"
320-
)
352+
session_affinity = pydash.get(self, "mcp_config.session_affinity")
353+
if not session_affinity:
354+
is_mcp_remote_with_proxy = (
355+
self.create_method == "MCP_REMOTE"
356+
and pydash.get(self, "mcp_config.proxy_enabled", False)
357+
)
358+
if is_mcp_remote_with_proxy:
359+
session_affinity = (
360+
self._infer_protocol_spec_mcp_session_affinity()
361+
)
362+
if not session_affinity:
363+
session_affinity = "MCP_SSE"
321364

322365
if session_affinity == "MCP_STREAMABLE":
323366
return (

tests/unittests/tool/test_tool.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1161,6 +1161,77 @@ def test_get_mcp_endpoint_mcp_remote_with_proxy_uses_data_endpoint(self):
11611161
{},
11621162
)
11631163

1164+
@pytest.mark.parametrize(
1165+
"protocol_spec",
1166+
[
1167+
None,
1168+
"invalid json",
1169+
"{}",
1170+
'{"mcpServers":{}}',
1171+
'{"mcpServers":[]}',
1172+
'{"mcpServers":{"s1":null}}',
1173+
'{"mcpServers":{"s1":[]}}',
1174+
],
1175+
)
1176+
def test_infer_protocol_spec_mcp_session_affinity_invalid_spec(
1177+
self,
1178+
protocol_spec,
1179+
):
1180+
"""测试无效 protocol_spec 无法推断 session_affinity。"""
1181+
tool = Tool(tool_name="my-tool", protocol_spec=protocol_spec)
1182+
assert tool._infer_protocol_spec_mcp_session_affinity() is None
1183+
1184+
@pytest.mark.parametrize(
1185+
"protocol_spec",
1186+
[
1187+
'{"mcpServers":{"s1":{"url":"https://external-mcp.com/sse"}}}',
1188+
'{"mcpServers":{"s1":{"transportType":"sse","url":"https://external-mcp.com/sse"}}}',
1189+
],
1190+
)
1191+
def test_infer_protocol_spec_mcp_session_affinity_sse(
1192+
self,
1193+
protocol_spec,
1194+
):
1195+
"""测试 protocol_spec 缺省或显式 sse 时推断 MCP_SSE。"""
1196+
tool = Tool(tool_name="my-tool", protocol_spec=protocol_spec)
1197+
assert tool._infer_protocol_spec_mcp_session_affinity() == "MCP_SSE"
1198+
1199+
def test_get_mcp_endpoint_mcp_remote_with_proxy_infers_streamable(self):
1200+
"""测试 MCP_REMOTE proxy 模式按 protocol_spec 推断 streamable。"""
1201+
tool = Tool(
1202+
tool_name="my-tool",
1203+
tool_type="MCP",
1204+
create_method="MCP_REMOTE",
1205+
data_endpoint="https://example.com",
1206+
mcp_config=McpConfig(proxy_enabled=True),
1207+
protocol_spec='{"mcpServers":{"s1":{"transportType":"streamable-http","url":"https://external-mcp.com/mcp"}}}',
1208+
)
1209+
result = tool._get_mcp_endpoint()
1210+
assert result == (
1211+
"https://example.com/tools/my-tool/mcp",
1212+
"MCP_STREAMABLE",
1213+
{},
1214+
)
1215+
1216+
def test_get_mcp_endpoint_mcp_remote_with_proxy_empty_affinity_infers_streamable(
1217+
self,
1218+
):
1219+
"""测试空 session_affinity 也按 protocol_spec 推断 streamable。"""
1220+
tool = Tool(
1221+
tool_name="my-tool",
1222+
tool_type="MCP",
1223+
create_method="MCP_REMOTE",
1224+
data_endpoint="https://example.com",
1225+
mcp_config=McpConfig(session_affinity="", proxy_enabled=True),
1226+
protocol_spec='{"mcpServers":{"s1":{"transportType":"streamable-http","url":"https://external-mcp.com/mcp"}}}',
1227+
)
1228+
result = tool._get_mcp_endpoint()
1229+
assert result == (
1230+
"https://example.com/tools/my-tool/mcp",
1231+
"MCP_STREAMABLE",
1232+
{},
1233+
)
1234+
11641235
def test_get_mcp_endpoint_mcp_bundle_uses_data_endpoint(self):
11651236
"""测试 MCP_BUNDLE 类型使用 data_endpoint 拼接"""
11661237
tool = Tool(

0 commit comments

Comments
 (0)