Jeremiah Lowin commited on
Commit
73cce75
·
unverified ·
2 Parent(s): d604e67a7bd2ea

Merge pull request #448 from jlowin/openapi-def

Browse files

Ensure openapi defs for structured objects are loaded properly

src/fastmcp/prompts/prompt.py CHANGED
@@ -13,7 +13,7 @@ from mcp.types import PromptArgument as MCPPromptArgument
13
  from pydantic import BaseModel, BeforeValidator, Field, TypeAdapter, validate_call
14
 
15
  from fastmcp.server.dependencies import get_context
16
- from fastmcp.utilities.json_schema import prune_params
17
  from fastmcp.utilities.logging import get_logger
18
  from fastmcp.utilities.types import (
19
  _convert_set_defaults,
@@ -115,7 +115,11 @@ class Prompt(BaseModel):
115
 
116
  context_kwarg = find_kwarg_by_type(fn, kwarg_type=Context)
117
  if context_kwarg:
118
- parameters = prune_params(parameters, params=[context_kwarg])
 
 
 
 
119
 
120
  # Convert parameters to PromptArguments
121
  arguments: list[PromptArgument] = []
 
13
  from pydantic import BaseModel, BeforeValidator, Field, TypeAdapter, validate_call
14
 
15
  from fastmcp.server.dependencies import get_context
16
+ from fastmcp.utilities.json_schema import compress_schema
17
  from fastmcp.utilities.logging import get_logger
18
  from fastmcp.utilities.types import (
19
  _convert_set_defaults,
 
115
 
116
  context_kwarg = find_kwarg_by_type(fn, kwarg_type=Context)
117
  if context_kwarg:
118
+ prune_params = [context_kwarg]
119
+ else:
120
+ prune_params = None
121
+
122
+ parameters = compress_schema(parameters, prune_params=prune_params)
123
 
124
  # Convert parameters to PromptArguments
125
  arguments: list[PromptArgument] = []
src/fastmcp/resources/template.py CHANGED
@@ -21,6 +21,7 @@ from pydantic import (
21
 
22
  from fastmcp.resources.types import FunctionResource, Resource
23
  from fastmcp.server.dependencies import get_context
 
24
  from fastmcp.utilities.types import (
25
  _convert_set_defaults,
26
  find_kwarg_by_type,
@@ -150,6 +151,10 @@ class ResourceTemplate(BaseModel):
150
  # Get schema from TypeAdapter - will fail if function isn't properly typed
151
  parameters = TypeAdapter(fn).json_schema()
152
 
 
 
 
 
153
  # ensure the arguments are properly cast
154
  fn = validate_call(fn)
155
 
 
21
 
22
  from fastmcp.resources.types import FunctionResource, Resource
23
  from fastmcp.server.dependencies import get_context
24
+ from fastmcp.utilities.json_schema import compress_schema
25
  from fastmcp.utilities.types import (
26
  _convert_set_defaults,
27
  find_kwarg_by_type,
 
151
  # Get schema from TypeAdapter - will fail if function isn't properly typed
152
  parameters = TypeAdapter(fn).json_schema()
153
 
154
+ # compress the schema
155
+ prune_params = [context_kwarg] if context_kwarg else None
156
+ parameters = compress_schema(parameters, prune_params=prune_params)
157
+
158
  # ensure the arguments are properly cast
159
  fn = validate_call(fn)
160
 
src/fastmcp/tools/tool.py CHANGED
@@ -12,7 +12,7 @@ from pydantic import BaseModel, BeforeValidator, Field
12
 
13
  import fastmcp
14
  from fastmcp.server.dependencies import get_context
15
- from fastmcp.utilities.json_schema import prune_params
16
  from fastmcp.utilities.logging import get_logger
17
  from fastmcp.utilities.types import (
18
  Image,
@@ -81,7 +81,11 @@ class Tool(BaseModel):
81
 
82
  context_kwarg = find_kwarg_by_type(fn, kwarg_type=Context)
83
  if context_kwarg:
84
- schema = prune_params(schema, params=[context_kwarg])
 
 
 
 
85
 
86
  return cls(
87
  fn=fn,
 
12
 
13
  import fastmcp
14
  from fastmcp.server.dependencies import get_context
15
+ from fastmcp.utilities.json_schema import compress_schema
16
  from fastmcp.utilities.logging import get_logger
17
  from fastmcp.utilities.types import (
18
  Image,
 
81
 
82
  context_kwarg = find_kwarg_by_type(fn, kwarg_type=Context)
83
  if context_kwarg:
84
+ prune_params = [context_kwarg]
85
+ else:
86
+ prune_params = None
87
+
88
+ schema = compress_schema(schema, prune_params=prune_params)
89
 
90
  return cls(
91
  fn=fn,
src/fastmcp/utilities/json_schema.py CHANGED
@@ -14,6 +14,7 @@ def _prune_param(schema: dict, param: str) -> dict:
14
  removed = props.pop(param, None)
15
  if removed is None: # nothing to do
16
  return schema
 
17
  # Keep empty properties object rather than removing it entirely
18
  schema["properties"] = props
19
  if param in schema.get("required", []):
@@ -21,7 +22,12 @@ def _prune_param(schema: dict, param: str) -> dict:
21
  if not schema["required"]:
22
  schema.pop("required")
23
 
24
- # ── 2. collect all remaining local $ref targets ───────────────────
 
 
 
 
 
25
  used_defs: set[str] = set()
26
 
27
  def walk(node: object) -> None: # depth-first traversal
@@ -37,7 +43,8 @@ def _prune_param(schema: dict, param: str) -> dict:
37
 
38
  walk(schema)
39
 
40
- # ── 3. remove orphaned definitions ────────────────────────────────
 
41
  defs = schema.get("$defs", {})
42
  for def_name in list(defs):
43
  if def_name not in used_defs:
@@ -48,12 +55,28 @@ def _prune_param(schema: dict, param: str) -> dict:
48
  return schema
49
 
50
 
51
- def prune_params(schema: dict, params: list[str]) -> dict:
 
 
 
 
 
 
 
 
 
 
 
 
52
  """
53
  Remove the given parameters from the schema.
54
 
55
  """
56
  schema = copy.deepcopy(schema)
57
- for param in params:
58
  schema = _prune_param(schema, param=param)
 
 
 
 
59
  return schema
 
14
  removed = props.pop(param, None)
15
  if removed is None: # nothing to do
16
  return schema
17
+
18
  # Keep empty properties object rather than removing it entirely
19
  schema["properties"] = props
20
  if param in schema.get("required", []):
 
22
  if not schema["required"]:
23
  schema.pop("required")
24
 
25
+ return schema
26
+
27
+
28
+ def _prune_unused_defs(schema: dict) -> dict:
29
+ """Remove unused definitions from the schema."""
30
+ # collect all remaining local $ref targets
31
  used_defs: set[str] = set()
32
 
33
  def walk(node: object) -> None: # depth-first traversal
 
43
 
44
  walk(schema)
45
 
46
+ # remove orphaned definitions
47
+
48
  defs = schema.get("$defs", {})
49
  for def_name in list(defs):
50
  if def_name not in used_defs:
 
55
  return schema
56
 
57
 
58
+ def _prune_additional_properties(schema: dict) -> dict:
59
+ """Remove additionalProperties from the schema if it is False."""
60
+ if schema.get("additionalProperties", None) is False:
61
+ schema.pop("additionalProperties")
62
+ return schema
63
+
64
+
65
+ def compress_schema(
66
+ schema: dict,
67
+ prune_params: list[str] | None = None,
68
+ prune_defs: bool = True,
69
+ prune_additional_properties: bool = True,
70
+ ) -> dict:
71
  """
72
  Remove the given parameters from the schema.
73
 
74
  """
75
  schema = copy.deepcopy(schema)
76
+ for param in prune_params or []:
77
  schema = _prune_param(schema, param=param)
78
+ if prune_defs:
79
+ schema = _prune_unused_defs(schema)
80
+ if prune_additional_properties:
81
+ schema = _prune_additional_properties(schema)
82
  return schema
src/fastmcp/utilities/openapi.py CHANGED
@@ -84,6 +84,9 @@ class HTTPRoute(BaseModel):
84
  responses: dict[str, ResponseInfo] = Field(
85
  default_factory=dict
86
  ) # Key: status code str
 
 
 
87
 
88
 
89
  # Export public symbols
@@ -221,6 +224,27 @@ class OpenAPI31Parser(BaseOpenAPIParser):
221
  logger.warning("OpenAPI schema has no paths defined.")
222
  return []
223
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
224
  for path_str, path_item_obj in self.openapi.paths.items():
225
  if not isinstance(path_item_obj, PathItem):
226
  logger.warning(
@@ -269,6 +293,7 @@ class OpenAPI31Parser(BaseOpenAPIParser):
269
  parameters=parameters,
270
  request_body=request_body_info,
271
  responses=responses,
 
272
  )
273
  routes.append(route)
274
  logger.info(
@@ -386,16 +411,36 @@ class OpenAPI31Parser(BaseOpenAPIParser):
386
 
387
  param_schema_dict = {}
388
  if param_schema_obj: # Check if schema exists
 
 
389
  param_schema_dict = self._extract_schema_as_dict(param_schema_obj)
 
 
 
 
 
 
 
 
390
  elif parameter.content:
391
  # Handle complex parameters with 'content'
392
  first_media_type = next(iter(parameter.content.values()), None)
393
  if (
394
  first_media_type and first_media_type.media_type_schema
395
  ): # CORRECTED: Use 'media_type_schema'
396
- param_schema_dict = self._extract_schema_as_dict(
397
- first_media_type.media_type_schema
398
- )
 
 
 
 
 
 
 
 
 
 
399
  logger.debug(
400
  f"Parameter '{parameter.name}' using schema from 'content' field."
401
  )
@@ -543,6 +588,27 @@ class OpenAPI30Parser(BaseOpenAPIParser):
543
  logger.warning("OpenAPI schema has no paths defined.")
544
  return []
545
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
546
  for path_str, path_item_obj in self.openapi.paths.items():
547
  if not isinstance(path_item_obj, PathItem_30):
548
  logger.warning(
@@ -593,6 +659,7 @@ class OpenAPI30Parser(BaseOpenAPIParser):
593
  parameters=parameters,
594
  request_body=request_body_info,
595
  responses=responses,
 
596
  )
597
  routes.append(route)
598
  logger.info(
@@ -711,14 +778,34 @@ class OpenAPI30Parser(BaseOpenAPIParser):
711
 
712
  param_schema_dict = {}
713
  if param_schema_obj: # Check if schema exists
 
 
714
  param_schema_dict = self._extract_schema_as_dict(param_schema_obj)
 
 
 
 
 
 
 
 
715
  elif parameter.content:
716
  # Handle complex parameters with 'content'
717
  first_media_type = next(iter(parameter.content.values()), None)
718
  if first_media_type and first_media_type.media_type_schema:
719
- param_schema_dict = self._extract_schema_as_dict(
720
- first_media_type.media_type_schema
721
- )
 
 
 
 
 
 
 
 
 
 
722
  logger.debug(
723
  f"Parameter '{parameter.name}' using schema from 'content' field."
724
  )
@@ -1173,6 +1260,23 @@ def _combine_schemas(route: openapi.HTTPRoute) -> dict[str, Any]:
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
@@ -1193,8 +1297,19 @@ def _combine_schemas(route: openapi.HTTPRoute) -> dict[str, Any]:
1193
  if route.request_body.required:
1194
  required.extend(body_schema.get("required", []))
1195
 
1196
- return {
1197
  "type": "object",
1198
  "properties": properties,
1199
  "required": required,
1200
  }
 
 
 
 
 
 
 
 
 
 
 
 
84
  responses: dict[str, ResponseInfo] = Field(
85
  default_factory=dict
86
  ) # Key: status code str
87
+ schema_definitions: dict[str, JsonSchema] = Field(
88
+ default_factory=dict
89
+ ) # Store component schemas
90
 
91
 
92
  # Export public symbols
 
224
  logger.warning("OpenAPI schema has no paths defined.")
225
  return []
226
 
227
+ # Extract component schemas to add to each route
228
+ schema_definitions = {}
229
+ if hasattr(self.openapi, "components") and self.openapi.components:
230
+ components = self.openapi.components
231
+ if hasattr(components, "schemas") and components.schemas:
232
+ for name, schema in components.schemas.items():
233
+ try:
234
+ if isinstance(schema, Reference):
235
+ resolved_schema = self._resolve_ref(schema)
236
+ schema_definitions[name] = self._extract_schema_as_dict(
237
+ resolved_schema
238
+ )
239
+ else:
240
+ schema_definitions[name] = self._extract_schema_as_dict(
241
+ schema
242
+ )
243
+ except Exception as e:
244
+ logger.warning(
245
+ f"Failed to extract schema definition '{name}': {e}"
246
+ )
247
+
248
  for path_str, path_item_obj in self.openapi.paths.items():
249
  if not isinstance(path_item_obj, PathItem):
250
  logger.warning(
 
293
  parameters=parameters,
294
  request_body=request_body_info,
295
  responses=responses,
296
+ schema_definitions=schema_definitions,
297
  )
298
  routes.append(route)
299
  logger.info(
 
411
 
412
  param_schema_dict = {}
413
  if param_schema_obj: # Check if schema exists
414
+ # Resolve the schema if it's a reference
415
+ resolved_schema = self._resolve_ref(param_schema_obj)
416
  param_schema_dict = self._extract_schema_as_dict(param_schema_obj)
417
+
418
+ # Ensure default value is preserved from resolved schema
419
+ if (
420
+ not isinstance(resolved_schema, Reference)
421
+ and hasattr(resolved_schema, "default")
422
+ and resolved_schema.default is not None
423
+ ):
424
+ param_schema_dict["default"] = resolved_schema.default
425
  elif parameter.content:
426
  # Handle complex parameters with 'content'
427
  first_media_type = next(iter(parameter.content.values()), None)
428
  if (
429
  first_media_type and first_media_type.media_type_schema
430
  ): # CORRECTED: Use 'media_type_schema'
431
+ # Resolve the schema if it's a reference
432
+ media_schema = first_media_type.media_type_schema
433
+ resolved_media_schema = self._resolve_ref(media_schema)
434
+ param_schema_dict = self._extract_schema_as_dict(media_schema)
435
+
436
+ # Ensure default value is preserved from resolved schema
437
+ if (
438
+ not isinstance(resolved_media_schema, Reference)
439
+ and hasattr(resolved_media_schema, "default")
440
+ and resolved_media_schema.default is not None
441
+ ):
442
+ param_schema_dict["default"] = resolved_media_schema.default
443
+
444
  logger.debug(
445
  f"Parameter '{parameter.name}' using schema from 'content' field."
446
  )
 
588
  logger.warning("OpenAPI schema has no paths defined.")
589
  return []
590
 
591
+ # Extract component schemas to add to each route
592
+ schema_definitions = {}
593
+ if hasattr(self.openapi, "components") and self.openapi.components:
594
+ components = self.openapi.components
595
+ if hasattr(components, "schemas") and components.schemas:
596
+ for name, schema in components.schemas.items():
597
+ try:
598
+ if isinstance(schema, Reference_30):
599
+ resolved_schema = self._resolve_ref(schema)
600
+ schema_definitions[name] = self._extract_schema_as_dict(
601
+ resolved_schema
602
+ )
603
+ else:
604
+ schema_definitions[name] = self._extract_schema_as_dict(
605
+ schema
606
+ )
607
+ except Exception as e:
608
+ logger.warning(
609
+ f"Failed to extract schema definition '{name}': {e}"
610
+ )
611
+
612
  for path_str, path_item_obj in self.openapi.paths.items():
613
  if not isinstance(path_item_obj, PathItem_30):
614
  logger.warning(
 
659
  parameters=parameters,
660
  request_body=request_body_info,
661
  responses=responses,
662
+ schema_definitions=schema_definitions,
663
  )
664
  routes.append(route)
665
  logger.info(
 
778
 
779
  param_schema_dict = {}
780
  if param_schema_obj: # Check if schema exists
781
+ # Resolve the schema if it's a reference
782
+ resolved_schema = self._resolve_ref(param_schema_obj)
783
  param_schema_dict = self._extract_schema_as_dict(param_schema_obj)
784
+
785
+ # Ensure default value is preserved from resolved schema
786
+ if (
787
+ not isinstance(resolved_schema, Reference_30)
788
+ and hasattr(resolved_schema, "default")
789
+ and resolved_schema.default is not None
790
+ ):
791
+ param_schema_dict["default"] = resolved_schema.default
792
  elif parameter.content:
793
  # Handle complex parameters with 'content'
794
  first_media_type = next(iter(parameter.content.values()), None)
795
  if first_media_type and first_media_type.media_type_schema:
796
+ # Resolve the schema if it's a reference
797
+ media_schema = first_media_type.media_type_schema
798
+ resolved_media_schema = self._resolve_ref(media_schema)
799
+ param_schema_dict = self._extract_schema_as_dict(media_schema)
800
+
801
+ # Ensure default value is preserved from resolved schema
802
+ if (
803
+ not isinstance(resolved_media_schema, Reference_30)
804
+ and hasattr(resolved_media_schema, "default")
805
+ and resolved_media_schema.default is not None
806
+ ):
807
+ param_schema_dict["default"] = resolved_media_schema.default
808
+
809
  logger.debug(
810
  f"Parameter '{parameter.name}' using schema from 'content' field."
811
  )
 
1260
  # Copy the schema and add description if available
1261
  param_schema = param.schema_.copy() if isinstance(param.schema_, dict) else {}
1262
 
1263
+ # Convert #/components/schemas references to #/$defs references
1264
+ if isinstance(param_schema, dict) and "$ref" in param_schema:
1265
+ ref_path = param_schema["$ref"]
1266
+ if ref_path.startswith("#/components/schemas/"):
1267
+ schema_name = ref_path.split("/")[-1]
1268
+ param_schema["$ref"] = f"#/$defs/{schema_name}"
1269
+
1270
+ # Also handle anyOf, allOf, oneOf references
1271
+ for section in ["anyOf", "allOf", "oneOf"]:
1272
+ if section in param_schema and isinstance(param_schema[section], list):
1273
+ for i, item in enumerate(param_schema[section]):
1274
+ if isinstance(item, dict) and "$ref" in item:
1275
+ ref_path = item["$ref"]
1276
+ if ref_path.startswith("#/components/schemas/"):
1277
+ schema_name = ref_path.split("/")[-1]
1278
+ param_schema[section][i]["$ref"] = f"#/$defs/{schema_name}"
1279
+
1280
  # Add parameter description to schema if available and not already present
1281
  if param.description and not param_schema.get("description"):
1282
  param_schema["description"] = param.description
 
1297
  if route.request_body.required:
1298
  required.extend(body_schema.get("required", []))
1299
 
1300
+ result = {
1301
  "type": "object",
1302
  "properties": properties,
1303
  "required": required,
1304
  }
1305
+
1306
+ # Add schema definitions if available
1307
+ if route.schema_definitions:
1308
+ result["$defs"] = route.schema_definitions
1309
+
1310
+ # Use compress_schema to remove unused definitions
1311
+ from fastmcp.utilities.json_schema import compress_schema
1312
+
1313
+ result = compress_schema(result)
1314
+
1315
+ return result
tests/server/test_openapi.py CHANGED
@@ -1,6 +1,7 @@
1
  import base64
2
  import json
3
  import re
 
4
 
5
  import httpx
6
  import pytest
@@ -1817,3 +1818,75 @@ class TestReprMethods:
1817
  assert f"name={template.name!r}" in template_repr
1818
  assert "uri_template=" in template_repr
1819
  assert "path=" in template_repr
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  import base64
2
  import json
3
  import re
4
+ from enum import Enum
5
 
6
  import httpx
7
  import pytest
 
1818
  assert f"name={template.name!r}" in template_repr
1819
  assert "uri_template=" in template_repr
1820
  assert "path=" in template_repr
1821
+
1822
+
1823
+ class TestEnumHandling:
1824
+ """Tests for handling enum parameters in OpenAPI schemas."""
1825
+
1826
+ async def test_enum_parameter_schema(self):
1827
+ """Test that enum parameters are properly handled in tool parameter schemas."""
1828
+
1829
+ # Define an enum just like in example.py
1830
+ class QueryEnum(str, Enum):
1831
+ foo = "foo"
1832
+ bar = "bar"
1833
+ baz = "baz"
1834
+
1835
+ # Create a minimal FastAPI app with an endpoint using the enum
1836
+ app = FastAPI()
1837
+
1838
+ @app.post("/items/{item_id}")
1839
+ def read_item(
1840
+ item_id: int,
1841
+ query: QueryEnum | None = None,
1842
+ ):
1843
+ return {"item_id": item_id, "query": query}
1844
+
1845
+ # Create a client for the app
1846
+ client = AsyncClient(transport=ASGITransport(app=app), base_url="http://test")
1847
+
1848
+ # Create the FastMCPOpenAPI server from the app
1849
+ openapi_spec = app.openapi()
1850
+ server = FastMCPOpenAPI(
1851
+ openapi_spec=openapi_spec,
1852
+ client=client,
1853
+ name="Enum Test",
1854
+ )
1855
+
1856
+ # Get the tools from the server
1857
+ tools = server._tool_manager.list_tools()
1858
+
1859
+ # Find the read_item tool
1860
+ read_item_tool = next(
1861
+ (t for t in tools if t.name == "read_item_items__item_id__post"), None
1862
+ )
1863
+
1864
+ # Verify the tool exists
1865
+ assert read_item_tool is not None, "read_item tool wasn't created"
1866
+
1867
+ # Check that the parameters include the enum reference
1868
+ assert "properties" in read_item_tool.parameters
1869
+ assert "query" in read_item_tool.parameters["properties"]
1870
+
1871
+ # Check for the anyOf with $ref to the enum definition
1872
+ query_param = read_item_tool.parameters["properties"]["query"]
1873
+ assert "anyOf" in query_param
1874
+
1875
+ # Find the ref in the anyOf list
1876
+ ref_found = False
1877
+ for option in query_param["anyOf"]:
1878
+ if "$ref" in option and option["$ref"].startswith("#/$defs/QueryEnum"):
1879
+ ref_found = True
1880
+ break
1881
+
1882
+ assert ref_found, "Reference to enum definition not found in query parameter"
1883
+
1884
+ # Check that the $defs section exists and contains the enum definition
1885
+ assert "$defs" in read_item_tool.parameters
1886
+ assert "QueryEnum" in read_item_tool.parameters["$defs"]
1887
+
1888
+ # Verify the enum definition
1889
+ enum_def = read_item_tool.parameters["$defs"]["QueryEnum"]
1890
+ assert "enum" in enum_def
1891
+ assert enum_def["enum"] == ["foo", "bar", "baz"]
1892
+ assert enum_def["type"] == "string"
tests/utilities/test_json_schema.py CHANGED
@@ -1,110 +1,246 @@
1
- from fastmcp.utilities.json_schema import _prune_param, prune_params
2
-
3
-
4
- def test_prune_param_nonexistent():
5
- """Test pruning a parameter that doesn't exist."""
6
- schema = {"properties": {"foo": {"type": "string"}}}
7
- result = _prune_param(schema, "bar")
8
- assert result == schema # Schema should be unchanged
9
-
10
-
11
- def test_prune_param_exists():
12
- """Test pruning a parameter that exists."""
13
- schema = {"properties": {"foo": {"type": "string"}, "bar": {"type": "integer"}}}
14
- result = _prune_param(schema, "bar")
15
- assert result["properties"] == {"foo": {"type": "string"}}
16
-
17
-
18
- def test_prune_param_last_property():
19
- """Test pruning the only/last parameter, should leave empty properties object."""
20
- schema = {"properties": {"foo": {"type": "string"}}}
21
- result = _prune_param(schema, "foo")
22
- assert "properties" in result
23
- assert result["properties"] == {}
24
-
25
-
26
- def test_prune_param_from_required():
27
- """Test pruning a parameter that's in the required list."""
28
- schema = {
29
- "properties": {"foo": {"type": "string"}, "bar": {"type": "integer"}},
30
- "required": ["foo", "bar"],
31
- }
32
- result = _prune_param(schema, "bar")
33
- assert result["required"] == ["foo"]
34
-
35
-
36
- def test_prune_param_last_required():
37
- """Test pruning the last required parameter, should remove required field."""
38
- schema = {
39
- "properties": {"foo": {"type": "string"}, "bar": {"type": "integer"}},
40
- "required": ["foo"],
41
- }
42
- result = _prune_param(schema, "foo")
43
- assert "required" not in result
44
-
45
-
46
- def test_prune_param_with_refs():
47
- """Test pruning a parameter that has references in $defs."""
48
- schema = {
49
- "properties": {
50
- "foo": {"$ref": "#/$defs/foo_def"},
51
- "bar": {"$ref": "#/$defs/bar_def"},
52
- },
53
- "$defs": {
54
- "foo_def": {"type": "string"},
55
- "bar_def": {"type": "integer"},
56
- },
57
- }
58
- result = _prune_param(schema, "bar")
59
- assert "bar_def" not in result["$defs"]
60
- assert "foo_def" in result["$defs"]
61
-
62
-
63
- def test_prune_param_all_refs():
64
- """Test pruning all parameters with refs, should remove $defs."""
65
- schema = {
66
- "properties": {
67
- "foo": {"$ref": "#/$defs/foo_def"},
68
- },
69
- "$defs": {
70
- "foo_def": {"type": "string"},
71
- },
72
- }
73
- result = _prune_param(schema, "foo")
74
- assert "$defs" not in result
75
-
76
-
77
- def test_prune_params_multiple():
78
- """Test pruning multiple parameters at once."""
79
- schema = {
80
- "properties": {
81
- "foo": {"type": "string"},
82
- "bar": {"type": "integer"},
83
- "baz": {"type": "boolean"},
84
- },
85
- "required": ["foo", "bar"],
86
- }
87
- result = prune_params(schema, ["foo", "baz"])
88
- assert result["properties"] == {"bar": {"type": "integer"}}
89
- assert result["required"] == ["bar"]
90
-
91
-
92
- def test_prune_params_nested_refs():
93
- """Test pruning with nested references."""
94
- schema = {
95
- "properties": {
96
- "foo": {
97
- "type": "object",
98
- "properties": {"nested": {"$ref": "#/$defs/nested_def"}},
99
  },
100
- "bar": {"$ref": "#/$defs/bar_def"},
101
- },
102
- "$defs": {
103
- "nested_def": {"type": "string"},
104
- "bar_def": {"type": "integer"},
105
- },
106
- }
107
- # Removing foo should keep nested_def as it's not referenced anymore
108
- result = _prune_param(schema, "foo")
109
- assert "nested_def" not in result["$defs"]
110
- assert "bar_def" in result["$defs"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastmcp.utilities.json_schema import (
2
+ _prune_additional_properties,
3
+ _prune_param,
4
+ _prune_unused_defs,
5
+ compress_schema,
6
+ )
7
+
8
+
9
+ class TestPruneParam:
10
+ """Tests for the _prune_param function."""
11
+
12
+ def test_nonexistent(self):
13
+ """Test pruning a parameter that doesn't exist."""
14
+ schema = {"properties": {"foo": {"type": "string"}}}
15
+ result = _prune_param(schema, "bar")
16
+ assert result == schema # Schema should be unchanged
17
+
18
+ def test_exists(self):
19
+ """Test pruning a parameter that exists."""
20
+ schema = {"properties": {"foo": {"type": "string"}, "bar": {"type": "integer"}}}
21
+ result = _prune_param(schema, "bar")
22
+ assert result["properties"] == {"foo": {"type": "string"}}
23
+
24
+ def test_last_property(self):
25
+ """Test pruning the only/last parameter, should leave empty properties object."""
26
+ schema = {"properties": {"foo": {"type": "string"}}}
27
+ result = _prune_param(schema, "foo")
28
+ assert "properties" in result
29
+ assert result["properties"] == {}
30
+
31
+ def test_from_required(self):
32
+ """Test pruning a parameter that's in the required list."""
33
+ schema = {
34
+ "properties": {"foo": {"type": "string"}, "bar": {"type": "integer"}},
35
+ "required": ["foo", "bar"],
36
+ }
37
+ result = _prune_param(schema, "bar")
38
+ assert result["required"] == ["foo"]
39
+
40
+ def test_last_required(self):
41
+ """Test pruning the last required parameter, should remove required field."""
42
+ schema = {
43
+ "properties": {"foo": {"type": "string"}, "bar": {"type": "integer"}},
44
+ "required": ["foo"],
45
+ }
46
+ result = _prune_param(schema, "foo")
47
+ assert "required" not in result
48
+
49
+
50
+ class TestPruneUnusedDefs:
51
+ """Tests for the _prune_unused_defs function."""
52
+
53
+ def test_removes_unreferenced_defs(self):
54
+ """Test that unreferenced definitions are removed."""
55
+ schema = {
56
+ "properties": {
57
+ "foo": {"$ref": "#/$defs/foo_def"},
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
58
  },
59
+ "$defs": {
60
+ "foo_def": {"type": "string"},
61
+ "unused_def": {"type": "integer"},
62
+ },
63
+ }
64
+ result = _prune_unused_defs(schema)
65
+ assert "foo_def" in result["$defs"]
66
+ assert "unused_def" not in result["$defs"]
67
+
68
+ def test_nested_references_kept(self):
69
+ """Test that definitions referenced via nesting are kept."""
70
+ schema = {
71
+ "properties": {
72
+ "foo": {"$ref": "#/$defs/foo_def"},
73
+ },
74
+ "$defs": {
75
+ "foo_def": {
76
+ "type": "object",
77
+ "properties": {"nested": {"$ref": "#/$defs/nested_def"}},
78
+ },
79
+ "nested_def": {"type": "string"},
80
+ "unused_def": {"type": "integer"},
81
+ },
82
+ }
83
+ result = _prune_unused_defs(schema)
84
+ assert "foo_def" in result["$defs"]
85
+ assert "nested_def" in result["$defs"]
86
+ assert "unused_def" not in result["$defs"]
87
+
88
+ def test_array_references_kept(self):
89
+ """Test that definitions referenced in array items are kept."""
90
+ schema = {
91
+ "properties": {
92
+ "items": {"type": "array", "items": {"$ref": "#/$defs/item_def"}},
93
+ },
94
+ "$defs": {
95
+ "item_def": {"type": "string"},
96
+ "unused_def": {"type": "integer"},
97
+ },
98
+ }
99
+ result = _prune_unused_defs(schema)
100
+ assert "item_def" in result["$defs"]
101
+ assert "unused_def" not in result["$defs"]
102
+
103
+ def test_removes_defs_field_when_empty(self):
104
+ """Test that $defs field is removed when all definitions are unused."""
105
+ schema = {
106
+ "properties": {
107
+ "foo": {"type": "string"},
108
+ },
109
+ "$defs": {
110
+ "unused_def": {"type": "integer"},
111
+ },
112
+ }
113
+ result = _prune_unused_defs(schema)
114
+ assert "$defs" not in result
115
+
116
+
117
+ class TestPruneAdditionalProperties:
118
+ """Tests for the _prune_additional_properties function."""
119
+
120
+ def test_removes_when_false(self):
121
+ """Test that additionalProperties is removed when it's false."""
122
+ schema = {
123
+ "type": "object",
124
+ "properties": {"foo": {"type": "string"}},
125
+ "additionalProperties": False,
126
+ }
127
+ result = _prune_additional_properties(schema)
128
+ assert "additionalProperties" not in result
129
+
130
+ def test_keeps_when_true(self):
131
+ """Test that additionalProperties is kept when it's true."""
132
+ schema = {
133
+ "type": "object",
134
+ "properties": {"foo": {"type": "string"}},
135
+ "additionalProperties": True,
136
+ }
137
+ result = _prune_additional_properties(schema)
138
+ assert "additionalProperties" in result
139
+ assert result["additionalProperties"] is True
140
+
141
+ def test_keeps_when_object(self):
142
+ """Test that additionalProperties is kept when it's an object schema."""
143
+ schema = {
144
+ "type": "object",
145
+ "properties": {"foo": {"type": "string"}},
146
+ "additionalProperties": {"type": "string"},
147
+ }
148
+ result = _prune_additional_properties(schema)
149
+ assert "additionalProperties" in result
150
+ assert result["additionalProperties"] == {"type": "string"}
151
+
152
+
153
+ class TestCompressSchema:
154
+ """Tests for the compress_schema function."""
155
+
156
+ def test_prune_params(self):
157
+ """Test pruning parameters with compress_schema."""
158
+ schema = {
159
+ "properties": {
160
+ "foo": {"type": "string"},
161
+ "bar": {"type": "integer"},
162
+ "baz": {"type": "boolean"},
163
+ },
164
+ "required": ["foo", "bar"],
165
+ }
166
+ result = compress_schema(schema, prune_params=["foo", "baz"])
167
+ assert result["properties"] == {"bar": {"type": "integer"}}
168
+ assert result["required"] == ["bar"]
169
+
170
+ def test_prune_defs(self):
171
+ """Test pruning unused definitions with compress_schema."""
172
+ schema = {
173
+ "properties": {
174
+ "foo": {"$ref": "#/$defs/foo_def"},
175
+ "bar": {"type": "integer"},
176
+ },
177
+ "$defs": {
178
+ "foo_def": {"type": "string"},
179
+ "unused_def": {"type": "number"},
180
+ },
181
+ }
182
+ result = compress_schema(schema)
183
+ assert "foo_def" in result["$defs"]
184
+ assert "unused_def" not in result["$defs"]
185
+
186
+ def test_disable_prune_defs(self):
187
+ """Test disabling pruning of unused definitions."""
188
+ schema = {
189
+ "properties": {
190
+ "foo": {"$ref": "#/$defs/foo_def"},
191
+ "bar": {"type": "integer"},
192
+ },
193
+ "$defs": {
194
+ "foo_def": {"type": "string"},
195
+ "unused_def": {"type": "number"},
196
+ },
197
+ }
198
+ result = compress_schema(schema, prune_defs=False)
199
+ assert "foo_def" in result["$defs"]
200
+ assert "unused_def" in result["$defs"]
201
+
202
+ def test_pruning_additional_properties(self):
203
+ """Test pruning additionalProperties when False."""
204
+ schema = {
205
+ "type": "object",
206
+ "properties": {"foo": {"type": "string"}},
207
+ "additionalProperties": False,
208
+ }
209
+ result = compress_schema(schema)
210
+ assert "additionalProperties" not in result
211
+
212
+ def test_disable_pruning_additional_properties(self):
213
+ """Test disabling pruning of additionalProperties."""
214
+ schema = {
215
+ "type": "object",
216
+ "properties": {"foo": {"type": "string"}},
217
+ "additionalProperties": False,
218
+ }
219
+ result = compress_schema(schema, prune_additional_properties=False)
220
+ assert "additionalProperties" in result
221
+ assert result["additionalProperties"] is False
222
+
223
+ def test_combined_operations(self):
224
+ """Test all pruning operations together."""
225
+ schema = {
226
+ "type": "object",
227
+ "properties": {
228
+ "keep": {"type": "string"},
229
+ "remove": {"$ref": "#/$defs/remove_def"},
230
+ },
231
+ "required": ["keep", "remove"],
232
+ "additionalProperties": False,
233
+ "$defs": {
234
+ "remove_def": {"type": "string"},
235
+ "unused_def": {"type": "number"},
236
+ },
237
+ }
238
+ result = compress_schema(schema, prune_params=["remove"])
239
+ # Check that parameter was removed
240
+ assert "remove" not in result["properties"]
241
+ # Check that required list was updated
242
+ assert result["required"] == ["keep"]
243
+ # Check that unused definitions were removed
244
+ assert "$defs" not in result # Both defs should be gone
245
+ # Check that additionalProperties was removed
246
+ assert "additionalProperties" not in result
tests/utilities/test_typeadapter.py CHANGED
@@ -13,7 +13,7 @@ import annotated_types
13
  import pytest
14
  from pydantic import BaseModel, Field
15
 
16
- from fastmcp.utilities.json_schema import prune_params
17
  from fastmcp.utilities.types import get_cached_typeadapter
18
 
19
 
@@ -175,7 +175,7 @@ def test_skip_names():
175
  # Get schema and prune parameters
176
  type_adapter = get_cached_typeadapter(func_with_many_params)
177
  schema = type_adapter.json_schema()
178
- pruned_schema = prune_params(schema, params=["skip_this", "also_skip"])
179
 
180
  # Check that only the desired parameters remain
181
  assert "keep_this" in pruned_schema["properties"]
 
13
  import pytest
14
  from pydantic import BaseModel, Field
15
 
16
+ from fastmcp.utilities.json_schema import compress_schema
17
  from fastmcp.utilities.types import get_cached_typeadapter
18
 
19
 
 
175
  # Get schema and prune parameters
176
  type_adapter = get_cached_typeadapter(func_with_many_params)
177
  schema = type_adapter.json_schema()
178
+ pruned_schema = compress_schema(schema, prune_params=["skip_this", "also_skip"])
179
 
180
  # Check that only the desired parameters remain
181
  assert "keep_this" in pruned_schema["properties"]