Jeremiah Lowin commited on
Commit
82be79f
·
unverified ·
2 Parent(s): 6ad13fe365b235

Merge pull request #293 from jlowin/openapi-descriptions

Browse files
src/fastmcp/server/openapi.py CHANGED
@@ -534,10 +534,12 @@ class FastMCPOpenAPI(FastMCP):
534
  or f"Executes {route.method} {route.path}"
535
  )
536
 
537
- # Format enhanced description
538
  enhanced_description = format_description_with_responses(
539
  base_description=base_description,
540
  responses=route.responses,
 
 
541
  )
542
 
543
  tool = OpenAPITool(
@@ -565,10 +567,12 @@ class FastMCPOpenAPI(FastMCP):
565
  route.description or route.summary or f"Represents {route.path}"
566
  )
567
 
568
- # Format enhanced description
569
  enhanced_description = format_description_with_responses(
570
  base_description=base_description,
571
  responses=route.responses,
 
 
572
  )
573
 
574
  resource = OpenAPIResource(
@@ -600,16 +604,30 @@ class FastMCPOpenAPI(FastMCP):
600
  route.description or route.summary or f"Template for {route.path}"
601
  )
602
 
603
- # Format enhanced description
604
  enhanced_description = format_description_with_responses(
605
  base_description=base_description,
606
  responses=route.responses,
 
 
607
  )
608
 
609
  template_params_schema = {
610
  "type": "object",
611
  "properties": {
612
- p.name: p.schema_ for p in route.parameters if p.location == "path"
 
 
 
 
 
 
 
 
 
 
 
 
613
  },
614
  "required": [
615
  p.name for p in route.parameters if p.location == "path" and p.required
 
534
  or f"Executes {route.method} {route.path}"
535
  )
536
 
537
+ # Format enhanced description with parameters and request body
538
  enhanced_description = format_description_with_responses(
539
  base_description=base_description,
540
  responses=route.responses,
541
+ parameters=route.parameters,
542
+ request_body=route.request_body,
543
  )
544
 
545
  tool = OpenAPITool(
 
567
  route.description or route.summary or f"Represents {route.path}"
568
  )
569
 
570
+ # Format enhanced description with parameters and request body
571
  enhanced_description = format_description_with_responses(
572
  base_description=base_description,
573
  responses=route.responses,
574
+ parameters=route.parameters,
575
+ request_body=route.request_body,
576
  )
577
 
578
  resource = OpenAPIResource(
 
604
  route.description or route.summary or f"Template for {route.path}"
605
  )
606
 
607
+ # Format enhanced description with parameters and request body
608
  enhanced_description = format_description_with_responses(
609
  base_description=base_description,
610
  responses=route.responses,
611
+ parameters=route.parameters,
612
+ request_body=route.request_body,
613
  )
614
 
615
  template_params_schema = {
616
  "type": "object",
617
  "properties": {
618
+ p.name: {
619
+ **(p.schema_.copy() if isinstance(p.schema_, dict) else {}),
620
+ **(
621
+ {"description": p.description}
622
+ if p.description
623
+ and not (
624
+ isinstance(p.schema_, dict) and "description" in p.schema_
625
+ )
626
+ else {}
627
+ ),
628
+ }
629
+ for p in route.parameters
630
+ if p.location == "path"
631
  },
632
  "required": [
633
  p.name for p in route.parameters if p.location == "path" and p.required
src/fastmcp/utilities/openapi.py CHANGED
@@ -1001,53 +1001,153 @@ def format_description_with_responses(
1001
  responses: dict[
1002
  str, Any
1003
  ], # Changed from specific ResponseInfo type to avoid circular imports
 
 
1004
  ) -> str:
1005
- """Formats the base description string with response information."""
1006
- if not responses:
1007
- return base_description
 
 
 
 
 
 
 
 
1008
 
 
 
 
 
1009
  desc_parts = [base_description]
1010
- response_section = "\n\n**Responses:**"
1011
- added_response_section = False
1012
 
1013
- # Determine success codes (common ones)
1014
- success_codes = {"200", "201", "202", "204"} # As strings
1015
- success_status = next((s for s in success_codes if s in responses), None)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1016
 
1017
- # Process all responses
1018
- responses_to_process = responses.items()
 
 
1019
 
1020
- for status_code, resp_info in sorted(responses_to_process):
1021
- if not added_response_section:
1022
- desc_parts.append(response_section)
1023
- added_response_section = True
1024
 
1025
- status_marker = " (Success)" if status_code == success_status else ""
1026
- desc_parts.append(
1027
- f"\n- **{status_code}**{status_marker}: {resp_info.description or 'No description.'}"
1028
- )
1029
 
1030
- # Process content schemas for this response
1031
- if resp_info.content_schema:
1032
- # Prioritize json, then take first available
1033
- media_type = (
1034
- "application/json"
1035
- if "application/json" in resp_info.content_schema
1036
- else next(iter(resp_info.content_schema), None)
 
1037
  )
1038
 
1039
- if media_type:
1040
- schema = resp_info.content_schema.get(media_type)
1041
- desc_parts.append(f" - Content-Type: `{media_type}`")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1042
 
1043
- if schema:
1044
  # Generate Example
1045
- example = generate_example_from_schema(schema)
1046
- if example != "unknown_type" and example is not None:
1047
- desc_parts.append("\n - **Example:**")
1048
- desc_parts.append(
1049
- format_json_for_description(example, indent=2)
1050
- )
 
1051
 
1052
  return "\n".join(desc_parts)
1053
 
@@ -1069,7 +1169,15 @@ def _combine_schemas(route: openapi.HTTPRoute) -> dict[str, Any]:
1069
  for param in route.parameters:
1070
  if param.required:
1071
  required.append(param.name)
1072
- properties[param.name] = param.schema_
 
 
 
 
 
 
 
 
1073
 
1074
  # Add request body if it exists
1075
  if route.request_body and route.request_body.content_schema:
@@ -1077,8 +1185,11 @@ def _combine_schemas(route: openapi.HTTPRoute) -> dict[str, Any]:
1077
  content_type = next(iter(route.request_body.content_schema))
1078
  body_schema = route.request_body.content_schema[content_type]
1079
  body_props = body_schema.get("properties", {})
 
 
1080
  for prop_name, prop_schema in body_props.items():
1081
  properties[prop_name] = prop_schema
 
1082
  if route.request_body.required:
1083
  required.extend(body_schema.get("required", []))
1084
 
 
1001
  responses: dict[
1002
  str, Any
1003
  ], # Changed from specific ResponseInfo type to avoid circular imports
1004
+ parameters: list[openapi.ParameterInfo] | None = None, # Add parameters parameter
1005
+ request_body: openapi.RequestBodyInfo | None = None, # Add request_body parameter
1006
  ) -> str:
1007
+ """
1008
+ Formats the base description string with response, parameter, and request body information.
1009
+
1010
+ Args:
1011
+ base_description (str): The initial description to be formatted.
1012
+ responses (dict[str, Any]): A dictionary of response information, keyed by status code.
1013
+ parameters (list[openapi.ParameterInfo] | None, optional): A list of parameter information,
1014
+ including path and query parameters. Each parameter includes details such as name,
1015
+ location, whether it is required, and a description.
1016
+ request_body (openapi.RequestBodyInfo | None, optional): Information about the request body,
1017
+ including its description, whether it is required, and its content schema.
1018
 
1019
+ Returns:
1020
+ str: The formatted description string with additional details about responses, parameters,
1021
+ and the request body.
1022
+ """
1023
  desc_parts = [base_description]
 
 
1024
 
1025
+ # Add parameter information
1026
+ if parameters:
1027
+ # Process path parameters
1028
+ path_params = [p for p in parameters if p.location == "path"]
1029
+ if path_params:
1030
+ param_section = "\n\n**Path Parameters:**"
1031
+ desc_parts.append(param_section)
1032
+ for param in path_params:
1033
+ required_marker = " (Required)" if param.required else ""
1034
+ param_desc = f"\n- **{param.name}**{required_marker}: {param.description or 'No description.'}"
1035
+ desc_parts.append(param_desc)
1036
+
1037
+ # Process query parameters
1038
+ query_params = [p for p in parameters if p.location == "query"]
1039
+ if query_params:
1040
+ param_section = "\n\n**Query Parameters:**"
1041
+ desc_parts.append(param_section)
1042
+ for param in query_params:
1043
+ required_marker = " (Required)" if param.required else ""
1044
+ param_desc = f"\n- **{param.name}**{required_marker}: {param.description or 'No description.'}"
1045
+ desc_parts.append(param_desc)
1046
+
1047
+ # Add request body information if present
1048
+ if request_body and request_body.description:
1049
+ req_body_section = "\n\n**Request Body:**"
1050
+ desc_parts.append(req_body_section)
1051
+ required_marker = " (Required)" if request_body.required else ""
1052
+ desc_parts.append(f"\n{request_body.description}{required_marker}")
1053
+
1054
+ # Add request body property descriptions if available
1055
+ if request_body.content_schema:
1056
+ media_type = (
1057
+ "application/json"
1058
+ if "application/json" in request_body.content_schema
1059
+ else next(iter(request_body.content_schema), None)
1060
+ )
1061
+ if media_type:
1062
+ schema = request_body.content_schema.get(media_type, {})
1063
+ if isinstance(schema, dict) and "properties" in schema:
1064
+ desc_parts.append("\n\n**Request Properties:**")
1065
+ for prop_name, prop_schema in schema["properties"].items():
1066
+ if (
1067
+ isinstance(prop_schema, dict)
1068
+ and "description" in prop_schema
1069
+ ):
1070
+ required = prop_name in schema.get("required", [])
1071
+ req_mark = " (Required)" if required else ""
1072
+ desc_parts.append(
1073
+ f"\n- **{prop_name}**{req_mark}: {prop_schema['description']}"
1074
+ )
1075
 
1076
+ # Add response information
1077
+ if responses:
1078
+ response_section = "\n\n**Responses:**"
1079
+ added_response_section = False
1080
 
1081
+ # Determine success codes (common ones)
1082
+ success_codes = {"200", "201", "202", "204"} # As strings
1083
+ success_status = next((s for s in success_codes if s in responses), None)
 
1084
 
1085
+ # Process all responses
1086
+ responses_to_process = responses.items()
 
 
1087
 
1088
+ for status_code, resp_info in sorted(responses_to_process):
1089
+ if not added_response_section:
1090
+ desc_parts.append(response_section)
1091
+ added_response_section = True
1092
+
1093
+ status_marker = " (Success)" if status_code == success_status else ""
1094
+ desc_parts.append(
1095
+ f"\n- **{status_code}**{status_marker}: {resp_info.description or 'No description.'}"
1096
  )
1097
 
1098
+ # Process content schemas for this response
1099
+ if resp_info.content_schema:
1100
+ # Prioritize json, then take first available
1101
+ media_type = (
1102
+ "application/json"
1103
+ if "application/json" in resp_info.content_schema
1104
+ else next(iter(resp_info.content_schema), None)
1105
+ )
1106
+
1107
+ if media_type:
1108
+ schema = resp_info.content_schema.get(media_type)
1109
+ desc_parts.append(f" - Content-Type: `{media_type}`")
1110
+
1111
+ # Add response property descriptions
1112
+ if isinstance(schema, dict):
1113
+ # Handle array responses
1114
+ if schema.get("type") == "array" and "items" in schema:
1115
+ items_schema = schema["items"]
1116
+ if (
1117
+ isinstance(items_schema, dict)
1118
+ and "properties" in items_schema
1119
+ ):
1120
+ desc_parts.append("\n - **Response Item Properties:**")
1121
+ for prop_name, prop_schema in items_schema[
1122
+ "properties"
1123
+ ].items():
1124
+ if (
1125
+ isinstance(prop_schema, dict)
1126
+ and "description" in prop_schema
1127
+ ):
1128
+ desc_parts.append(
1129
+ f"\n - **{prop_name}**: {prop_schema['description']}"
1130
+ )
1131
+ # Handle object responses
1132
+ elif "properties" in schema:
1133
+ desc_parts.append("\n - **Response Properties:**")
1134
+ for prop_name, prop_schema in schema["properties"].items():
1135
+ if (
1136
+ isinstance(prop_schema, dict)
1137
+ and "description" in prop_schema
1138
+ ):
1139
+ desc_parts.append(
1140
+ f"\n - **{prop_name}**: {prop_schema['description']}"
1141
+ )
1142
 
 
1143
  # Generate Example
1144
+ if schema:
1145
+ example = generate_example_from_schema(schema)
1146
+ if example != "unknown_type" and example is not None:
1147
+ desc_parts.append("\n - **Example:**")
1148
+ desc_parts.append(
1149
+ format_json_for_description(example, indent=2)
1150
+ )
1151
 
1152
  return "\n".join(desc_parts)
1153
 
 
1169
  for param in route.parameters:
1170
  if param.required:
1171
  required.append(param.name)
1172
+
1173
+ # Copy the schema and add description if available
1174
+ param_schema = param.schema_.copy() if isinstance(param.schema_, dict) else {}
1175
+
1176
+ # Add parameter description to schema if available and not already present
1177
+ if param.description and not param_schema.get("description"):
1178
+ param_schema["description"] = param.description
1179
+
1180
+ properties[param.name] = param_schema
1181
 
1182
  # Add request body if it exists
1183
  if route.request_body and route.request_body.content_schema:
 
1185
  content_type = next(iter(route.request_body.content_schema))
1186
  body_schema = route.request_body.content_schema[content_type]
1187
  body_props = body_schema.get("properties", {})
1188
+
1189
+ # Add request body properties
1190
  for prop_name, prop_schema in body_props.items():
1191
  properties[prop_name] = prop_schema
1192
+
1193
  if route.request_body.required:
1194
  required.extend(body_schema.get("required", []))
1195
 
tests/server/test_openapi.py CHANGED
@@ -1034,3 +1034,738 @@ async def test_none_path_parameters_rejected(
1034
  "name": "New Name",
1035
  },
1036
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1034
  "name": "New Name",
1035
  },
1036
  )
1037
+
1038
+
1039
+ class TestDescriptionPropagation:
1040
+ """Tests for OpenAPI description propagation to FastMCP components.
1041
+
1042
+ Each test focuses on a single, specific behavior to make it immediately clear
1043
+ what's broken when a test fails.
1044
+ """
1045
+
1046
+ @pytest.fixture
1047
+ def simple_openapi_spec(self) -> dict:
1048
+ """Create a minimal OpenAPI spec with obvious test descriptions."""
1049
+ return {
1050
+ "openapi": "3.1.0",
1051
+ "info": {"title": "Test API", "version": "1.0.0"},
1052
+ "paths": {
1053
+ "/items": {
1054
+ "get": {
1055
+ "operationId": "listItems",
1056
+ "summary": "List items summary",
1057
+ "description": "LIST_DESCRIPTION\n\nFUNCTION_LIST_DESCRIPTION",
1058
+ "responses": {
1059
+ "200": {
1060
+ "description": "LIST_RESPONSE_DESCRIPTION",
1061
+ "content": {
1062
+ "application/json": {
1063
+ "schema": {
1064
+ "type": "array",
1065
+ "items": {
1066
+ "type": "object",
1067
+ "properties": {
1068
+ "id": {
1069
+ "type": "string",
1070
+ "description": "ITEM_RESPONSE_ID_DESCRIPTION",
1071
+ },
1072
+ "name": {
1073
+ "type": "string",
1074
+ "description": "ITEM_RESPONSE_NAME_DESCRIPTION",
1075
+ },
1076
+ "price": {
1077
+ "type": "number",
1078
+ "description": "ITEM_RESPONSE_PRICE_DESCRIPTION",
1079
+ },
1080
+ },
1081
+ },
1082
+ },
1083
+ }
1084
+ },
1085
+ }
1086
+ },
1087
+ }
1088
+ },
1089
+ "/items/{item_id}": {
1090
+ "get": {
1091
+ "operationId": "getItem",
1092
+ "summary": "Get item summary",
1093
+ "description": "GET_DESCRIPTION\n\nFUNCTION_GET_DESCRIPTION",
1094
+ "parameters": [
1095
+ {
1096
+ "name": "item_id",
1097
+ "in": "path",
1098
+ "required": True,
1099
+ "description": "PATH_PARAM_DESCRIPTION",
1100
+ "schema": {"type": "string"},
1101
+ },
1102
+ {
1103
+ "name": "fields",
1104
+ "in": "query",
1105
+ "required": False,
1106
+ "description": "QUERY_PARAM_DESCRIPTION",
1107
+ "schema": {"type": "string"},
1108
+ },
1109
+ ],
1110
+ "responses": {
1111
+ "200": {
1112
+ "description": "GET_RESPONSE_DESCRIPTION",
1113
+ "content": {
1114
+ "application/json": {
1115
+ "schema": {
1116
+ "type": "object",
1117
+ "properties": {
1118
+ "id": {
1119
+ "type": "string",
1120
+ "description": "ITEM_RESPONSE_ID_DESCRIPTION",
1121
+ },
1122
+ "name": {
1123
+ "type": "string",
1124
+ "description": "ITEM_RESPONSE_NAME_DESCRIPTION",
1125
+ },
1126
+ "price": {
1127
+ "type": "number",
1128
+ "description": "ITEM_RESPONSE_PRICE_DESCRIPTION",
1129
+ },
1130
+ },
1131
+ },
1132
+ }
1133
+ },
1134
+ }
1135
+ },
1136
+ }
1137
+ },
1138
+ "/items/create": {
1139
+ "post": {
1140
+ "operationId": "createItem",
1141
+ "summary": "Create item summary",
1142
+ "description": "CREATE_DESCRIPTION\n\nFUNCTION_CREATE_DESCRIPTION",
1143
+ "requestBody": {
1144
+ "required": True,
1145
+ "description": "BODY_DESCRIPTION",
1146
+ "content": {
1147
+ "application/json": {
1148
+ "schema": {
1149
+ "type": "object",
1150
+ "properties": {
1151
+ "name": {
1152
+ "type": "string",
1153
+ "description": "PROP_DESCRIPTION",
1154
+ }
1155
+ },
1156
+ "required": ["name"],
1157
+ }
1158
+ }
1159
+ },
1160
+ },
1161
+ "responses": {
1162
+ "201": {
1163
+ "description": "CREATE_RESPONSE_DESCRIPTION",
1164
+ "content": {
1165
+ "application/json": {
1166
+ "schema": {
1167
+ "type": "object",
1168
+ "properties": {
1169
+ "id": {
1170
+ "type": "string",
1171
+ "description": "ITEM_RESPONSE_ID_DESCRIPTION",
1172
+ },
1173
+ "name": {
1174
+ "type": "string",
1175
+ "description": "ITEM_RESPONSE_NAME_DESCRIPTION",
1176
+ },
1177
+ },
1178
+ },
1179
+ }
1180
+ },
1181
+ }
1182
+ },
1183
+ }
1184
+ },
1185
+ },
1186
+ }
1187
+
1188
+ @pytest.fixture
1189
+ async def mock_client(self) -> httpx.AsyncClient:
1190
+ """Create a mock client that returns simple responses."""
1191
+
1192
+ async def _responder(request):
1193
+ if request.url.path == "/items" and request.method == "GET":
1194
+ return httpx.Response(200, json=[{"id": "1", "name": "Item 1"}])
1195
+ elif request.url.path.startswith("/items/") and request.method == "GET":
1196
+ item_id = request.url.path.split("/")[-1]
1197
+ return httpx.Response(
1198
+ 200, json={"id": item_id, "name": f"Item {item_id}"}
1199
+ )
1200
+ elif request.url.path == "/items/create" and request.method == "POST":
1201
+ import json
1202
+
1203
+ data = json.loads(request.content)
1204
+ return httpx.Response(201, json={"id": "new", "name": data.get("name")})
1205
+
1206
+ return httpx.Response(404)
1207
+
1208
+ transport = httpx.MockTransport(_responder)
1209
+ return httpx.AsyncClient(transport=transport, base_url="http://test")
1210
+
1211
+ @pytest.fixture
1212
+ async def test_server(self, simple_openapi_spec, mock_client):
1213
+ """Create a FastMCPOpenAPI server with the simple test spec."""
1214
+ return FastMCPOpenAPI(
1215
+ openapi_spec=simple_openapi_spec,
1216
+ client=mock_client,
1217
+ name="Test API",
1218
+ )
1219
+
1220
+ # --- RESOURCE TESTS ---
1221
+
1222
+ async def test_resource_includes_route_description(self, test_server):
1223
+ """Test that a Resource includes the route description."""
1224
+ resources = list(test_server._resource_manager.get_resources().values())
1225
+ list_resource = next((r for r in resources if r.name == "listItems"), None)
1226
+
1227
+ assert list_resource is not None, "listItems resource wasn't created"
1228
+ assert "LIST_DESCRIPTION" in (list_resource.description or ""), (
1229
+ "Route description missing from Resource"
1230
+ )
1231
+
1232
+ async def test_resource_includes_response_description(self, test_server):
1233
+ """Test that a Resource includes the response description."""
1234
+ resources = list(test_server._resource_manager.get_resources().values())
1235
+ list_resource = next((r for r in resources if r.name == "listItems"), None)
1236
+
1237
+ assert list_resource is not None, "listItems resource wasn't created"
1238
+ assert "LIST_RESPONSE_DESCRIPTION" in (list_resource.description or ""), (
1239
+ "Response description missing from Resource"
1240
+ )
1241
+
1242
+ async def test_resource_includes_response_model_fields(self, test_server):
1243
+ """Test that a Resource description includes response model field descriptions."""
1244
+ resources = list(test_server._resource_manager.get_resources().values())
1245
+ list_resource = next((r for r in resources if r.name == "listItems"), None)
1246
+
1247
+ assert list_resource is not None, "listItems resource wasn't created"
1248
+ description = list_resource.description or ""
1249
+ assert "ITEM_RESPONSE_ID_DESCRIPTION" in description, (
1250
+ "Response model field descriptions missing from Resource description"
1251
+ )
1252
+ assert "ITEM_RESPONSE_NAME_DESCRIPTION" in description, (
1253
+ "Response model field descriptions missing from Resource description"
1254
+ )
1255
+ assert "ITEM_RESPONSE_PRICE_DESCRIPTION" in description, (
1256
+ "Response model field descriptions missing from Resource description"
1257
+ )
1258
+
1259
+ # --- RESOURCE TEMPLATE TESTS ---
1260
+
1261
+ async def test_template_includes_route_description(self, test_server):
1262
+ """Test that a ResourceTemplate includes the route description."""
1263
+ templates = list(test_server._resource_manager.get_templates().values())
1264
+ get_template = next((t for t in templates if t.name == "getItem"), None)
1265
+
1266
+ assert get_template is not None, "getItem template wasn't created"
1267
+ assert "GET_DESCRIPTION" in (get_template.description or ""), (
1268
+ "Route description missing from ResourceTemplate"
1269
+ )
1270
+
1271
+ async def test_template_includes_function_docstring(self, test_server):
1272
+ """Test that a ResourceTemplate includes the function docstring."""
1273
+ templates = list(test_server._resource_manager.get_templates().values())
1274
+ get_template = next((t for t in templates if t.name == "getItem"), None)
1275
+
1276
+ assert get_template is not None, "getItem template wasn't created"
1277
+ assert "FUNCTION_GET_DESCRIPTION" in (get_template.description or ""), (
1278
+ "Function docstring missing from ResourceTemplate"
1279
+ )
1280
+
1281
+ async def test_template_includes_path_parameter_description(self, test_server):
1282
+ """Test that a ResourceTemplate includes path parameter descriptions."""
1283
+ templates = list(test_server._resource_manager.get_templates().values())
1284
+ get_template = next((t for t in templates if t.name == "getItem"), None)
1285
+
1286
+ assert get_template is not None, "getItem template wasn't created"
1287
+ assert "PATH_PARAM_DESCRIPTION" in (get_template.description or ""), (
1288
+ "Path parameter description missing from ResourceTemplate description"
1289
+ )
1290
+
1291
+ async def test_template_includes_query_parameter_description(self, test_server):
1292
+ """Test that a ResourceTemplate includes query parameter descriptions."""
1293
+ templates = list(test_server._resource_manager.get_templates().values())
1294
+ get_template = next((t for t in templates if t.name == "getItem"), None)
1295
+
1296
+ assert get_template is not None, "getItem template wasn't created"
1297
+ assert "QUERY_PARAM_DESCRIPTION" in (get_template.description or ""), (
1298
+ "Query parameter description missing from ResourceTemplate description"
1299
+ )
1300
+
1301
+ async def test_template_parameter_schema_includes_description(self, test_server):
1302
+ """Test that a ResourceTemplate's parameter schema includes parameter descriptions."""
1303
+ templates = list(test_server._resource_manager.get_templates().values())
1304
+ get_template = next((t for t in templates if t.name == "getItem"), None)
1305
+
1306
+ assert get_template is not None, "getItem template wasn't created"
1307
+ assert "properties" in get_template.parameters, (
1308
+ "Schema properties missing from ResourceTemplate"
1309
+ )
1310
+ assert "item_id" in get_template.parameters["properties"], (
1311
+ "item_id missing from ResourceTemplate schema"
1312
+ )
1313
+ assert "description" in get_template.parameters["properties"]["item_id"], (
1314
+ "Description missing from item_id parameter schema"
1315
+ )
1316
+ assert (
1317
+ "PATH_PARAM_DESCRIPTION"
1318
+ in get_template.parameters["properties"]["item_id"]["description"]
1319
+ ), "Path parameter description incorrect in schema"
1320
+
1321
+ # --- TOOL TESTS ---
1322
+
1323
+ async def test_tool_includes_route_description(self, test_server):
1324
+ """Test that a Tool includes the route description."""
1325
+ tools = test_server._tool_manager.list_tools()
1326
+ create_tool = next((t for t in tools if t.name == "createItem"), None)
1327
+
1328
+ assert create_tool is not None, "createItem tool wasn't created"
1329
+ assert "CREATE_DESCRIPTION" in (create_tool.description or ""), (
1330
+ "Route description missing from Tool"
1331
+ )
1332
+
1333
+ async def test_tool_includes_function_docstring(self, test_server):
1334
+ """Test that a Tool includes the function docstring."""
1335
+ tools = test_server._tool_manager.list_tools()
1336
+ create_tool = next((t for t in tools if t.name == "createItem"), None)
1337
+
1338
+ assert create_tool is not None, "createItem tool wasn't created"
1339
+ description = create_tool.description or ""
1340
+ assert "FUNCTION_CREATE_DESCRIPTION" in description, (
1341
+ "Function docstring missing from Tool"
1342
+ )
1343
+
1344
+ async def test_tool_parameter_schema_includes_property_description(
1345
+ self, test_server
1346
+ ):
1347
+ """Test that a Tool's parameter schema includes property descriptions from request model."""
1348
+ tools = test_server._tool_manager.list_tools()
1349
+ create_tool = next((t for t in tools if t.name == "createItem"), None)
1350
+
1351
+ assert create_tool is not None, "createItem tool wasn't created"
1352
+ assert "properties" in create_tool.parameters, (
1353
+ "Schema properties missing from Tool"
1354
+ )
1355
+ assert "name" in create_tool.parameters["properties"], (
1356
+ "name parameter missing from Tool schema"
1357
+ )
1358
+ assert "description" in create_tool.parameters["properties"]["name"], (
1359
+ "Description missing from name parameter schema"
1360
+ )
1361
+ assert (
1362
+ "PROP_DESCRIPTION"
1363
+ in create_tool.parameters["properties"]["name"]["description"]
1364
+ ), "Property description incorrect in schema"
1365
+
1366
+ # --- CLIENT API TESTS ---
1367
+
1368
+ async def test_client_api_resource_description(self, test_server):
1369
+ """Test that Resource descriptions are accessible via the client API."""
1370
+ async with Client(test_server) as client:
1371
+ resources = await client.list_resources()
1372
+ list_resource = next((r for r in resources if r.name == "listItems"), None)
1373
+
1374
+ assert list_resource is not None, (
1375
+ "listItems resource not accessible via client API"
1376
+ )
1377
+ resource_description = list_resource.description or ""
1378
+ assert "LIST_DESCRIPTION" in resource_description, (
1379
+ "Route description missing in Resource from client API"
1380
+ )
1381
+
1382
+ async def test_client_api_template_description(self, test_server):
1383
+ """Test that ResourceTemplate descriptions are accessible via the client API."""
1384
+ async with Client(test_server) as client:
1385
+ templates = await client.list_resource_templates()
1386
+ get_template = next((t for t in templates if t.name == "getItem"), None)
1387
+
1388
+ assert get_template is not None, (
1389
+ "getItem template not accessible via client API"
1390
+ )
1391
+ template_description = get_template.description or ""
1392
+ assert "GET_DESCRIPTION" in template_description, (
1393
+ "Route description missing in ResourceTemplate from client API"
1394
+ )
1395
+
1396
+ async def test_client_api_tool_description(self, test_server):
1397
+ """Test that Tool descriptions are accessible via the client API."""
1398
+ async with Client(test_server) as client:
1399
+ tools = await client.list_tools()
1400
+ create_tool = next((t for t in tools if t.name == "createItem"), None)
1401
+
1402
+ assert create_tool is not None, (
1403
+ "createItem tool not accessible via client API"
1404
+ )
1405
+ tool_description = create_tool.description or ""
1406
+ assert "FUNCTION_CREATE_DESCRIPTION" in tool_description, (
1407
+ "Function docstring missing in Tool from client API"
1408
+ )
1409
+
1410
+ async def test_client_api_tool_parameter_schema(self, test_server):
1411
+ """Test that Tool parameter schemas are accessible via the client API."""
1412
+ async with Client(test_server) as client:
1413
+ tools = await client.list_tools()
1414
+ create_tool = next((t for t in tools if t.name == "createItem"), None)
1415
+
1416
+ assert create_tool is not None, (
1417
+ "createItem tool not accessible via client API"
1418
+ )
1419
+ assert "properties" in create_tool.inputSchema, (
1420
+ "Schema properties missing from Tool inputSchema in client API"
1421
+ )
1422
+ assert "name" in create_tool.inputSchema["properties"], (
1423
+ "name parameter missing from Tool schema in client API"
1424
+ )
1425
+ assert "description" in create_tool.inputSchema["properties"]["name"], (
1426
+ "Description missing from name parameter in client API"
1427
+ )
1428
+ assert (
1429
+ "PROP_DESCRIPTION"
1430
+ in create_tool.inputSchema["properties"]["name"]["description"]
1431
+ ), "Property description incorrect in schema from client API"
1432
+
1433
+
1434
+ class TestFastAPIDescriptionPropagation:
1435
+ """Tests for FastAPI docstring and annotation propagation to FastMCP components.
1436
+
1437
+ Each test focuses on a single, specific behavior to make it immediately clear
1438
+ what's broken when a test fails.
1439
+ """
1440
+
1441
+ @pytest.fixture
1442
+ def fastapi_app_with_descriptions(self) -> FastAPI:
1443
+ """Create a simple FastAPI app with docstrings and annotations."""
1444
+ from typing import Annotated
1445
+
1446
+ from pydantic import BaseModel, Field
1447
+
1448
+ app = FastAPI(title="Test FastAPI App")
1449
+
1450
+ class Item(BaseModel):
1451
+ name: str = Field(..., description="ITEM_NAME_DESCRIPTION")
1452
+ price: float = Field(..., description="ITEM_PRICE_DESCRIPTION")
1453
+
1454
+ class ItemResponse(BaseModel):
1455
+ id: str = Field(..., description="ITEM_RESPONSE_ID_DESCRIPTION")
1456
+ name: str = Field(..., description="ITEM_RESPONSE_NAME_DESCRIPTION")
1457
+ price: float = Field(..., description="ITEM_RESPONSE_PRICE_DESCRIPTION")
1458
+
1459
+ @app.get("/items", tags=["items"])
1460
+ async def list_items() -> list[ItemResponse]:
1461
+ """FUNCTION_LIST_DESCRIPTION
1462
+
1463
+ Returns a list of items.
1464
+ """
1465
+ return [
1466
+ ItemResponse(id="1", name="Item 1", price=10.0),
1467
+ ItemResponse(id="2", name="Item 2", price=20.0),
1468
+ ]
1469
+
1470
+ @app.get("/items/{item_id}", tags=["items", "detail"])
1471
+ async def get_item(
1472
+ item_id: Annotated[str, Field(description="PATH_PARAM_DESCRIPTION")],
1473
+ fields: Annotated[
1474
+ str | None, Field(description="QUERY_PARAM_DESCRIPTION")
1475
+ ] = None,
1476
+ ) -> ItemResponse:
1477
+ """FUNCTION_GET_DESCRIPTION
1478
+
1479
+ Gets a specific item by ID.
1480
+
1481
+ Args:
1482
+ item_id: The ID of the item to retrieve
1483
+ fields: Optional fields to include
1484
+ """
1485
+ return ItemResponse(
1486
+ id=item_id, name=f"Item {item_id}", price=float(item_id) * 10.0
1487
+ )
1488
+
1489
+ @app.post("/items", tags=["items", "create"])
1490
+ async def create_item(item: Item) -> ItemResponse:
1491
+ """FUNCTION_CREATE_DESCRIPTION
1492
+
1493
+ Creates a new item.
1494
+
1495
+ Body:
1496
+ Item object with name and price
1497
+ """
1498
+ return ItemResponse(id="new", name=item.name, price=item.price)
1499
+
1500
+ return app
1501
+
1502
+ @pytest.fixture
1503
+ async def fastapi_server(self, fastapi_app_with_descriptions):
1504
+ """Create a FastMCP server from the FastAPI app with custom route mappings."""
1505
+ # First create from FastAPI app to get the OpenAPI spec
1506
+ openapi_spec = fastapi_app_with_descriptions.openapi()
1507
+
1508
+ # Debug: check the operationIds in the OpenAPI spec
1509
+ print("\nDEBUG - OpenAPI Paths:")
1510
+ for path, methods in openapi_spec["paths"].items():
1511
+ for method, details in methods.items():
1512
+ if method != "parameters": # Skip non-HTTP method keys
1513
+ operation_id = details.get("operationId", "no_operation_id")
1514
+ print(
1515
+ f" Path: {path}, Method: {method}, OperationId: {operation_id}"
1516
+ )
1517
+
1518
+ # Create custom route mappings
1519
+ route_maps = [
1520
+ # Map GET /items to Resource
1521
+ RouteMap(
1522
+ methods=["GET"], pattern=r"^/items$", route_type=RouteType.RESOURCE
1523
+ ),
1524
+ # Map GET /items/{item_id} to ResourceTemplate
1525
+ RouteMap(
1526
+ methods=["GET"],
1527
+ pattern=r"^/items/\{.*\}$",
1528
+ route_type=RouteType.RESOURCE_TEMPLATE,
1529
+ ),
1530
+ # Map POST /items to Tool
1531
+ RouteMap(methods=["POST"], pattern=r"^/items$", route_type=RouteType.TOOL),
1532
+ ]
1533
+
1534
+ # Create FastMCP server with the OpenAPI spec and custom route mappings
1535
+ server = FastMCPOpenAPI(
1536
+ openapi_spec=openapi_spec,
1537
+ client=AsyncClient(
1538
+ transport=ASGITransport(app=fastapi_app_with_descriptions),
1539
+ base_url="http://test",
1540
+ ),
1541
+ name="Test FastAPI App",
1542
+ route_maps=route_maps,
1543
+ )
1544
+
1545
+ # Debug: print all components created
1546
+ print("\nDEBUG - Resources created:")
1547
+ for name, resource in server._resource_manager.get_resources().items():
1548
+ print(f" Resource: {name}, Name attribute: {resource.name}")
1549
+
1550
+ print("\nDEBUG - Templates created:")
1551
+ for name, template in server._resource_manager.get_templates().items():
1552
+ print(f" Template: {name}, Name attribute: {template.name}")
1553
+
1554
+ print("\nDEBUG - Tools created:")
1555
+ for tool in server._tool_manager.list_tools():
1556
+ print(f" Tool: {tool.name}")
1557
+
1558
+ return server
1559
+
1560
+ async def test_resource_includes_function_docstring(self, fastapi_server):
1561
+ """Test that a Resource includes the function docstring."""
1562
+ resources = list(fastapi_server._resource_manager.get_resources().values())
1563
+
1564
+ # Now checking for the get_items operation ID rather than list_items
1565
+ list_resource = next((r for r in resources if "items_get" in r.name), None)
1566
+
1567
+ assert list_resource is not None, "GET /items resource wasn't created"
1568
+ description = list_resource.description or ""
1569
+ assert "FUNCTION_LIST_DESCRIPTION" in description, (
1570
+ "Function docstring missing from Resource"
1571
+ )
1572
+
1573
+ async def test_resource_includes_response_model_fields(self, fastapi_server):
1574
+ """Test that a Resource description includes basic response information.
1575
+
1576
+ Note: FastAPI doesn't reliably include Pydantic field descriptions in the OpenAPI schema,
1577
+ so we can only check for basic response information being present.
1578
+ """
1579
+ resources = list(fastapi_server._resource_manager.get_resources().values())
1580
+ list_resource = next((r for r in resources if "items_get" in r.name), None)
1581
+
1582
+ assert list_resource is not None, "GET /items resource wasn't created"
1583
+ description = list_resource.description or ""
1584
+
1585
+ # Check that at least the response information is included
1586
+ assert "Successful Response" in description, (
1587
+ "Response information missing from Resource description"
1588
+ )
1589
+
1590
+ # We've already verified in TestDescriptionPropagation that when descriptions
1591
+ # are present in the OpenAPI schema, they are properly included in the component description
1592
+
1593
+ async def test_template_includes_function_docstring(self, fastapi_server):
1594
+ """Test that a ResourceTemplate includes the function docstring."""
1595
+ templates = list(fastapi_server._resource_manager.get_templates().values())
1596
+ get_template = next(
1597
+ (t for t in templates if "items__item_id__get" in t.name), None
1598
+ )
1599
+
1600
+ assert get_template is not None, "GET /items/{item_id} template wasn't created"
1601
+ description = get_template.description or ""
1602
+ assert "FUNCTION_GET_DESCRIPTION" in description, (
1603
+ "Function docstring missing from ResourceTemplate"
1604
+ )
1605
+
1606
+ async def test_template_includes_path_parameter_description(self, fastapi_server):
1607
+ """Test that a ResourceTemplate includes path parameter descriptions.
1608
+
1609
+ Note: Currently, FastAPI parameter descriptions using Annotated[type, Field(description=...)]
1610
+ are not properly propagated to the OpenAPI schema. The parameters appear but without the description.
1611
+ """
1612
+ templates = list(fastapi_server._resource_manager.get_templates().values())
1613
+ get_template = next(
1614
+ (t for t in templates if "items__item_id__get" in t.name), None
1615
+ )
1616
+
1617
+ assert get_template is not None, "GET /items/{item_id} template wasn't created"
1618
+ description = get_template.description or ""
1619
+
1620
+ # Just test that parameters are included at all
1621
+ assert "Path Parameters" in description, (
1622
+ "Path parameters section missing from ResourceTemplate description"
1623
+ )
1624
+ assert "item_id" in description, (
1625
+ "item_id parameter missing from ResourceTemplate description"
1626
+ )
1627
+
1628
+ async def test_template_includes_query_parameter_description(self, fastapi_server):
1629
+ """Test that a ResourceTemplate includes query parameter descriptions.
1630
+
1631
+ Note: Currently, FastAPI parameter descriptions using Annotated[type, Field(description=...)]
1632
+ are not properly propagated to the OpenAPI schema. The parameters appear but without the description.
1633
+ """
1634
+ templates = list(fastapi_server._resource_manager.get_templates().values())
1635
+ get_template = next(
1636
+ (t for t in templates if "items__item_id__get" in t.name), None
1637
+ )
1638
+
1639
+ assert get_template is not None, "GET /items/{item_id} template wasn't created"
1640
+ description = get_template.description or ""
1641
+
1642
+ # Just test that parameters are included at all
1643
+ assert "Query Parameters" in description, (
1644
+ "Query parameters section missing from ResourceTemplate description"
1645
+ )
1646
+ assert "fields" in description, (
1647
+ "fields parameter missing from ResourceTemplate description"
1648
+ )
1649
+
1650
+ async def test_template_parameter_schema_includes_description(self, fastapi_server):
1651
+ """Test that a ResourceTemplate's parameter schema includes parameter descriptions."""
1652
+ templates = list(fastapi_server._resource_manager.get_templates().values())
1653
+ get_template = next(
1654
+ (t for t in templates if "items__item_id__get" in t.name), None
1655
+ )
1656
+
1657
+ assert get_template is not None, "GET /items/{item_id} template wasn't created"
1658
+ assert "properties" in get_template.parameters, (
1659
+ "Schema properties missing from ResourceTemplate"
1660
+ )
1661
+ assert "item_id" in get_template.parameters["properties"], (
1662
+ "item_id missing from ResourceTemplate schema"
1663
+ )
1664
+ assert "description" in get_template.parameters["properties"]["item_id"], (
1665
+ "Description missing from item_id parameter schema"
1666
+ )
1667
+ assert (
1668
+ "PATH_PARAM_DESCRIPTION"
1669
+ in get_template.parameters["properties"]["item_id"]["description"]
1670
+ ), "Path parameter description incorrect in schema"
1671
+
1672
+ async def test_tool_includes_function_docstring(self, fastapi_server):
1673
+ """Test that a Tool includes the function docstring."""
1674
+ tools = fastapi_server._tool_manager.list_tools()
1675
+ create_tool = next(
1676
+ (t for t in tools if "create_item_items_post" == t.name), None
1677
+ )
1678
+
1679
+ assert create_tool is not None, "POST /items tool wasn't created"
1680
+ description = create_tool.description or ""
1681
+ assert "FUNCTION_CREATE_DESCRIPTION" in description, (
1682
+ "Function docstring missing from Tool"
1683
+ )
1684
+
1685
+ async def test_tool_parameter_schema_includes_property_description(
1686
+ self, fastapi_server
1687
+ ):
1688
+ """Test that a Tool's parameter schema includes property descriptions from request model.
1689
+
1690
+ Note: Currently, model field descriptions defined in Pydantic models using Field(description=...)
1691
+ may not be consistently propagated into the FastAPI OpenAPI schema and thus not into the tool's
1692
+ parameter schema.
1693
+ """
1694
+ tools = fastapi_server._tool_manager.list_tools()
1695
+ create_tool = next(
1696
+ (t for t in tools if "create_item_items_post" == t.name), None
1697
+ )
1698
+
1699
+ assert create_tool is not None, "POST /items tool wasn't created"
1700
+ assert "properties" in create_tool.parameters, (
1701
+ "Schema properties missing from Tool"
1702
+ )
1703
+ assert "name" in create_tool.parameters["properties"], (
1704
+ "name parameter missing from Tool schema"
1705
+ )
1706
+ # We don't test for the description field content as it may not be consistently propagated
1707
+
1708
+ async def test_client_api_resource_description(self, fastapi_server):
1709
+ """Test that Resource descriptions are accessible via the client API."""
1710
+ async with Client(fastapi_server) as client:
1711
+ resources = await client.list_resources()
1712
+ list_resource = next((r for r in resources if "items_get" in r.name), None)
1713
+
1714
+ assert list_resource is not None, (
1715
+ "GET /items resource not accessible via client API"
1716
+ )
1717
+ resource_description = list_resource.description or ""
1718
+ assert "FUNCTION_LIST_DESCRIPTION" in resource_description, (
1719
+ "Function docstring missing in Resource from client API"
1720
+ )
1721
+
1722
+ async def test_client_api_template_description(self, fastapi_server):
1723
+ """Test that ResourceTemplate descriptions are accessible via the client API."""
1724
+ async with Client(fastapi_server) as client:
1725
+ templates = await client.list_resource_templates()
1726
+ get_template = next(
1727
+ (t for t in templates if "items__item_id__get" in t.name), None
1728
+ )
1729
+
1730
+ assert get_template is not None, (
1731
+ "GET /items/{item_id} template not accessible via client API"
1732
+ )
1733
+ template_description = get_template.description or ""
1734
+ assert "FUNCTION_GET_DESCRIPTION" in template_description, (
1735
+ "Function docstring missing in ResourceTemplate from client API"
1736
+ )
1737
+
1738
+ async def test_client_api_tool_description(self, fastapi_server):
1739
+ """Test that Tool descriptions are accessible via the client API."""
1740
+ async with Client(fastapi_server) as client:
1741
+ tools = await client.list_tools()
1742
+ create_tool = next(
1743
+ (t for t in tools if "create_item_items_post" == t.name), None
1744
+ )
1745
+
1746
+ assert create_tool is not None, (
1747
+ "POST /items tool not accessible via client API"
1748
+ )
1749
+ tool_description = create_tool.description or ""
1750
+ assert "FUNCTION_CREATE_DESCRIPTION" in tool_description, (
1751
+ "Function docstring missing in Tool from client API"
1752
+ )
1753
+
1754
+ async def test_client_api_tool_parameter_schema(self, fastapi_server):
1755
+ """Test that Tool parameter schemas are accessible via the client API."""
1756
+ async with Client(fastapi_server) as client:
1757
+ tools = await client.list_tools()
1758
+ create_tool = next(
1759
+ (t for t in tools if "create_item_items_post" == t.name), None
1760
+ )
1761
+
1762
+ assert create_tool is not None, (
1763
+ "POST /items tool not accessible via client API"
1764
+ )
1765
+ assert "properties" in create_tool.inputSchema, (
1766
+ "Schema properties missing from Tool inputSchema in client API"
1767
+ )
1768
+ assert "name" in create_tool.inputSchema["properties"], (
1769
+ "name parameter missing from Tool schema in client API"
1770
+ )
1771
+ # We don't test for the description field content as it may not be consistently propagated