From ab30192847e9f70820962ac4e615edaee2f7e1ea Mon Sep 17 00:00:00 2001 From: Nicole Gomes Date: Thu, 1 Oct 2026 14:38:28 -0300 Subject: [PATCH 1/3] fix: mcp parameter fields are being dropped --- src/sap_cloud_sdk/agentgateway/converters.py | 55 ++++++- tests/agentgateway/unit/test_converters.py | 155 +++++++++++++++++++ 2 files changed, 208 insertions(+), 2 deletions(-) diff --git a/src/sap_cloud_sdk/agentgateway/converters.py b/src/sap_cloud_sdk/agentgateway/converters.py index ed9c40bc..8635f933 100644 --- a/src/sap_cloud_sdk/agentgateway/converters.py +++ b/src/sap_cloud_sdk/agentgateway/converters.py @@ -25,6 +25,44 @@ "object": dict, } +# JSON Schema keys that map directly to a Pydantic Field kwarg (same semantics, +# but camelCase → snake_case where needed). +_FIELD_KWARGS: dict[str, str] = { + "title": "title", + "description": "description", + "examples": "examples", + "deprecated": "deprecated", + "pattern": "pattern", + "minLength": "min_length", + "maxLength": "max_length", + "minimum": "ge", + "maximum": "le", + "exclusiveMinimum": "gt", + "exclusiveMaximum": "lt", + "multipleOf": "multiple_of", +} + +# Everything else the MCP builder can emit that has no native Pydantic Field kwarg. +# These are passed through via json_schema_extra so the LLM still sees them. +_EXTRA_KEYS: frozenset[str] = frozenset( + { + "enum", + "default", + "example", + "const", + "format", + "contentEncoding", + "uniqueItems", + "items", + "properties", + "required", + "additionalProperties", + "oneOf", + "anyOf", + "allOf", + } +) + def _resolve_type(json_type: Any) -> tuple[type, bool]: """Return (python_type, is_nullable) from a JSON Schema ``type`` value. @@ -119,10 +157,23 @@ async def run(**kwargs) -> str: v = properties[orig] py_type, type_nullable = _resolve_type(v.get("type")) optional = orig not in required + + field_kwargs: dict[str, Any] = {} + extra: dict[str, Any] = {} + for key, value in v.items(): + if key in ("type",): + continue + if key in _FIELD_KWARGS: + field_kwargs[_FIELD_KWARGS[key]] = value + elif key in _EXTRA_KEYS: + extra[key] = value + if extra: + field_kwargs["json_schema_extra"] = extra + if optional or type_nullable: - fields[safe] = (py_type | None, Field(default=None)) + fields[safe] = (py_type | None, Field(default=None, **field_kwargs)) else: - fields[safe] = (py_type, ...) + fields[safe] = (py_type, Field(..., **field_kwargs)) args_schema = create_model(f"{mcp_tool.name}_args", **fields) if fields else None return StructuredTool.from_function( diff --git a/tests/agentgateway/unit/test_converters.py b/tests/agentgateway/unit/test_converters.py index 45e30663..d11ca950 100644 --- a/tests/agentgateway/unit/test_converters.py +++ b/tests/agentgateway/unit/test_converters.py @@ -316,6 +316,161 @@ async def test_optional_underscore_param_restored_when_supplied(self): assert "Product" not in kwargs +class TestMcpToolToLangchainFieldMetadata: + """JSON Schema property metadata is forwarded to Pydantic Field. + + Fields with a native Pydantic equivalent go there directly; everything + else is preserved via json_schema_extra so the LLM still sees them. + """ + + def _tool(self, properties: dict, required: list[str] | None = None) -> MCPTool: + return MCPTool( + name="meta_tool", + server_name="server", + description="desc", + input_schema={ + "type": "object", + "required": required or [], + "properties": properties, + }, + url="https://example.com/mcp", + ) + + # --- native Field kwargs --- + + def test_description_preserved_on_required_field(self): + lc_tool = mcp_tool_to_langchain( + self._tool({"s": {"type": "string", "description": "The status"}}, required=["s"]), + AsyncMock(), lambda: "token", + ) + assert _schema_fields(lc_tool)["s"].description == "The status" + + def test_description_preserved_on_optional_field(self): + lc_tool = mcp_tool_to_langchain( + self._tool({"s": {"type": "string", "description": "The status"}}), + AsyncMock(), lambda: "token", + ) + assert _schema_fields(lc_tool)["s"].description == "The status" + + def test_title_preserved(self): + lc_tool = mcp_tool_to_langchain( + self._tool({"n": {"type": "string", "title": "OriginalName"}}, required=["n"]), + AsyncMock(), lambda: "token", + ) + assert _schema_fields(lc_tool)["n"].title == "OriginalName" + + def test_examples_preserved_as_native_field_kwarg(self): + lc_tool = mcp_tool_to_langchain( + self._tool({"n": {"type": "string", "examples": ["Alice", "Bob"]}}, required=["n"]), + AsyncMock(), lambda: "token", + ) + assert _schema_fields(lc_tool)["n"].examples == ["Alice", "Bob"] + + def test_deprecated_preserved(self): + lc_tool = mcp_tool_to_langchain( + self._tool({"n": {"type": "string", "deprecated": True}}, required=["n"]), + AsyncMock(), lambda: "token", + ) + assert _schema_fields(lc_tool)["n"].deprecated is True + + def test_pattern_preserved(self): + lc_tool = mcp_tool_to_langchain( + self._tool({"n": {"type": "string", "pattern": "^[a-z]+$"}}, required=["n"]), + AsyncMock(), lambda: "token", + ) + assert _schema_fields(lc_tool)["n"].metadata # pattern lives in metadata + + def test_min_max_length_preserved(self): + lc_tool = mcp_tool_to_langchain( + self._tool({"n": {"type": "string", "minLength": 2, "maxLength": 50}}, required=["n"]), + AsyncMock(), lambda: "token", + ) + schema = lc_tool.args_schema.model_json_schema() + assert schema["properties"]["n"]["minLength"] == 2 + assert schema["properties"]["n"]["maxLength"] == 50 + + def test_minimum_maximum_preserved(self): + lc_tool = mcp_tool_to_langchain( + self._tool({"v": {"type": "integer", "minimum": 1, "maximum": 100}}, required=["v"]), + AsyncMock(), lambda: "token", + ) + schema = lc_tool.args_schema.model_json_schema() + assert schema["properties"]["v"]["minimum"] == 1 + assert schema["properties"]["v"]["maximum"] == 100 + + # --- json_schema_extra bucket --- + + def test_enum_preserved_in_json_schema_extra(self): + lc_tool = mcp_tool_to_langchain( + self._tool({"c": {"type": "string", "enum": ["red", "green", "blue"]}}, required=["c"]), + AsyncMock(), lambda: "token", + ) + schema = lc_tool.args_schema.model_json_schema() + assert schema["properties"]["c"]["enum"] == ["red", "green", "blue"] + + def test_default_preserved_in_json_schema_extra(self): + lc_tool = mcp_tool_to_langchain( + self._tool({"c": {"type": "string", "default": "active"}}, required=["c"]), + AsyncMock(), lambda: "token", + ) + schema = lc_tool.args_schema.model_json_schema() + assert schema["properties"]["c"]["default"] == "active" + + def test_example_preserved_in_json_schema_extra(self): + lc_tool = mcp_tool_to_langchain( + self._tool({"c": {"type": "string", "example": "hello"}}, required=["c"]), + AsyncMock(), lambda: "token", + ) + schema = lc_tool.args_schema.model_json_schema() + assert schema["properties"]["c"]["example"] == "hello" + + def test_format_preserved_in_json_schema_extra(self): + lc_tool = mcp_tool_to_langchain( + self._tool({"ts": {"type": "string", "format": "date-time"}}, required=["ts"]), + AsyncMock(), lambda: "token", + ) + schema = lc_tool.args_schema.model_json_schema() + assert schema["properties"]["ts"]["format"] == "date-time" + + def test_const_preserved_in_json_schema_extra(self): + lc_tool = mcp_tool_to_langchain( + self._tool({"v": {"type": "string", "const": "fixed"}}, required=["v"]), + AsyncMock(), lambda: "token", + ) + schema = lc_tool.args_schema.model_json_schema() + assert schema["properties"]["v"]["const"] == "fixed" + + def test_multiple_extra_keys_coexist(self): + lc_tool = mcp_tool_to_langchain( + self._tool( + {"s": {"type": "string", "enum": ["a", "b"], "format": "uuid", "example": "a"}}, + required=["s"], + ), + AsyncMock(), lambda: "token", + ) + schema = lc_tool.args_schema.model_json_schema() + assert schema["properties"]["s"]["enum"] == ["a", "b"] + assert schema["properties"]["s"]["format"] == "uuid" + assert schema["properties"]["s"]["example"] == "a" + + def test_missing_metadata_produces_no_extra(self): + lc_tool = mcp_tool_to_langchain( + self._tool({"id": {"type": "string"}}, required=["id"]), + AsyncMock(), lambda: "token", + ) + field = _schema_fields(lc_tool)["id"] + assert field.description is None + assert field.json_schema_extra is None + + def test_unknown_keys_are_silently_ignored(self): + """Keys not in either bucket (e.g. future JSON Schema extensions) must not raise.""" + lc_tool = mcp_tool_to_langchain( + self._tool({"x": {"type": "string", "x-custom-ext": "value"}}, required=["x"]), + AsyncMock(), lambda: "token", + ) + assert "x" in _schema_fields(lc_tool) + + class TestMcpToolToLangchainInvocation: """End-to-end invocation tests: verify what actually reaches call_tool.""" From 8cb02edada954d35aa9c1e617aa0ba705c91d75f Mon Sep 17 00:00:00 2001 From: Nicole Gomes Date: Thu, 1 Oct 2026 14:44:05 -0300 Subject: [PATCH 2/3] fix checks --- pyproject.toml | 2 +- tests/agentgateway/unit/test_converters.py | 23 ++++++++++++++-------- uv.lock | 2 +- 3 files changed, 17 insertions(+), 10 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index bc2c5cf9..b3a151bd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "sap-cloud-sdk" -version = "0.57.1" +version = "0.57.2" description = "SAP Cloud SDK for Python" readme = "README.md" license = "Apache-2.0" diff --git a/tests/agentgateway/unit/test_converters.py b/tests/agentgateway/unit/test_converters.py index d11ca950..fb8ad6f8 100644 --- a/tests/agentgateway/unit/test_converters.py +++ b/tests/agentgateway/unit/test_converters.py @@ -15,6 +15,13 @@ def _schema_fields(lc_tool): return schema.model_fields +def _json_schema(lc_tool) -> dict: + """Return model_json_schema() from the args_schema Pydantic model.""" + schema = lc_tool.args_schema + assert isinstance(schema, type) and issubclass(schema, BaseModel) + return schema.model_json_schema() + + def _make_tool(*, required=("eventid",), optional=("showdeclinedreason", "datafetchmode")): properties = {k: {"type": "string"} for k in (*required, *optional)} return MCPTool( @@ -385,7 +392,7 @@ def test_min_max_length_preserved(self): self._tool({"n": {"type": "string", "minLength": 2, "maxLength": 50}}, required=["n"]), AsyncMock(), lambda: "token", ) - schema = lc_tool.args_schema.model_json_schema() + schema = _json_schema(lc_tool) assert schema["properties"]["n"]["minLength"] == 2 assert schema["properties"]["n"]["maxLength"] == 50 @@ -394,7 +401,7 @@ def test_minimum_maximum_preserved(self): self._tool({"v": {"type": "integer", "minimum": 1, "maximum": 100}}, required=["v"]), AsyncMock(), lambda: "token", ) - schema = lc_tool.args_schema.model_json_schema() + schema = _json_schema(lc_tool) assert schema["properties"]["v"]["minimum"] == 1 assert schema["properties"]["v"]["maximum"] == 100 @@ -405,7 +412,7 @@ def test_enum_preserved_in_json_schema_extra(self): self._tool({"c": {"type": "string", "enum": ["red", "green", "blue"]}}, required=["c"]), AsyncMock(), lambda: "token", ) - schema = lc_tool.args_schema.model_json_schema() + schema = _json_schema(lc_tool) assert schema["properties"]["c"]["enum"] == ["red", "green", "blue"] def test_default_preserved_in_json_schema_extra(self): @@ -413,7 +420,7 @@ def test_default_preserved_in_json_schema_extra(self): self._tool({"c": {"type": "string", "default": "active"}}, required=["c"]), AsyncMock(), lambda: "token", ) - schema = lc_tool.args_schema.model_json_schema() + schema = _json_schema(lc_tool) assert schema["properties"]["c"]["default"] == "active" def test_example_preserved_in_json_schema_extra(self): @@ -421,7 +428,7 @@ def test_example_preserved_in_json_schema_extra(self): self._tool({"c": {"type": "string", "example": "hello"}}, required=["c"]), AsyncMock(), lambda: "token", ) - schema = lc_tool.args_schema.model_json_schema() + schema = _json_schema(lc_tool) assert schema["properties"]["c"]["example"] == "hello" def test_format_preserved_in_json_schema_extra(self): @@ -429,7 +436,7 @@ def test_format_preserved_in_json_schema_extra(self): self._tool({"ts": {"type": "string", "format": "date-time"}}, required=["ts"]), AsyncMock(), lambda: "token", ) - schema = lc_tool.args_schema.model_json_schema() + schema = _json_schema(lc_tool) assert schema["properties"]["ts"]["format"] == "date-time" def test_const_preserved_in_json_schema_extra(self): @@ -437,7 +444,7 @@ def test_const_preserved_in_json_schema_extra(self): self._tool({"v": {"type": "string", "const": "fixed"}}, required=["v"]), AsyncMock(), lambda: "token", ) - schema = lc_tool.args_schema.model_json_schema() + schema = _json_schema(lc_tool) assert schema["properties"]["v"]["const"] == "fixed" def test_multiple_extra_keys_coexist(self): @@ -448,7 +455,7 @@ def test_multiple_extra_keys_coexist(self): ), AsyncMock(), lambda: "token", ) - schema = lc_tool.args_schema.model_json_schema() + schema = _json_schema(lc_tool) assert schema["properties"]["s"]["enum"] == ["a", "b"] assert schema["properties"]["s"]["format"] == "uuid" assert schema["properties"]["s"]["example"] == "a" diff --git a/uv.lock b/uv.lock index 5c4ff105..78dce635 100644 --- a/uv.lock +++ b/uv.lock @@ -4363,7 +4363,7 @@ wheels = [ [[package]] name = "sap-cloud-sdk" -version = "0.57.1" +version = "0.57.2" source = { editable = "." } dependencies = [ { name = "cryptography" }, From 13e5da34ba4995a314a07e7e0d1392df7bc6dff4 Mon Sep 17 00:00:00 2001 From: Nicole Gomes Date: Thu, 1 Oct 2026 16:13:54 -0300 Subject: [PATCH 3/3] enhance integration tests for agw --- .../agentgateway/integration/agw_auth.feature | 8 + .../agentgateway/integration/test_agw_bdd.py | 72 ++++++ tests/agentgateway/unit/test_converters.py | 240 ++++++++++++++++++ 3 files changed, 320 insertions(+) diff --git a/tests/agentgateway/integration/agw_auth.feature b/tests/agentgateway/integration/agw_auth.feature index 4ab6c0cb..efc0ed8d 100644 --- a/tests/agentgateway/integration/agw_auth.feature +++ b/tests/agentgateway/integration/agw_auth.feature @@ -59,3 +59,11 @@ Feature: Agent Gateway Auth Integration Scenario: Get IAS client ID returns a non-empty string When I call get_ias_client_id Then the ias_client_id should be a non-empty string + + Scenario: Convert MCP tools to LangChain StructuredTools + Given I have a valid user token + When I call list_mcp_tools + And I convert all tools to LangChain StructuredTools + Then every tool should have a non-empty name and description + And every tool should have a Pydantic args_schema + And every tool should preserve metadata fields from input_schema diff --git a/tests/agentgateway/integration/test_agw_bdd.py b/tests/agentgateway/integration/test_agw_bdd.py index 4512b086..ee5f8c3e 100644 --- a/tests/agentgateway/integration/test_agw_bdd.py +++ b/tests/agentgateway/integration/test_agw_bdd.py @@ -17,12 +17,15 @@ import asyncio import os from typing import Optional +from unittest.mock import AsyncMock import pytest +from pydantic import BaseModel from pytest_bdd import scenarios, given, when, then, parsers from sap_cloud_sdk.agentgateway import AgentGatewayClient, AuthResult, AgentGatewaySDKError from sap_cloud_sdk.agentgateway._models import MCPTool +from sap_cloud_sdk.agentgateway.converters import mcp_tool_to_langchain scenarios("agw_auth.feature") @@ -52,6 +55,7 @@ def __init__(self): self.operation_error: Optional[Exception] = None self.user_token: Optional[str] = None self.tools: Optional[list[MCPTool]] = None + self.langchain_tools: Optional[list] = None self.tool_result: Optional[str] = None self.sample_mcp_tool_name: Optional[str] = None self.ias_client_id: Optional[str] = None @@ -281,3 +285,71 @@ def ias_client_id_non_empty(context: ScenarioContext): assert context.ias_client_id is not None assert isinstance(context.ias_client_id, str) assert context.ias_client_id.strip(), "Expected a non-empty IAS client ID" + + +# ==================== LANGCHAIN CONVERTER STEPS ==================== + + +@when("I convert all tools to LangChain StructuredTools") +def convert_tools_to_langchain(context: ScenarioContext): + """Convert every MCPTool from list_mcp_tools to a LangChain StructuredTool.""" + assert context.tools is not None, "call list_mcp_tools before converting" + token = context.user_token or "" + context.langchain_tools = [ + mcp_tool_to_langchain(t, AsyncMock(return_value="ok"), lambda: token) + for t in context.tools + ] + + +@then("every tool should have a non-empty name and description") +def every_langchain_tool_has_name_and_description(context: ScenarioContext): + """Verify name and description survive the conversion.""" + assert context.langchain_tools is not None + for lc_tool, mcp_tool in zip(context.langchain_tools, context.tools or []): + assert lc_tool.name == mcp_tool.name, ( + f"name mismatch: LangChain={lc_tool.name!r}, MCPTool={mcp_tool.name!r}" + ) + assert isinstance(lc_tool.description, str) and lc_tool.description.strip(), ( + f"Tool '{lc_tool.name}' has empty description" + ) + + +@then("every tool should have a Pydantic args_schema") +def every_langchain_tool_has_args_schema(context: ScenarioContext): + """Verify every converted tool has a Pydantic BaseModel as args_schema.""" + assert context.langchain_tools is not None + for lc_tool in context.langchain_tools: + assert lc_tool.args_schema is not None, ( + f"Tool '{lc_tool.name}' has no args_schema" + ) + assert isinstance(lc_tool.args_schema, type) and issubclass( + lc_tool.args_schema, BaseModel + ), f"Tool '{lc_tool.name}' args_schema is not a Pydantic BaseModel" + + +@then("every tool should preserve metadata fields from input_schema") +def every_langchain_tool_preserves_metadata(context: ScenarioContext): + """Verify that description, title, examples, enum, and constraints from the + MCP input_schema appear in the converted Pydantic model_json_schema.""" + assert context.langchain_tools is not None + assert context.tools is not None + + CHECKED_KEYS = ("description", "title", "examples", "enum", "pattern", + "maxLength", "minimum", "maximum", "format", "default") + + for lc_tool, mcp_tool in zip(context.langchain_tools, context.tools): + assert isinstance(lc_tool.args_schema, type) and issubclass(lc_tool.args_schema, BaseModel) + out_props = lc_tool.args_schema.model_json_schema().get("properties", {}) + in_props = mcp_tool.input_schema.get("properties", {}) + + for pname, pdef in in_props.items(): + safe = pname.lstrip("_") or pname + assert safe in out_props, ( + f"Tool '{mcp_tool.name}': parameter '{pname}' missing from converted schema" + ) + out_str = str(out_props[safe]) + for key in CHECKED_KEYS: + if key in pdef: + assert f"'{key}'" in out_str or f'"{key}"' in out_str, ( + f"Tool '{mcp_tool.name}': '{pname}'.{key} dropped during conversion" + ) diff --git a/tests/agentgateway/unit/test_converters.py b/tests/agentgateway/unit/test_converters.py index fb8ad6f8..fee04b66 100644 --- a/tests/agentgateway/unit/test_converters.py +++ b/tests/agentgateway/unit/test_converters.py @@ -22,6 +22,13 @@ def _json_schema(lc_tool) -> dict: return schema.model_json_schema() +def _model(lc_tool) -> type[BaseModel]: + """Return the narrowed args_schema Pydantic model class.""" + schema = lc_tool.args_schema + assert isinstance(schema, type) and issubclass(schema, BaseModel) + return schema + + def _make_tool(*, required=("eventid",), optional=("showdeclinedreason", "datafetchmode")): properties = {k: {"type": "string"} for k in (*required, *optional)} return MCPTool( @@ -537,3 +544,236 @@ async def test_none_values_forwarded_when_omit_none_false(self): kwargs = call_tool.call_args.kwargs assert "showdeclinedreason" in kwargs assert kwargs["showdeclinedreason"] is None + + +# --------------------------------------------------------------------------- +# Parameter shapes taken verbatim from a real Agent Gateway MCP payload +# (list_directReports_in_User_for_sfodata / get_User_for_sfodata). +# These tests ensure the converter handles every field combination the MCP +# builder actually emits without dropping metadata or crashing. +# --------------------------------------------------------------------------- + +_SFODATA_TOOL_SCHEMA = { + "type": "object", + "additionalProperties": False, + "required": ["userid"], + "properties": { + # required string with title + description + maxLength + "userid": { + "title": "userId", + "type": "string", + "maxLength": 100, + "description": "Key property userId", + }, + # optional string with title + description + examples only + "filter": { + "title": "filter", + "description": "OData $filter expression to return only entities matching specific criteria.", + "type": "string", + "examples": ["Price gt 20", "Category eq 'Beverages'"], + }, + # optional string with title + description + examples + pattern + "orderby": { + "title": "orderby", + "description": "OData $orderby query option.", + "type": "string", + "examples": ["Price desc", "Name asc"], + "pattern": "^[a-zA-Z0-9_]+( (asc|desc))?(, [a-zA-Z0-9_]+( (asc|desc))?)*", + }, + # optional integer with title + description + examples + minimum + default + "top": { + "title": "top", + "description": "OData $top query option.", + "type": "integer", + "examples": ["10", "50"], + "minimum": 0, + "default": 50, + }, + # optional integer with title + description + examples + minimum (no default) + "skip": { + "title": "skip", + "description": "OData $skip query option.", + "type": "integer", + "examples": ["10", "50"], + "minimum": 0, + }, + # optional string with title + description only (no constraints) + "expand": { + "title": "expand", + "description": "OData $expand query option.", + "type": "string", + }, + # optional string with title + description + examples + enum + "inlinecount": { + "title": "inlinecount", + "description": "OData $inlinecount query option.", + "type": "string", + "examples": ["allpages"], + "enum": ["allpages", "none"], + }, + }, +} + + +def _sfodata_tool() -> MCPTool: + return MCPTool( + name="list_directReports_in_User_for_sfodata", + server_name="sfodata", + description="Navigate from User to related User entities via the 'directReports' navigation property.", + input_schema=_SFODATA_TOOL_SCHEMA, + url="https://example.com/mcp", + ) + + +class TestRealAgentGatewayPayloadShapes: + """Converter correctness against parameter shapes from a real AGW MCP payload. + + Each test maps to one of the distinct field combinations actually emitted + by the MCP builder (SFOData service). The fixture schema is copied verbatim + from the live payload — do not simplify it. + """ + + def _props(self, lc_tool) -> dict: + return _json_schema(lc_tool)["properties"] + + # ── required string: title + description + maxLength ────────────────── + + def test_required_string_with_maxlength(self): + """userid: required string — title, description, maxLength must all appear.""" + lc_tool = mcp_tool_to_langchain(_sfodata_tool(), AsyncMock(), lambda: "tok") + p = self._props(lc_tool)["userid"] + assert p.get("title") == "userId" + assert p.get("description") == "Key property userId" + assert p.get("maxLength") == 100 + assert p.get("type") == "string" + + def test_required_field_is_required(self): + lc_tool = mcp_tool_to_langchain(_sfodata_tool(), AsyncMock(), lambda: "tok") + assert _schema_fields(lc_tool)["userid"].is_required() + + def test_required_string_maxlength_enforced(self): + """maxLength is a native Field kwarg — Pydantic must enforce it at validation time.""" + lc_tool = mcp_tool_to_langchain(_sfodata_tool(), AsyncMock(), lambda: "tok") + import pydantic + with pytest.raises(pydantic.ValidationError, match="at most 100"): + _model(lc_tool)(**{"userid": "x" * 101}) + + # ── optional string: title + description + examples ─────────────────── + + def test_optional_string_with_examples(self): + """filter: optional string — title, description, examples must all appear.""" + lc_tool = mcp_tool_to_langchain(_sfodata_tool(), AsyncMock(), lambda: "tok") + p = self._props(lc_tool)["filter"] + assert p.get("title") == "filter" + assert "OData $filter" in p.get("description", "") + assert p.get("examples") == ["Price gt 20", "Category eq 'Beverages'"] + + def test_optional_string_is_optional(self): + lc_tool = mcp_tool_to_langchain(_sfodata_tool(), AsyncMock(), lambda: "tok") + assert not _schema_fields(lc_tool)["filter"].is_required() + + # ── optional string: title + description + examples + pattern ───────── + + def test_optional_string_with_examples_and_pattern(self): + """orderby: optional string — title, description, examples, pattern must all appear.""" + lc_tool = mcp_tool_to_langchain(_sfodata_tool(), AsyncMock(), lambda: "tok") + p = self._props(lc_tool)["orderby"] + assert p.get("title") == "orderby" + assert p.get("examples") == ["Price desc", "Name asc"] + # pattern lands inside the non-null anyOf branch for optional fields + schema_str = str(p) + assert "pattern" in schema_str + + def test_optional_string_pattern_enforced(self): + """pattern is a native Field kwarg — Pydantic must reject values that don't match.""" + lc_tool = mcp_tool_to_langchain(_sfodata_tool(), AsyncMock(), lambda: "tok") + import pydantic + with pytest.raises(pydantic.ValidationError, match="pattern"): + _model(lc_tool)(**{"userid": "u1", "orderby": "!!!invalid!!!"}) + + # ── optional integer: title + description + examples + minimum + default + + def test_optional_integer_with_minimum_and_default(self): + """top: optional integer — title, description, examples, minimum, default must appear.""" + lc_tool = mcp_tool_to_langchain(_sfodata_tool(), AsyncMock(), lambda: "tok") + p = self._props(lc_tool)["top"] + assert p.get("title") == "top" + assert p.get("examples") == ["10", "50"] + assert p.get("default") == 50 + schema_str = str(p) + assert "minimum" in schema_str + + def test_optional_integer_minimum_enforced(self): + """minimum is a native Field kwarg — Pydantic must reject values below it.""" + lc_tool = mcp_tool_to_langchain(_sfodata_tool(), AsyncMock(), lambda: "tok") + import pydantic + with pytest.raises(pydantic.ValidationError, match="greater than or equal"): + _model(lc_tool)(**{"userid": "u1", "top": -1}) + + # ── optional integer: title + description + examples + minimum (no default) + + def test_optional_integer_with_minimum_no_default(self): + """skip: same as top but no default — minimum must still appear.""" + lc_tool = mcp_tool_to_langchain(_sfodata_tool(), AsyncMock(), lambda: "tok") + p = self._props(lc_tool)["skip"] + schema_str = str(p) + assert "minimum" in schema_str + assert not _schema_fields(lc_tool)["skip"].is_required() + + # ── optional string: title + description only ───────────────────────── + + def test_optional_string_title_and_description_only(self): + """expand: bare optional string — title and description must appear, nothing spurious.""" + lc_tool = mcp_tool_to_langchain(_sfodata_tool(), AsyncMock(), lambda: "tok") + p = self._props(lc_tool)["expand"] + assert p.get("title") == "expand" + assert "expand" in p.get("description", "").lower() + + # ── optional string: title + description + examples + enum ──────────── + + def test_optional_string_with_enum_and_examples(self): + """inlinecount: optional string with enum — enum must appear in schema for the LLM.""" + lc_tool = mcp_tool_to_langchain(_sfodata_tool(), AsyncMock(), lambda: "tok") + p = self._props(lc_tool)["inlinecount"] + assert p.get("title") == "inlinecount" + assert p.get("examples") == ["allpages"] + assert p.get("enum") == ["allpages", "none"] + + # ── full tool: no crashes, all 7 params present ─────────────────────── + + def test_all_params_present_in_schema(self): + """Every property in the real payload schema must appear in the converted schema.""" + lc_tool = mcp_tool_to_langchain(_sfodata_tool(), AsyncMock(), lambda: "tok") + props = self._props(lc_tool) + for param in ("userid", "filter", "orderby", "top", "skip", "expand", "inlinecount"): + assert param in props, f"'{param}' missing from converted schema" + + # ── invocation: required param forwarded, optionals omitted when None ─ + + @pytest.mark.asyncio + async def test_invocation_required_only(self): + """Calling with only the required userid must forward it and omit all optional params.""" + call_tool = AsyncMock(return_value="[]") + lc_tool = mcp_tool_to_langchain(_sfodata_tool(), call_tool, lambda: "tok") + + await lc_tool.arun({"userid": "user123"}) + + kwargs = call_tool.call_args.kwargs + assert kwargs["userid"] == "user123" + for opt in ("filter", "orderby", "top", "skip", "expand", "inlinecount"): + assert opt not in kwargs, f"optional '{opt}' must not be forwarded when None" + + @pytest.mark.asyncio + async def test_invocation_with_optional_params(self): + """Calling with several optional params must forward exactly the supplied ones.""" + call_tool = AsyncMock(return_value="[]") + lc_tool = mcp_tool_to_langchain(_sfodata_tool(), call_tool, lambda: "tok") + + await lc_tool.arun({"userid": "user123", "top": 10, "inlinecount": "allpages"}) + + kwargs = call_tool.call_args.kwargs + assert kwargs["userid"] == "user123" + assert kwargs["top"] == 10 + assert kwargs["inlinecount"] == "allpages" + assert "filter" not in kwargs + assert "orderby" not in kwargs