From 3cd6a4b5b03933d4a6478ba3f0c54c054940a120 Mon Sep 17 00:00:00 2001 From: rakdutta Date: Fri, 7 Nov 2025 18:19:05 +0530 Subject: [PATCH 01/13] streamblehttp output_schema Signed-off-by: rakdutta --- .../transports/streamablehttp_transport.py | 27 +++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/mcpgateway/transports/streamablehttp_transport.py b/mcpgateway/transports/streamablehttp_transport.py index 599f20f03..bfcacc91c 100644 --- a/mcpgateway/transports/streamablehttp_transport.py +++ b/mcpgateway/transports/streamablehttp_transport.py @@ -389,12 +389,35 @@ async def call_tool(name: str, arguments: dict) -> List[Union[types.TextContent, logger.warning(f"No content returned by tool: {name}") return [] - return [types.TextContent(type=content.type, text=content.text) for content in result.content] + # Normalize unstructured content to MCP SDK types + unstructured = [types.TextContent(type=content.type, text=content.text) for content in result.content] + + # If the tool produced structured content (ToolResult.structured_content / structuredContent), + # return a combination (unstructured, structured) so the server can validate against outputSchema. + # The ToolService may populate structured_content (snake_case) or the model may expose + # an alias 'structuredContent' when dumped via model_dump(by_alias=True). + structured = None + try: + # Prefer attribute if present + structured = getattr(result, "structured_content", None) + except Exception: + structured = None + + # Fallback to by-alias dump (in case the result is a pydantic model with alias fields) + if structured is None: + try: + structured = result.model_dump(by_alias=True).get("structuredContent") if hasattr(result, "model_dump") else None + except Exception: + structured = None + + if structured: + return (unstructured, structured) + + return unstructured except Exception as e: logger.exception(f"Error calling tool '{name}': {e}") return [] - @mcp_app.list_tools() async def list_tools() -> List[types.Tool]: """ From dc547c0f7dd8f35f078e4824952e7470675cad4a Mon Sep 17 00:00:00 2001 From: rakdutta Date: Fri, 7 Nov 2025 19:31:01 +0530 Subject: [PATCH 02/13] primitive_types Signed-off-by: rakdutta --- mcpgateway/services/tool_service.py | 32 +++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/mcpgateway/services/tool_service.py b/mcpgateway/services/tool_service.py index dee42c271..242226e4e 100644 --- a/mcpgateway/services/tool_service.py +++ b/mcpgateway/services/tool_service.py @@ -529,6 +529,38 @@ def _extract_and_validate_structured_content(self, tool: DbTool, tool_result: "T else: structured = inner + # Transiently normalize common wrapper shapes so validation can proceed + # without mutating the stored output_schema. We handle two cases: + # 1) {'result': } -> unwrap to when schema expects a primitive + # 2) -> wrap to {'result': } when schema expects an object + try: + primitive_types = {"integer", "number", "string", "boolean", "null"} + + # Case 1: unwrap {'result': X} -> X when schema expects a primitive + if isinstance(structured, dict) and len(structured) == 1 and "result" in structured and schema_type in primitive_types: + structured = structured["result"] + logger.debug("Transiently unwrapped {'result': ..} to primitive for validation") + + # Case 2: wrap primitive -> {'result': primitive} when schema expects an object with a 'result' prop + elif (isinstance(structured, (str, int, float, bool)) or structured is None) and schema_type == "object" and isinstance(output_schema, dict): + prop = output_schema.get("properties", {}).get("result") + if isinstance(prop, dict): + prop_type = prop.get("type") + if prop_type in primitive_types: + compatible = ( + (prop_type == "integer" and isinstance(structured, int) and not isinstance(structured, bool)) + or (prop_type == "number" and isinstance(structured, (int, float)) and not isinstance(structured, bool)) + or (prop_type == "string" and isinstance(structured, str)) + or (prop_type == "boolean" and isinstance(structured, bool)) + or (prop_type == "null" and structured is None) + ) + if compatible: + structured = {"result": structured} + logger.debug("Transiently wrapped primitive into {'result': ..} to match object schema for validation") + except Exception: + # defensive: don't break validation flow + pass + # Attach structured content try: setattr(tool_result, "structured_content", structured) From caeef0ba62d920d4c569db01667a14c8cb5239f6 Mon Sep 17 00:00:00 2001 From: rakdutta Date: Mon, 10 Nov 2025 11:47:23 +0530 Subject: [PATCH 03/13] primitive Signed-off-by: rakdutta --- mcpgateway/services/tool_service.py | 1 + 1 file changed, 1 insertion(+) diff --git a/mcpgateway/services/tool_service.py b/mcpgateway/services/tool_service.py index 242226e4e..f8df8b285 100644 --- a/mcpgateway/services/tool_service.py +++ b/mcpgateway/services/tool_service.py @@ -1445,6 +1445,7 @@ async def connect_to_streamablehttp_server(server_url: str, headers: dict = head # If output schema is present, validate and attach structured content if getattr(tool, "output_schema", None): valid = self._extract_and_validate_structured_content(tool, tool_result, candidate=filtered_response) + logger.info(f"Structured content validation result: {valid}") success = bool(valid) else: tool_result = ToolResult(content=[TextContent(type="text", text="Invalid tool type")]) From 25992d1217ef64a7aaad34976f176d74556d8148 Mon Sep 17 00:00:00 2001 From: rakdutta Date: Mon, 10 Nov 2025 18:44:43 +0530 Subject: [PATCH 04/13] mcp-structure Signed-off-by: rakdutta --- mcpgateway/services/tool_service.py | 35 ++++--- .../test_tool_service_structured_content.py | 92 +++++++++++++++++++ 2 files changed, 116 insertions(+), 11 deletions(-) create mode 100644 tests/unit/test_tool_service_structured_content.py diff --git a/mcpgateway/services/tool_service.py b/mcpgateway/services/tool_service.py index f8df8b285..bf7a9aa4a 100644 --- a/mcpgateway/services/tool_service.py +++ b/mcpgateway/services/tool_service.py @@ -50,6 +50,10 @@ from mcpgateway.db import server_tool_association from mcpgateway.db import Tool as DbTool from mcpgateway.db import ToolMetric +from mcpgateway.models import Gateway as PydanticGateway +from mcpgateway.models import TextContent +from mcpgateway.models import Tool as PydanticTool +from mcpgateway.models import ToolResult from mcpgateway.observability import create_span from mcpgateway.plugins.framework import GlobalContext, HttpHeaderPayload, PluginError, PluginManager, PluginViolationError, ToolHookType, ToolPostInvokePayload, ToolPreInvokePayload from mcpgateway.plugins.framework.constants import GATEWAY_METADATA, TOOL_METADATA @@ -1272,15 +1276,14 @@ async def invoke_tool(self, db: Session, name: str, arguments: Dict[str, Any], r # Don't mark as successful for error responses - success remains False else: result = response.json() + logger.info(f" Rest API Tool response: {result}") filtered_response = extract_using_jq(result, tool.jsonpath_filter) tool_result = ToolResult(content=[TextContent(type="text", text=json.dumps(filtered_response, indent=2))]) success = True - # If output schema is present, validate and attach structured content if getattr(tool, "output_schema", None): valid = self._extract_and_validate_structured_content(tool, tool_result, candidate=filtered_response) success = bool(valid) - elif tool.integration_type == "MCP": transport = tool.request_type.lower() # gateway = db.execute(select(DbGateway).where(DbGateway.id == tool.gateway_id).where(DbGateway.enabled)).scalar_one_or_none() @@ -1437,16 +1440,26 @@ async def connect_to_streamablehttp_server(server_url: str, headers: dict = head tool_call_result = await connect_to_sse_server(tool_gateway.url, headers=headers) elif transport == "streamablehttp": tool_call_result = await connect_to_streamablehttp_server(tool_gateway.url, headers=headers) - content = tool_call_result.model_dump(by_alias=True).get("content", []) - + logger.info(f"Tool call result: {tool_call_result}") + logger.info(f"Tool call result content type : {type(tool_call_result)})") + dump = tool_call_result.model_dump(by_alias=True) + logger.info(f"Tool call result dump: {dump}") + content = dump.get("content", []) + # Accept both alias and pythonic names for structured content + structured = dump.get("structuredContent") or dump.get("structured_content") + logger.info(f"content: {content}, structuredContent: {structured}") + + # Use textual content for jq extraction, but keep structured payload available filtered_response = extract_using_jq(content, tool.jsonpath_filter) - tool_result = ToolResult(content=filtered_response) - success = True - # If output schema is present, validate and attach structured content - if getattr(tool, "output_schema", None): - valid = self._extract_and_validate_structured_content(tool, tool_result, candidate=filtered_response) - logger.info(f"Structured content validation result: {valid}") - success = bool(valid) + logger.info(f"filtered_response: {filtered_response}") + tool_result = ToolResult( + content=filtered_response, + structured_content=structured, + is_error=tool_call_result.isError, + meta=getattr(tool_call_result, 'meta', None) + ) + logger.info(f"Final tool_result: {tool_result}") + #tool_result=tool_call_result else: tool_result = ToolResult(content=[TextContent(type="text", text="Invalid tool type")]) diff --git a/tests/unit/test_tool_service_structured_content.py b/tests/unit/test_tool_service_structured_content.py new file mode 100644 index 000000000..ef6b00ee9 --- /dev/null +++ b/tests/unit/test_tool_service_structured_content.py @@ -0,0 +1,92 @@ +import json + +import pytest + +from mcpgateway.services.tool_service import ToolService +from mcpgateway.models import TextContent + + +class DummyToolResult: + def __init__(self, content=None): + self.content = content or [] + self.structured_content = None + self.is_error = False + + +def make_tool(output_schema): + return type("T", (object,), {"output_schema": output_schema, "name": "dummy"})() + + +def test_no_schema_returns_true_and_unchanged(): + service = ToolService() + tool = make_tool(None) + # content is a JSON text but no schema -> nothing to validate + tr = DummyToolResult(content=[{"type": "text", "text": json.dumps({"a": 1})}]) + ok = service._extract_and_validate_structured_content(tool, tr) + assert ok is True + assert tr.structured_content is None + assert tr.is_error is False + + +def test_valid_candidate_attaches_structured_content(): + service = ToolService() + tool = make_tool({"type": "object", "properties": {"foo": {"type": "string"}}, "required": ["foo"]}) + tr = DummyToolResult(content=[]) + ok = service._extract_and_validate_structured_content(tool, tr, candidate={"foo": "bar"}) + assert ok is True + assert tr.structured_content == {"foo": "bar"} + assert tr.is_error is False + + +def test_invalid_candidate_marks_error_and_emits_details(): + service = ToolService() + tool = make_tool({"type": "object", "properties": {"foo": {"type": "string"}}, "required": ["foo"]}) + tr = DummyToolResult(content=[]) + ok = service._extract_and_validate_structured_content(tool, tr, candidate={"foo": 123}) + assert ok is False + assert tr.is_error is True + # content should be replaced with a TextContent describing the error + assert isinstance(tr.content, list) and len(tr.content) == 1 + tc = tr.content[0] + # The function attempts to set a TextContent instance; if it's a model-like object, inspect text + text = tc.text if hasattr(tc, "text") else str(tc) + details = json.loads(text) + assert "received" in details + + +def test_parse_textcontent_json_and_validate_object_schema(): + service = ToolService() + tool = make_tool({"type": "object", "properties": {"foo": {"type": "string"}}, "required": ["foo"]}) + payload = {"foo": "baz"} + tr = DummyToolResult(content=[{"type": "text", "text": json.dumps(payload)}]) + ok = service._extract_and_validate_structured_content(tool, tr) + assert ok is True + assert tr.structured_content == payload + + +def test_unwrap_single_element_list_wrapper_with_textcontent_inner(): + service = ToolService() + # Schema expects an object + tool = make_tool({"type": "object", "properties": {"foo": {"type": "string"}}, "required": ["foo"]}) + inner = {"type": "text", "text": json.dumps({"foo": "inner"})} + wrapped_list = [inner] + # The first TextContent contains JSON encoding of the list with inner TextContent-like dict + tr = DummyToolResult(content=[{"type": "text", "text": json.dumps(wrapped_list)}]) + ok = service._extract_and_validate_structured_content(tool, tr) + assert ok is True + assert tr.structured_content == {"foo": "inner"} + + +def test_wrap_primitive_into_result_when_schema_expects_object(): + service = ToolService() + # Schema expects object with 'result' integer + tool = make_tool({ + "type": "object", + "properties": {"result": {"type": "integer"}}, + "required": ["result"], + }) + # Provide primitive JSON in the first text content + tr = DummyToolResult(content=[{"type": "text", "text": json.dumps(42)}]) + ok = service._extract_and_validate_structured_content(tool, tr) + assert ok is True + assert tr.structured_content == {"result": 42} From a4b004e1aa98d6f03d3d503462d6449f0218f0f4 Mon Sep 17 00:00:00 2001 From: rakdutta Date: Mon, 10 Nov 2025 19:02:14 +0530 Subject: [PATCH 05/13] output_schema Signed-off-by: rakdutta --- mcpgateway/services/tool_service.py | 41 +---------------------------- 1 file changed, 1 insertion(+), 40 deletions(-) diff --git a/mcpgateway/services/tool_service.py b/mcpgateway/services/tool_service.py index bf7a9aa4a..67d36a1fe 100644 --- a/mcpgateway/services/tool_service.py +++ b/mcpgateway/services/tool_service.py @@ -421,7 +421,6 @@ async def _record_tool_metric(self, db: Session, tool: DbTool, start_time: float ) db.add(metric) db.commit() - def _extract_and_validate_structured_content(self, tool: DbTool, tool_result: "ToolResult", candidate: Optional[Any] = None) -> bool: """ Extract structured content (if any) and validate it against ``tool.output_schema``. @@ -533,38 +532,6 @@ def _extract_and_validate_structured_content(self, tool: DbTool, tool_result: "T else: structured = inner - # Transiently normalize common wrapper shapes so validation can proceed - # without mutating the stored output_schema. We handle two cases: - # 1) {'result': } -> unwrap to when schema expects a primitive - # 2) -> wrap to {'result': } when schema expects an object - try: - primitive_types = {"integer", "number", "string", "boolean", "null"} - - # Case 1: unwrap {'result': X} -> X when schema expects a primitive - if isinstance(structured, dict) and len(structured) == 1 and "result" in structured and schema_type in primitive_types: - structured = structured["result"] - logger.debug("Transiently unwrapped {'result': ..} to primitive for validation") - - # Case 2: wrap primitive -> {'result': primitive} when schema expects an object with a 'result' prop - elif (isinstance(structured, (str, int, float, bool)) or structured is None) and schema_type == "object" and isinstance(output_schema, dict): - prop = output_schema.get("properties", {}).get("result") - if isinstance(prop, dict): - prop_type = prop.get("type") - if prop_type in primitive_types: - compatible = ( - (prop_type == "integer" and isinstance(structured, int) and not isinstance(structured, bool)) - or (prop_type == "number" and isinstance(structured, (int, float)) and not isinstance(structured, bool)) - or (prop_type == "string" and isinstance(structured, str)) - or (prop_type == "boolean" and isinstance(structured, bool)) - or (prop_type == "null" and structured is None) - ) - if compatible: - structured = {"result": structured} - logger.debug("Transiently wrapped primitive into {'result': ..} to match object schema for validation") - except Exception: - # defensive: don't break validation flow - pass - # Attach structured content try: setattr(tool_result, "structured_content", structured) @@ -594,6 +561,7 @@ def _extract_and_validate_structured_content(self, tool: DbTool, tool_result: "T logger.error(f"Error extracting/validating structured_content: {exc}") return False + async def register_tool( self, db: Session, @@ -1440,18 +1408,12 @@ async def connect_to_streamablehttp_server(server_url: str, headers: dict = head tool_call_result = await connect_to_sse_server(tool_gateway.url, headers=headers) elif transport == "streamablehttp": tool_call_result = await connect_to_streamablehttp_server(tool_gateway.url, headers=headers) - logger.info(f"Tool call result: {tool_call_result}") - logger.info(f"Tool call result content type : {type(tool_call_result)})") dump = tool_call_result.model_dump(by_alias=True) logger.info(f"Tool call result dump: {dump}") content = dump.get("content", []) # Accept both alias and pythonic names for structured content structured = dump.get("structuredContent") or dump.get("structured_content") - logger.info(f"content: {content}, structuredContent: {structured}") - - # Use textual content for jq extraction, but keep structured payload available filtered_response = extract_using_jq(content, tool.jsonpath_filter) - logger.info(f"filtered_response: {filtered_response}") tool_result = ToolResult( content=filtered_response, structured_content=structured, @@ -1459,7 +1421,6 @@ async def connect_to_streamablehttp_server(server_url: str, headers: dict = head meta=getattr(tool_call_result, 'meta', None) ) logger.info(f"Final tool_result: {tool_result}") - #tool_result=tool_call_result else: tool_result = ToolResult(content=[TextContent(type="text", text="Invalid tool type")]) From 8c228147a5817d76f284eb7e0ffdb420326b1bab Mon Sep 17 00:00:00 2001 From: rakdutta Date: Mon, 10 Nov 2025 19:22:45 +0530 Subject: [PATCH 06/13] flake Signed-off-by: rakdutta --- mcpgateway/common/models.py | 2 +- mcpgateway/services/tool_service.py | 12 +-- .../transports/streamablehttp_transport.py | 3 +- .../test_tool_service_structured_content.py | 92 ------------------- 4 files changed, 6 insertions(+), 103 deletions(-) delete mode 100644 tests/unit/test_tool_service_structured_content.py diff --git a/mcpgateway/common/models.py b/mcpgateway/common/models.py index f8704e917..5df71d3a8 100644 --- a/mcpgateway/common/models.py +++ b/mcpgateway/common/models.py @@ -672,7 +672,7 @@ class CallToolResult(BaseModelWithConfigDict): is_error: Optional[bool] = Field(default=False, alias="isError") structured_content: Optional[Dict[str, Any]] = Field(None, alias="structuredContent") meta: Optional[Dict[str, Any]] = Field(None, alias="_meta") - + # Legacy alias for backwards compatibility ToolResult = CallToolResult diff --git a/mcpgateway/services/tool_service.py b/mcpgateway/services/tool_service.py index 67d36a1fe..e7ac00fcb 100644 --- a/mcpgateway/services/tool_service.py +++ b/mcpgateway/services/tool_service.py @@ -421,6 +421,7 @@ async def _record_tool_metric(self, db: Session, tool: DbTool, start_time: float ) db.add(metric) db.commit() + def _extract_and_validate_structured_content(self, tool: DbTool, tool_result: "ToolResult", candidate: Optional[Any] = None) -> bool: """ Extract structured content (if any) and validate it against ``tool.output_schema``. @@ -561,7 +562,6 @@ def _extract_and_validate_structured_content(self, tool: DbTool, tool_result: "T logger.error(f"Error extracting/validating structured_content: {exc}") return False - async def register_tool( self, db: Session, @@ -1414,20 +1414,14 @@ async def connect_to_streamablehttp_server(server_url: str, headers: dict = head # Accept both alias and pythonic names for structured content structured = dump.get("structuredContent") or dump.get("structured_content") filtered_response = extract_using_jq(content, tool.jsonpath_filter) - tool_result = ToolResult( - content=filtered_response, - structured_content=structured, - is_error=tool_call_result.isError, - meta=getattr(tool_call_result, 'meta', None) - ) + tool_result = ToolResult(content=filtered_response, structured_content=structured, is_error=tool_call_result.isError, meta=getattr(tool_call_result, "meta", None)) logger.info(f"Final tool_result: {tool_result}") else: tool_result = ToolResult(content=[TextContent(type="text", text="Invalid tool type")]) # Plugin hook: tool post-invoke if self._plugin_manager: - post_result, _ = await self._plugin_manager.invoke_hook( - ToolHookType.TOOL_POST_INVOKE, + post_result, _ = await self._plugin_manager.tool_post_invoke( payload=ToolPostInvokePayload(name=name, result=tool_result.model_dump(by_alias=True)), global_context=global_context, local_contexts=context_table, diff --git a/mcpgateway/transports/streamablehttp_transport.py b/mcpgateway/transports/streamablehttp_transport.py index bfcacc91c..c113ec5b1 100644 --- a/mcpgateway/transports/streamablehttp_transport.py +++ b/mcpgateway/transports/streamablehttp_transport.py @@ -389,7 +389,7 @@ async def call_tool(name: str, arguments: dict) -> List[Union[types.TextContent, logger.warning(f"No content returned by tool: {name}") return [] - # Normalize unstructured content to MCP SDK types + # Normalize unstructured content to MCP SDK types unstructured = [types.TextContent(type=content.type, text=content.text) for content in result.content] # If the tool produced structured content (ToolResult.structured_content / structuredContent), @@ -418,6 +418,7 @@ async def call_tool(name: str, arguments: dict) -> List[Union[types.TextContent, logger.exception(f"Error calling tool '{name}': {e}") return [] + @mcp_app.list_tools() async def list_tools() -> List[types.Tool]: """ diff --git a/tests/unit/test_tool_service_structured_content.py b/tests/unit/test_tool_service_structured_content.py deleted file mode 100644 index ef6b00ee9..000000000 --- a/tests/unit/test_tool_service_structured_content.py +++ /dev/null @@ -1,92 +0,0 @@ -import json - -import pytest - -from mcpgateway.services.tool_service import ToolService -from mcpgateway.models import TextContent - - -class DummyToolResult: - def __init__(self, content=None): - self.content = content or [] - self.structured_content = None - self.is_error = False - - -def make_tool(output_schema): - return type("T", (object,), {"output_schema": output_schema, "name": "dummy"})() - - -def test_no_schema_returns_true_and_unchanged(): - service = ToolService() - tool = make_tool(None) - # content is a JSON text but no schema -> nothing to validate - tr = DummyToolResult(content=[{"type": "text", "text": json.dumps({"a": 1})}]) - ok = service._extract_and_validate_structured_content(tool, tr) - assert ok is True - assert tr.structured_content is None - assert tr.is_error is False - - -def test_valid_candidate_attaches_structured_content(): - service = ToolService() - tool = make_tool({"type": "object", "properties": {"foo": {"type": "string"}}, "required": ["foo"]}) - tr = DummyToolResult(content=[]) - ok = service._extract_and_validate_structured_content(tool, tr, candidate={"foo": "bar"}) - assert ok is True - assert tr.structured_content == {"foo": "bar"} - assert tr.is_error is False - - -def test_invalid_candidate_marks_error_and_emits_details(): - service = ToolService() - tool = make_tool({"type": "object", "properties": {"foo": {"type": "string"}}, "required": ["foo"]}) - tr = DummyToolResult(content=[]) - ok = service._extract_and_validate_structured_content(tool, tr, candidate={"foo": 123}) - assert ok is False - assert tr.is_error is True - # content should be replaced with a TextContent describing the error - assert isinstance(tr.content, list) and len(tr.content) == 1 - tc = tr.content[0] - # The function attempts to set a TextContent instance; if it's a model-like object, inspect text - text = tc.text if hasattr(tc, "text") else str(tc) - details = json.loads(text) - assert "received" in details - - -def test_parse_textcontent_json_and_validate_object_schema(): - service = ToolService() - tool = make_tool({"type": "object", "properties": {"foo": {"type": "string"}}, "required": ["foo"]}) - payload = {"foo": "baz"} - tr = DummyToolResult(content=[{"type": "text", "text": json.dumps(payload)}]) - ok = service._extract_and_validate_structured_content(tool, tr) - assert ok is True - assert tr.structured_content == payload - - -def test_unwrap_single_element_list_wrapper_with_textcontent_inner(): - service = ToolService() - # Schema expects an object - tool = make_tool({"type": "object", "properties": {"foo": {"type": "string"}}, "required": ["foo"]}) - inner = {"type": "text", "text": json.dumps({"foo": "inner"})} - wrapped_list = [inner] - # The first TextContent contains JSON encoding of the list with inner TextContent-like dict - tr = DummyToolResult(content=[{"type": "text", "text": json.dumps(wrapped_list)}]) - ok = service._extract_and_validate_structured_content(tool, tr) - assert ok is True - assert tr.structured_content == {"foo": "inner"} - - -def test_wrap_primitive_into_result_when_schema_expects_object(): - service = ToolService() - # Schema expects object with 'result' integer - tool = make_tool({ - "type": "object", - "properties": {"result": {"type": "integer"}}, - "required": ["result"], - }) - # Provide primitive JSON in the first text content - tr = DummyToolResult(content=[{"type": "text", "text": json.dumps(42)}]) - ok = service._extract_and_validate_structured_content(tool, tr) - assert ok is True - assert tr.structured_content == {"result": 42} From a4c7100a40c524e9c4e1e62c4cbf4790b96b50bb Mon Sep 17 00:00:00 2001 From: rakdutta Date: Mon, 10 Nov 2025 20:01:37 +0530 Subject: [PATCH 07/13] test Signed-off-by: rakdutta --- mcpgateway/services/tool_service.py | 8 +++++++- .../transports/test_streamablehttp_transport.py | 9 +++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/mcpgateway/services/tool_service.py b/mcpgateway/services/tool_service.py index e7ac00fcb..9a5976ac1 100644 --- a/mcpgateway/services/tool_service.py +++ b/mcpgateway/services/tool_service.py @@ -1414,12 +1414,17 @@ async def connect_to_streamablehttp_server(server_url: str, headers: dict = head # Accept both alias and pythonic names for structured content structured = dump.get("structuredContent") or dump.get("structured_content") filtered_response = extract_using_jq(content, tool.jsonpath_filter) - tool_result = ToolResult(content=filtered_response, structured_content=structured, is_error=tool_call_result.isError, meta=getattr(tool_call_result, "meta", None)) + + is_err = getattr(tool_call_result, "is_error", None) + if is_err is None: + is_err = getattr(tool_call_result, "isError", False) + tool_result = ToolResult(content=filtered_response, structured_content=structured, is_error=is_err, meta=getattr(tool_call_result, "meta", None)) logger.info(f"Final tool_result: {tool_result}") else: tool_result = ToolResult(content=[TextContent(type="text", text="Invalid tool type")]) # Plugin hook: tool post-invoke + logger.info(f"self._plugin_manager:{self._plugin_manager}") if self._plugin_manager: post_result, _ = await self._plugin_manager.tool_post_invoke( payload=ToolPostInvokePayload(name=name, result=tool_result.model_dump(by_alias=True)), @@ -1431,6 +1436,7 @@ async def connect_to_streamablehttp_server(server_url: str, headers: dict = head if post_result.modified_payload: # Reconstruct ToolResult from modified result modified_result = post_result.modified_payload.result + logger.info(f"Post-invoke modified result: {modified_result}") if isinstance(modified_result, dict) and "content" in modified_result: tool_result = ToolResult(content=modified_result["content"]) else: diff --git a/tests/unit/mcpgateway/transports/test_streamablehttp_transport.py b/tests/unit/mcpgateway/transports/test_streamablehttp_transport.py index 3b53c4919..7cceaf37e 100644 --- a/tests/unit/mcpgateway/transports/test_streamablehttp_transport.py +++ b/tests/unit/mcpgateway/transports/test_streamablehttp_transport.py @@ -165,6 +165,10 @@ async def test_call_tool_success(monkeypatch): mock_content.type = "text" mock_content.text = "hello" mock_result.content = [mock_content] + # Ensure no accidental 'structured_content' MagicMock attribute is present + mock_result.structured_content = None + # Prevent model_dump from returning a MagicMock with a 'structuredContent' key + mock_result.model_dump = lambda by_alias=True: {} monkeypatch.setattr("mcpgateway.transports.streamablehttp_transport.get_db", AsyncMock(return_value=AsyncMock(__aenter__=AsyncMock(return_value=mock_db), __aexit__=AsyncMock()))) monkeypatch.setattr(tool_service, "invoke_tool", AsyncMock(return_value=mock_result)) @@ -192,6 +196,11 @@ async def test_call_tool_success(monkeypatch): async def fake_get_db(): yield mock_db + # Ensure no accidental 'structured_content' MagicMock attribute is present + mock_result.structured_content = None + # Prevent model_dump from returning a MagicMock with a 'structuredContent' key + mock_result.model_dump = lambda by_alias=True: {} + monkeypatch.setattr("mcpgateway.transports.streamablehttp_transport.get_db", fake_get_db) monkeypatch.setattr(tool_service, "invoke_tool", AsyncMock(return_value=mock_result)) From c35bb48e4d549afe6f9fd8822b4ca06c13f0ea40 Mon Sep 17 00:00:00 2001 From: rakdutta Date: Mon, 10 Nov 2025 20:55:15 +0530 Subject: [PATCH 08/13] ruff Signed-off-by: rakdutta --- mcpgateway/common/models.py | 2 +- mcpgateway/services/tool_service.py | 6 +----- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/mcpgateway/common/models.py b/mcpgateway/common/models.py index 5df71d3a8..f8704e917 100644 --- a/mcpgateway/common/models.py +++ b/mcpgateway/common/models.py @@ -672,7 +672,7 @@ class CallToolResult(BaseModelWithConfigDict): is_error: Optional[bool] = Field(default=False, alias="isError") structured_content: Optional[Dict[str, Any]] = Field(None, alias="structuredContent") meta: Optional[Dict[str, Any]] = Field(None, alias="_meta") - + # Legacy alias for backwards compatibility ToolResult = CallToolResult diff --git a/mcpgateway/services/tool_service.py b/mcpgateway/services/tool_service.py index 9a5976ac1..55fad84da 100644 --- a/mcpgateway/services/tool_service.py +++ b/mcpgateway/services/tool_service.py @@ -50,10 +50,6 @@ from mcpgateway.db import server_tool_association from mcpgateway.db import Tool as DbTool from mcpgateway.db import ToolMetric -from mcpgateway.models import Gateway as PydanticGateway -from mcpgateway.models import TextContent -from mcpgateway.models import Tool as PydanticTool -from mcpgateway.models import ToolResult from mcpgateway.observability import create_span from mcpgateway.plugins.framework import GlobalContext, HttpHeaderPayload, PluginError, PluginManager, PluginViolationError, ToolHookType, ToolPostInvokePayload, ToolPreInvokePayload from mcpgateway.plugins.framework.constants import GATEWAY_METADATA, TOOL_METADATA @@ -1414,7 +1410,7 @@ async def connect_to_streamablehttp_server(server_url: str, headers: dict = head # Accept both alias and pythonic names for structured content structured = dump.get("structuredContent") or dump.get("structured_content") filtered_response = extract_using_jq(content, tool.jsonpath_filter) - + is_err = getattr(tool_call_result, "is_error", None) if is_err is None: is_err = getattr(tool_call_result, "isError", False) From dbb18cc611714cd1dffe93e23ab234a101f2b053 Mon Sep 17 00:00:00 2001 From: rakdutta Date: Mon, 10 Nov 2025 21:05:56 +0530 Subject: [PATCH 09/13] remove logging Signed-off-by: rakdutta --- mcpgateway/services/tool_service.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/mcpgateway/services/tool_service.py b/mcpgateway/services/tool_service.py index 55fad84da..9b2a6be37 100644 --- a/mcpgateway/services/tool_service.py +++ b/mcpgateway/services/tool_service.py @@ -1420,7 +1420,6 @@ async def connect_to_streamablehttp_server(server_url: str, headers: dict = head tool_result = ToolResult(content=[TextContent(type="text", text="Invalid tool type")]) # Plugin hook: tool post-invoke - logger.info(f"self._plugin_manager:{self._plugin_manager}") if self._plugin_manager: post_result, _ = await self._plugin_manager.tool_post_invoke( payload=ToolPostInvokePayload(name=name, result=tool_result.model_dump(by_alias=True)), @@ -1432,7 +1431,6 @@ async def connect_to_streamablehttp_server(server_url: str, headers: dict = head if post_result.modified_payload: # Reconstruct ToolResult from modified result modified_result = post_result.modified_payload.result - logger.info(f"Post-invoke modified result: {modified_result}") if isinstance(modified_result, dict) and "content" in modified_result: tool_result = ToolResult(content=modified_result["content"]) else: From 6b45a97416fe7da76a8b9a7776a9e484b7e17907 Mon Sep 17 00:00:00 2001 From: rakdutta Date: Mon, 10 Nov 2025 21:13:13 +0530 Subject: [PATCH 10/13] invoke_hook Signed-off-by: rakdutta --- mcpgateway/services/tool_service.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/mcpgateway/services/tool_service.py b/mcpgateway/services/tool_service.py index 9b2a6be37..77c982e9d 100644 --- a/mcpgateway/services/tool_service.py +++ b/mcpgateway/services/tool_service.py @@ -1421,7 +1421,8 @@ async def connect_to_streamablehttp_server(server_url: str, headers: dict = head # Plugin hook: tool post-invoke if self._plugin_manager: - post_result, _ = await self._plugin_manager.tool_post_invoke( + post_result, _ = await self._plugin_manager.invoke_hook( + ToolHookType.TOOL_POST_INVOKE, payload=ToolPostInvokePayload(name=name, result=tool_result.model_dump(by_alias=True)), global_context=global_context, local_contexts=context_table, From 6b217f4f72bd7adb4da3a7e2eb6d84650fd71fc6 Mon Sep 17 00:00:00 2001 From: rakdutta Date: Tue, 11 Nov 2025 11:28:51 +0530 Subject: [PATCH 11/13] plugging response Signed-off-by: rakdutta --- mcpgateway/services/tool_service.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mcpgateway/services/tool_service.py b/mcpgateway/services/tool_service.py index 77c982e9d..b6be8b7d0 100644 --- a/mcpgateway/services/tool_service.py +++ b/mcpgateway/services/tool_service.py @@ -1433,7 +1433,7 @@ async def connect_to_streamablehttp_server(server_url: str, headers: dict = head # Reconstruct ToolResult from modified result modified_result = post_result.modified_payload.result if isinstance(modified_result, dict) and "content" in modified_result: - tool_result = ToolResult(content=modified_result["content"]) + tool_result = ToolResult(content=modified_result["content"], structured_content=modified_result["structuredContent"] if "structuredContent" in modified_result else modified_result["structured_content"]) else: # If result is not in expected format, convert it to text content tool_result = ToolResult(content=[TextContent(type="text", text=str(modified_result))]) From 3ea179c4eeb26221c0342e66544afcbf5573de69 Mon Sep 17 00:00:00 2001 From: rakdutta Date: Tue, 11 Nov 2025 12:37:08 +0530 Subject: [PATCH 12/13] plugging Signed-off-by: rakdutta --- mcpgateway/services/tool_service.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/mcpgateway/services/tool_service.py b/mcpgateway/services/tool_service.py index b6be8b7d0..3ff38841c 100644 --- a/mcpgateway/services/tool_service.py +++ b/mcpgateway/services/tool_service.py @@ -1433,7 +1433,16 @@ async def connect_to_streamablehttp_server(server_url: str, headers: dict = head # Reconstruct ToolResult from modified result modified_result = post_result.modified_payload.result if isinstance(modified_result, dict) and "content" in modified_result: - tool_result = ToolResult(content=modified_result["content"], structured_content=modified_result["structuredContent"] if "structuredContent" in modified_result else modified_result["structured_content"]) + # Safely obtain structured content using .get() to avoid KeyError when + # plugins provide only the content without structured content fields. + structured = None + if isinstance(modified_result, dict): + structured = modified_result.get("structuredContent") if "structuredContent" in modified_result else modified_result.get("structured_content") + + tool_result = ToolResult( + content=modified_result["content"], + structured_content=structured + ) else: # If result is not in expected format, convert it to text content tool_result = ToolResult(content=[TextContent(type="text", text=str(modified_result))]) From e6deb8ef26294f54e73942d7f477913725208290 Mon Sep 17 00:00:00 2001 From: Mihai Criveti Date: Wed, 12 Nov 2025 08:40:14 +0000 Subject: [PATCH 13/13] refactor: improve structured content handling and code quality - Change verbose info logging to debug level for tool responses to reduce production log noise while maintaining debug capability - Remove redundant isinstance check in plugin response handling - Add comprehensive docstring explaining structured content return types and MCP SDK behavior in call_tool function - Add test case for structured content validation with tuple returns - Improve code maintainability and documentation clarity These changes enhance code quality without altering functionality, improving pylint score from 9.64 to 9.77. Signed-off-by: Mihai Criveti --- mcpgateway/services/tool_service.py | 17 +++---- .../transports/streamablehttp_transport.py | 15 +++++- .../test_streamablehttp_transport.py | 48 +++++++++++++++++++ 3 files changed, 68 insertions(+), 12 deletions(-) diff --git a/mcpgateway/services/tool_service.py b/mcpgateway/services/tool_service.py index 3ff38841c..494678936 100644 --- a/mcpgateway/services/tool_service.py +++ b/mcpgateway/services/tool_service.py @@ -1240,7 +1240,7 @@ async def invoke_tool(self, db: Session, name: str, arguments: Dict[str, Any], r # Don't mark as successful for error responses - success remains False else: result = response.json() - logger.info(f" Rest API Tool response: {result}") + logger.debug(f"REST API tool response: {result}") filtered_response = extract_using_jq(result, tool.jsonpath_filter) tool_result = ToolResult(content=[TextContent(type="text", text=json.dumps(filtered_response, indent=2))]) success = True @@ -1405,7 +1405,7 @@ async def connect_to_streamablehttp_server(server_url: str, headers: dict = head elif transport == "streamablehttp": tool_call_result = await connect_to_streamablehttp_server(tool_gateway.url, headers=headers) dump = tool_call_result.model_dump(by_alias=True) - logger.info(f"Tool call result dump: {dump}") + logger.debug(f"Tool call result dump: {dump}") content = dump.get("content", []) # Accept both alias and pythonic names for structured content structured = dump.get("structuredContent") or dump.get("structured_content") @@ -1415,7 +1415,7 @@ async def connect_to_streamablehttp_server(server_url: str, headers: dict = head if is_err is None: is_err = getattr(tool_call_result, "isError", False) tool_result = ToolResult(content=filtered_response, structured_content=structured, is_error=is_err, meta=getattr(tool_call_result, "meta", None)) - logger.info(f"Final tool_result: {tool_result}") + logger.debug(f"Final tool_result: {tool_result}") else: tool_result = ToolResult(content=[TextContent(type="text", text="Invalid tool type")]) @@ -1435,14 +1435,9 @@ async def connect_to_streamablehttp_server(server_url: str, headers: dict = head if isinstance(modified_result, dict) and "content" in modified_result: # Safely obtain structured content using .get() to avoid KeyError when # plugins provide only the content without structured content fields. - structured = None - if isinstance(modified_result, dict): - structured = modified_result.get("structuredContent") if "structuredContent" in modified_result else modified_result.get("structured_content") - - tool_result = ToolResult( - content=modified_result["content"], - structured_content=structured - ) + structured = modified_result.get("structuredContent") if "structuredContent" in modified_result else modified_result.get("structured_content") + + tool_result = ToolResult(content=modified_result["content"], structured_content=structured) else: # If result is not in expected format, convert it to text content tool_result = ToolResult(content=[TextContent(type="text", text=str(modified_result))]) diff --git a/mcpgateway/transports/streamablehttp_transport.py b/mcpgateway/transports/streamablehttp_transport.py index c113ec5b1..596eea0bc 100644 --- a/mcpgateway/transports/streamablehttp_transport.py +++ b/mcpgateway/transports/streamablehttp_transport.py @@ -359,12 +359,25 @@ async def call_tool(name: str, arguments: dict) -> List[Union[types.TextContent, """ Handles tool invocation via the MCP Server. + This function supports the MCP protocol's tool calling with structured content validation. + It can return either unstructured content only, or both unstructured and structured content + when the tool defines an outputSchema. + Args: name (str): The name of the tool to invoke. arguments (dict): A dictionary of arguments to pass to the tool. Returns: - List of content (TextContent, ImageContent, or EmbeddedResource) from the tool response. + Union[List[ContentBlock], Tuple[List[ContentBlock], Dict[str, Any]]]: + - If structured content is not present: Returns a list of content blocks + (TextContent, ImageContent, or EmbeddedResource) + - If structured content is present: Returns a tuple of (unstructured_content, structured_content) + where structured_content is a dictionary that will be validated against the tool's outputSchema + + The MCP SDK's call_tool decorator automatically handles both return types: + - List return → CallToolResult with content only + - Tuple return → CallToolResult with both content and structuredContent fields + Logs and returns an empty list on failure. Examples: diff --git a/tests/unit/mcpgateway/transports/test_streamablehttp_transport.py b/tests/unit/mcpgateway/transports/test_streamablehttp_transport.py index 7cceaf37e..7c61799fe 100644 --- a/tests/unit/mcpgateway/transports/test_streamablehttp_transport.py +++ b/tests/unit/mcpgateway/transports/test_streamablehttp_transport.py @@ -211,6 +211,54 @@ async def fake_get_db(): assert result[0].text == "hello" +@pytest.mark.asyncio +async def test_call_tool_with_structured_content(monkeypatch): + """Test call_tool returns tuple with both unstructured and structured content.""" + # First-Party + from mcpgateway.transports.streamablehttp_transport import call_tool, tool_service, types + + mock_db = MagicMock() + mock_result = MagicMock() + mock_content = MagicMock() + mock_content.type = "text" + mock_content.text = '{"result": "success"}' + mock_result.content = [mock_content] + + # Simulate structured content being present + mock_structured = {"status": "ok", "data": {"value": 42}} + mock_result.structured_content = mock_structured + mock_result.model_dump = lambda by_alias=True: { + "content": [{"type": "text", "text": '{"result": "success"}'}], + "structuredContent": mock_structured + } + + @asynccontextmanager + async def fake_get_db(): + yield mock_db + + monkeypatch.setattr("mcpgateway.transports.streamablehttp_transport.get_db", fake_get_db) + monkeypatch.setattr(tool_service, "invoke_tool", AsyncMock(return_value=mock_result)) + + result = await call_tool("mytool", {"foo": "bar"}) + + # When structured content is present, result should be a tuple + assert isinstance(result, tuple) + assert len(result) == 2 + + # First element should be the unstructured content list + unstructured, structured = result + assert isinstance(unstructured, list) + assert len(unstructured) == 1 + assert isinstance(unstructured[0], types.TextContent) + assert unstructured[0].text == '{"result": "success"}' + + # Second element should be the structured content dict + assert isinstance(structured, dict) + assert structured == mock_structured + assert structured["status"] == "ok" + assert structured["data"]["value"] == 42 + + @pytest.mark.asyncio async def test_call_tool_no_content(monkeypatch, caplog): """Test call_tool returns [] and logs warning if no content."""