schemashim 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
schemashim/__init__.py ADDED
@@ -0,0 +1,12 @@
1
+ """schemashim: make MCP and agent tool schemas work on Gemini and OpenAI strict mode."""
2
+
3
+ from .core import GEMINI, OPENAI_STRICT, TARGETS, Change, Shim, adapt, check
4
+ from .names import tool_name
5
+ from .tools import AdaptedTool, ToolKit, adapt_tool, adapt_tools
6
+
7
+ __version__ = "0.1.0"
8
+
9
+ __all__ = [
10
+ "GEMINI", "OPENAI_STRICT", "TARGETS", "Change", "Shim", "adapt", "check", "tool_name",
11
+ "AdaptedTool", "ToolKit", "adapt_tool", "adapt_tools", "__version__",
12
+ ]
schemashim/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
schemashim/cli.py ADDED
@@ -0,0 +1,97 @@
1
+ """schemashim check / fix: find and fix tool schemas a provider would reject."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+ from typing import Any, List, Optional
9
+
10
+ from . import __version__
11
+ from .core import TARGETS, adapt
12
+ from .tools import ToolKit
13
+
14
+
15
+ def _load(path: str) -> Any:
16
+ try:
17
+ if path == "-":
18
+ return json.load(sys.stdin)
19
+ with open(path, encoding="utf-8") as f:
20
+ return json.load(f)
21
+ except OSError as exc:
22
+ raise SystemExit(f"schemashim: can't read {path}: {exc.strerror or exc}")
23
+ except ValueError as exc:
24
+ raise SystemExit(f"schemashim: {path} is not valid JSON: {exc}")
25
+
26
+
27
+ def _tools(data: Any) -> Optional[list]:
28
+ """The list of tools in ``data``, or None when ``data`` is a bare schema."""
29
+ if isinstance(data, list):
30
+ return data
31
+ if isinstance(data, dict):
32
+ result = data.get("result") if isinstance(data.get("result"), dict) else data
33
+ if isinstance(result.get("tools"), list):
34
+ return result["tools"]
35
+ if isinstance(result.get("tools"), dict): # mcptest snapshot: {"tools": {name: tool}}
36
+ return [{"name": k, **v} for k, v in result["tools"].items() if isinstance(v, dict)]
37
+ if "name" in data and ("inputSchema" in data or "input_schema" in data):
38
+ return [data]
39
+ return None
40
+
41
+
42
+ def main(argv: Optional[List[str]] = None) -> int:
43
+ parser = argparse.ArgumentParser(
44
+ prog="schemashim",
45
+ description="Find and fix JSON Schemas that Gemini or OpenAI strict mode reject. "
46
+ "FILE is a schema, a tool, a list of tools or a tools/list result "
47
+ "('-' reads stdin).")
48
+ parser.add_argument("--version", action="version", version=f"schemashim {__version__}")
49
+ sub = parser.add_subparsers(dest="command", required=True)
50
+ c = sub.add_parser("check", help="list what each target would reject; exit 1 if anything")
51
+ c.add_argument("file")
52
+ c.add_argument("--target", choices=TARGETS + ("all",), default="all")
53
+ c.add_argument("--json", action="store_true", help="print the findings as JSON")
54
+ f = sub.add_parser("fix", help="print the adapted schema or tool declarations")
55
+ f.add_argument("file")
56
+ f.add_argument("--target", choices=TARGETS, required=True)
57
+ args = parser.parse_args(argv)
58
+
59
+ data = _load(args.file)
60
+ tools = _tools(data)
61
+ try:
62
+ if args.command == "fix":
63
+ if tools is None:
64
+ out: Any = adapt(data, args.target).schema
65
+ else:
66
+ out = ToolKit(tools, args.target).declarations
67
+ json.dump(out, sys.stdout, indent=2)
68
+ sys.stdout.write("\n")
69
+ return 0
70
+
71
+ targets = TARGETS if args.target == "all" else (args.target,)
72
+ findings = []
73
+ for target in targets:
74
+ if tools is None:
75
+ findings += [{"target": target, "tool": None, "path": c.path,
76
+ "message": c.message} for c in adapt(data, target).changes]
77
+ else:
78
+ for tool in ToolKit(tools, target):
79
+ findings += [{"target": target, "tool": tool.original_name, "path": c.path,
80
+ "message": c.message} for c in tool.changes]
81
+ except (TypeError, ValueError) as exc:
82
+ print(f"schemashim: {exc}", file=sys.stderr)
83
+ return 2
84
+
85
+ if args.json:
86
+ json.dump(findings, sys.stdout, indent=2)
87
+ sys.stdout.write("\n")
88
+ else:
89
+ for item in findings:
90
+ where = f"{item['tool']}: " if item["tool"] else ""
91
+ print(f"[{item['target']}] {where}{item['path'] or '(root)'}: {item['message']}")
92
+ if findings:
93
+ n = len(findings)
94
+ print(f"{n} problem{'s' if n != 1 else ''} found; `schemashim fix` rewrites them.")
95
+ else:
96
+ print("OK: accepted as is by " + ", ".join(targets))
97
+ return 1 if findings else 0
schemashim/core.py ADDED
@@ -0,0 +1,609 @@
1
+ """Rewrite a JSON Schema so a model provider accepts it, and undo the rewrite on the arguments."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import copy
6
+ import json
7
+ import re
8
+ from dataclasses import dataclass, field
9
+ from typing import Any, Dict, List, Optional, Tuple
10
+
11
+ GEMINI = "gemini"
12
+ OPENAI_STRICT = "openai-strict"
13
+ TARGETS = (GEMINI, OPENAI_STRICT)
14
+
15
+ # Keywords each target accepts on a schema node. Anything else is removed, and constraints the
16
+ # model should still know about are written into the description instead.
17
+ _GEMINI_KEYS = {
18
+ "type", "format", "title", "description", "nullable", "enum", "maxItems", "minItems",
19
+ "properties", "required", "minProperties", "maxProperties", "minLength", "maxLength",
20
+ "pattern", "example", "anyOf", "propertyOrdering", "default", "items", "minimum", "maximum",
21
+ }
22
+ _OPENAI_KEYS = {
23
+ "type", "title", "description", "enum", "const", "properties", "required",
24
+ "additionalProperties", "items", "anyOf", "pattern", "format", "minimum", "maximum",
25
+ "exclusiveMinimum", "exclusiveMaximum", "multipleOf", "minItems", "maxItems",
26
+ }
27
+ _GEMINI_STRING_FORMATS = {"enum", "date-time"}
28
+ _GEMINI_NUMBER_FORMATS = {"int32", "int64", "float", "double"}
29
+ _OPENAI_FORMATS = {"date-time", "time", "date", "duration", "email", "hostname", "ipv4",
30
+ "ipv6", "uuid"}
31
+ # Keys dropped without a note: they carry no meaning for the model.
32
+ _SILENT = {"$schema", "$id", "$comment", "$anchor", "readOnly", "writeOnly", "deprecated",
33
+ "contentMediaType", "contentEncoding"}
34
+ _STRUCTURAL = {"$ref", "$defs", "definitions", "allOf", "anyOf", "oneOf", "type", "properties",
35
+ "required", "items", "prefixItems", "additionalProperties", "nullable", "const",
36
+ "enum"}
37
+ _LOOKAROUND = re.compile(r"\(\?<?[=!]|\\[1-9]|\\k<")
38
+
39
+ _PHRASES = {
40
+ "minLength": "at least {} character(s)",
41
+ "maxLength": "at most {} character(s)",
42
+ "pattern": "must match the regex {}",
43
+ "format": "format: {}",
44
+ "minimum": "at least {}",
45
+ "maximum": "at most {}",
46
+ "exclusiveMinimum": "greater than {}",
47
+ "exclusiveMaximum": "less than {}",
48
+ "multipleOf": "a multiple of {}",
49
+ "minItems": "at least {} item(s)",
50
+ "maxItems": "at most {} item(s)",
51
+ "uniqueItems": "items must be unique",
52
+ "minProperties": "at least {} key(s)",
53
+ "maxProperties": "at most {} key(s)",
54
+ "default": "default: {}",
55
+ "examples": "examples: {}",
56
+ "example": "example: {}",
57
+ }
58
+
59
+
60
+ @dataclass(frozen=True)
61
+ class Change:
62
+ """One thing the original schema had that the target would reject or ignore."""
63
+
64
+ path: str
65
+ message: str
66
+
67
+ def __str__(self) -> str:
68
+ return f"{self.path or '(root)'}: {self.message}"
69
+
70
+
71
+ @dataclass
72
+ class _Restore:
73
+ json: bool = False
74
+ drop_null: bool = False
75
+ integer: bool = False
76
+ props: Dict[str, "_Restore"] = field(default_factory=dict)
77
+ items: Optional["_Restore"] = None
78
+
79
+ def empty(self) -> bool:
80
+ return not (self.json or self.drop_null or self.integer or self.props or self.items)
81
+
82
+
83
+ @dataclass
84
+ class Shim:
85
+ """The adapted schema, what changed, and how to turn model arguments back."""
86
+
87
+ target: str
88
+ schema: Dict[str, Any]
89
+ changes: List[Change]
90
+ _restore: _Restore = field(repr=False, default_factory=_Restore)
91
+
92
+ @property
93
+ def ok(self) -> bool:
94
+ """True when the original schema was already accepted as it was."""
95
+ return not self.changes
96
+
97
+ def restore(self, args: Any) -> Any:
98
+ """Turn arguments the model sent for the adapted schema into arguments for the original.
99
+
100
+ Decodes values that were sent as JSON strings, drops the nulls strict mode forces the
101
+ model to send for optional fields, and turns 5.0 back into 5 for integer fields.
102
+ """
103
+ if args is None:
104
+ return {}
105
+ return _apply(args, self._restore)
106
+
107
+
108
+ def _apply(value: Any, node: Optional[_Restore]) -> Any:
109
+ if node is None or node.empty():
110
+ return value
111
+ if node.json:
112
+ if isinstance(value, str):
113
+ try:
114
+ return json.loads(value)
115
+ except ValueError:
116
+ return value
117
+ return value
118
+ if node.integer and isinstance(value, float) and value.is_integer():
119
+ return int(value)
120
+ if node.props and isinstance(value, dict):
121
+ out = {}
122
+ for key, val in value.items():
123
+ child = node.props.get(key)
124
+ if child is not None and child.drop_null and val is None:
125
+ continue
126
+ out[key] = _apply(val, child)
127
+ return out
128
+ if node.items is not None and isinstance(value, list):
129
+ return [_apply(v, node.items) for v in value]
130
+ return value
131
+
132
+
133
+ def adapt(schema: Any, target: str, *, max_depth: int = 10) -> Shim:
134
+ """Rewrite ``schema`` (a tool's input schema) so ``target`` accepts it.
135
+
136
+ ``target`` is ``"gemini"`` (Gemini API / Vertex AI function declarations) or
137
+ ``"openai-strict"`` (OpenAI function calling with ``strict: true``). The input is not
138
+ modified. Nested objects deeper than ``max_depth`` are passed as JSON strings.
139
+ """
140
+ if target not in TARGETS:
141
+ raise ValueError(f"unknown target {target!r}; use one of {', '.join(TARGETS)}")
142
+ if not isinstance(max_depth, int) or max_depth < 1:
143
+ raise ValueError("max_depth must be a positive integer")
144
+ return _Adapter(target, max_depth).run(schema)
145
+
146
+
147
+ def check(schema: Any, target: str) -> List[Change]:
148
+ """List what ``target`` would reject in ``schema``. Empty means it is accepted as is."""
149
+ return adapt(schema, target).changes
150
+
151
+
152
+ def _fmt(value: Any) -> str:
153
+ if isinstance(value, str):
154
+ return value
155
+ return json.dumps(value, sort_keys=True)
156
+
157
+
158
+ def _join(path: str, key: str) -> str:
159
+ return f"{path}.{key}" if path else key
160
+
161
+
162
+ class _Adapter:
163
+ def __init__(self, target: str, max_depth: int) -> None:
164
+ self.target = target
165
+ self.gemini = target == GEMINI
166
+ self.max_depth = max_depth
167
+ self.changes: List[Change] = []
168
+ self.defs: Dict[str, Any] = {}
169
+
170
+ def note(self, path: str, message: str) -> None:
171
+ change = Change(path, message)
172
+ if change not in self.changes:
173
+ self.changes.append(change)
174
+
175
+ # entry point -------------------------------------------------------------------------
176
+ def run(self, schema: Any) -> Shim:
177
+ if schema is None or schema is True or schema == {}:
178
+ schema = {"type": "object", "properties": {}}
179
+ if not isinstance(schema, dict):
180
+ raise TypeError("schema must be a JSON object (dict)")
181
+ schema = copy.deepcopy(schema)
182
+ for key in ("$defs", "definitions"):
183
+ defs = schema.get(key)
184
+ if isinstance(defs, dict):
185
+ for name, sub in defs.items():
186
+ self.defs[f"#/{key}/{name}"] = sub
187
+ root, stack = self.resolve(schema, "", ())
188
+ if not isinstance(root, dict) or root.get("__free__"):
189
+ self.note("", "the root schema can't be resolved: the tool gets no arguments")
190
+ root, stack = {"type": "object", "properties": {}}, ()
191
+ root = self.root_object(root)
192
+ out, restore = self.convert(root, "", stack, 0, root=True)
193
+ return Shim(self.target, out, self.changes, restore)
194
+
195
+ def root_object(self, s: Dict[str, Any]) -> Dict[str, Any]:
196
+ branches = s.get("anyOf") or s.get("oneOf")
197
+ if s.get("type") in (None, "object") and isinstance(branches, list) and branches:
198
+ objects = []
199
+ for b in branches:
200
+ b, _ = self.resolve(b, "", ())
201
+ if isinstance(b, dict) and (b.get("type") == "object" or "properties" in b):
202
+ objects.append(b)
203
+ if len(objects) == len(branches):
204
+ props: Dict[str, Any] = dict(s.get("properties") or {})
205
+ required = None
206
+ for b in objects:
207
+ for k, v in (b.get("properties") or {}).items():
208
+ props.setdefault(k, v)
209
+ r = set(b.get("required") or [])
210
+ required = r if required is None else required & r
211
+ merged = {k: v for k, v in s.items() if k not in ("anyOf", "oneOf")}
212
+ merged["type"] = "object"
213
+ merged["properties"] = props
214
+ merged["required"] = sorted((required or set()) | set(s.get("required") or []))
215
+ self.note("", "the root is a choice of objects, which tools can't use: merged "
216
+ "into one object (fields not in every choice became optional)")
217
+ return merged
218
+ if s.get("type") not in (None, "object") or (
219
+ "type" not in s and "properties" not in s and not branches):
220
+ if s.get("type") is not None:
221
+ self.note("", f"the root must be an object, not {_fmt(s.get('type'))}")
222
+ elif s:
223
+ self.note("", "the root has no type: made it an object")
224
+ keep = {k: v for k, v in s.items() if k in ("description", "title")}
225
+ return {"type": "object", "properties": {}, **keep}
226
+ return s
227
+
228
+ # $ref and allOf ----------------------------------------------------------------------
229
+ def resolve(self, s: Any, path: str, stack: Tuple[str, ...]):
230
+ """Follow $ref and merge allOf. Returns (schema, stack) or (None, stack) on a loop."""
231
+ if not isinstance(s, dict):
232
+ return s, stack
233
+ seen = 0
234
+ while isinstance(s, dict) and "$ref" in s:
235
+ ref = s["$ref"]
236
+ if ref in stack:
237
+ return None, stack + (ref,)
238
+ target = self.defs.get(ref) if isinstance(ref, str) else None
239
+ if target is None:
240
+ self.note(path, f"can't resolve $ref {_fmt(ref)}: passed as a JSON string")
241
+ return {"__free__": True, **{k: v for k, v in s.items() if k == "description"}}, stack
242
+ if self.gemini:
243
+ self.note(path, f"inlined $ref {ref} (references are not supported)")
244
+ siblings = {k: v for k, v in s.items() if k != "$ref"}
245
+ s = {**copy.deepcopy(target), **siblings}
246
+ stack = stack + (ref,)
247
+ seen += 1
248
+ if seen > 64:
249
+ return None, stack
250
+ if isinstance(s, dict) and isinstance(s.get("allOf"), list):
251
+ parts = []
252
+ for part in s["allOf"]:
253
+ part, stack2 = self.resolve(part, path, stack)
254
+ if part is None:
255
+ return None, stack2
256
+ if isinstance(part, dict):
257
+ parts.append(part)
258
+ merged = {k: v for k, v in s.items() if k != "allOf"}
259
+ for part in parts:
260
+ for k, v in part.items():
261
+ if k == "properties" and isinstance(v, dict):
262
+ merged.setdefault("properties", {})
263
+ for pk, pv in v.items():
264
+ merged["properties"].setdefault(pk, pv)
265
+ elif k == "required" and isinstance(v, list):
266
+ merged["required"] = list(dict.fromkeys((merged.get("required") or []) + v))
267
+ else:
268
+ merged.setdefault(k, v)
269
+ self.note(path, "merged allOf into one schema")
270
+ return self.resolve(merged, path, stack)
271
+ return s, stack
272
+
273
+ # one node ----------------------------------------------------------------------------
274
+ def json_string(self, s: Any, path: str, why: str) -> Tuple[Dict[str, Any], _Restore]:
275
+ self.note(path, f"{why}: passed as a JSON string")
276
+ desc = s.get("description") if isinstance(s, dict) else None
277
+ text = "JSON-encoded value"
278
+ out: Dict[str, Any] = {"type": "string",
279
+ "description": f"{desc} ({text})" if desc else text}
280
+ return out, _Restore(json=True)
281
+
282
+ def convert(self, s: Any, path: str, stack: Tuple[str, ...], depth: int,
283
+ root: bool = False, nullable: bool = False) -> Tuple[Dict[str, Any], _Restore]:
284
+ s, stack = self.resolve(s, path, stack)
285
+ if s is None:
286
+ return self.json_string({}, path, "recursive schema")
287
+ if s is True or s == {} or not isinstance(s, dict):
288
+ return self.json_string({}, path, "any value is allowed")
289
+ if s.get("__free__"):
290
+ return self.json_string(s, path, "unresolved reference")
291
+ if depth > self.max_depth:
292
+ return self.json_string(s, path, f"nested deeper than {self.max_depth} levels")
293
+
294
+ s = dict(s)
295
+ if "nullable" in s:
296
+ if s.pop("nullable") is True:
297
+ nullable = True
298
+ if not self.gemini:
299
+ self.note(path, "nullable (OpenAPI) rewritten as a null type")
300
+
301
+ # "type": ["string", "null"] and friends.
302
+ t = s.get("type")
303
+ if isinstance(t, list):
304
+ types = [x for x in t if isinstance(x, str)]
305
+ if "null" in types:
306
+ nullable = True
307
+ types = [x for x in types if x != "null"]
308
+ if self.gemini:
309
+ self.note(path, f"type list {_fmt(t)} rewritten (not supported)")
310
+ if len(types) == 1:
311
+ s["type"] = types[0]
312
+ elif not types:
313
+ s.pop("type")
314
+ return self.json_string(s, path, "the only type is null")
315
+ else:
316
+ s.pop("type")
317
+ s["anyOf"] = [{"type": x} for x in types]
318
+ elif t == "null":
319
+ return self.json_string(s, path, "the only type is null")
320
+
321
+ # oneOf / anyOf: drop the null branch, collapse single branches.
322
+ if "oneOf" in s:
323
+ branches = s.pop("oneOf")
324
+ self.note(path, "oneOf changed to anyOf")
325
+ s["anyOf"] = branches if "anyOf" not in s else s["anyOf"]
326
+ if isinstance(s.get("anyOf"), list):
327
+ branches = []
328
+ for b in s["anyOf"]:
329
+ rb, _ = self.resolve(b, path, stack)
330
+ if isinstance(rb, dict) and rb.get("type") == "null" and not (
331
+ set(rb) & (_STRUCTURAL - {"type"})):
332
+ nullable = True
333
+ if self.gemini:
334
+ self.note(path, "null branch of anyOf changed to nullable")
335
+ continue
336
+ branches.append(b)
337
+ if not branches:
338
+ s.pop("anyOf")
339
+ return self.json_string(s, path, "the only type is null")
340
+ if len(branches) == 1:
341
+ rest = {k: v for k, v in s.items() if k != "anyOf"}
342
+ inner, stack = self.resolve(branches[0], path, stack)
343
+ if inner is None:
344
+ return self.json_string(rest, path, "recursive schema")
345
+ if not isinstance(inner, dict) or inner is True:
346
+ return self.json_string(rest, path, "any value is allowed")
347
+ s = {**inner, **{k: v for k, v in rest.items() if k != "type"}}
348
+ s.pop("anyOf", None)
349
+ return self.convert(s, path, stack, depth, root=root, nullable=nullable)
350
+ s["anyOf"] = branches
351
+
352
+ if isinstance(s.get("anyOf"), list):
353
+ out, restore = self.convert_anyof(s, path, stack, depth)
354
+ else:
355
+ out, restore = self.convert_typed(s, path, stack, depth, root)
356
+ return self.nullable(out, nullable), restore
357
+
358
+ def nullable(self, out: Dict[str, Any], nullable: bool) -> Dict[str, Any]:
359
+ if not nullable:
360
+ return out
361
+ if self.gemini:
362
+ out["nullable"] = True
363
+ return out
364
+ return _openai_null(out)
365
+
366
+ def convert_anyof(self, s, path, stack, depth):
367
+ branches = []
368
+ for b in s["anyOf"]:
369
+ out, _ = self.convert(b, path, stack, depth + 1)
370
+ if out not in branches:
371
+ branches.append(out)
372
+ out: Dict[str, Any] = {"anyOf": branches}
373
+ extra = {k: v for k, v in s.items() if k != "anyOf"}
374
+ self.finish(extra, out, path)
375
+ return out, _Restore()
376
+
377
+ def convert_typed(self, s, path, stack, depth, root):
378
+ s = dict(s)
379
+ if "const" in s:
380
+ c = s["const"]
381
+ if self.gemini:
382
+ s.pop("const")
383
+ if isinstance(c, str):
384
+ s["enum"] = [c]
385
+ s.setdefault("type", "string")
386
+ self.note(path, "const changed to a one-value enum")
387
+ else:
388
+ self.add_note(s, f"must be {_fmt(c)}")
389
+ self.note(path, "const moved to the description")
390
+ t = s.get("type")
391
+ if t is None:
392
+ t = _infer(s)
393
+ if t is None:
394
+ return self.json_string(s, path, "no type")
395
+ s["type"] = t
396
+ self.note(path, f"missing type: set to {t}")
397
+ if t == "object":
398
+ return self.convert_object(s, path, stack, depth, root)
399
+ if t == "array":
400
+ return self.convert_array(s, path, stack, depth)
401
+ if t not in ("string", "number", "integer", "boolean"):
402
+ return self.json_string(s, path, f"unknown type {_fmt(t)}")
403
+ out: Dict[str, Any] = {"type": t}
404
+ self.finish(s, out, path)
405
+ if "enum" in s:
406
+ self.enum(s, out, t, path)
407
+ return out, _Restore(integer=(t == "integer"))
408
+
409
+ def enum(self, s, out, t, path):
410
+ values = s.get("enum")
411
+ if not isinstance(values, list):
412
+ s.pop("enum", None)
413
+ return
414
+ if self.gemini:
415
+ if t != "string":
416
+ s.pop("enum")
417
+ self.add_note(out, "one of: " + ", ".join(_fmt(v) for v in values))
418
+ self.note(path, f"{t} enum moved to the description (Gemini only "
419
+ "allows enums on strings)")
420
+ return
421
+ strings = [v for v in values if isinstance(v, str) and v != ""]
422
+ if len(strings) != len(values):
423
+ self.note(path, "removed empty or non-string enum values")
424
+ if strings:
425
+ out["enum"] = strings
426
+ s.pop("enum")
427
+ return
428
+ out["enum"] = values
429
+ s.pop("enum")
430
+
431
+ def convert_object(self, s, path, stack, depth, root):
432
+ props = s.get("properties")
433
+ props = props if isinstance(props, dict) else {}
434
+ required_in = s.get("required")
435
+ required = [r for r in required_in if isinstance(r, str)] \
436
+ if isinstance(required_in, list) else []
437
+ if required_in is not None and not isinstance(required_in, list):
438
+ self.note(path, "required must be a list of names: ignored")
439
+
440
+ # FastAPI / OpenAPI 2 style "required": true on the property itself.
441
+ cleaned = {}
442
+ for name, ps in props.items():
443
+ if isinstance(ps, dict) and isinstance(ps.get("required"), bool):
444
+ ps = dict(ps)
445
+ flag = ps.pop("required")
446
+ self.note(_join(path, name), "boolean required flag moved into the parent's "
447
+ "required list")
448
+ if flag and name not in required:
449
+ required.append(name)
450
+ cleaned[name] = ps
451
+ props = cleaned
452
+
453
+ missing = [r for r in required if r not in props]
454
+ if missing:
455
+ self.note(path, "required lists undefined properties: " + ", ".join(missing))
456
+ required = [r for r in required if r in props]
457
+
458
+ extra = s.get("additionalProperties")
459
+ if not props:
460
+ if root:
461
+ out: Dict[str, Any] = {"type": "object", "properties": {}}
462
+ if extra not in (None, False) or s.get("patternProperties"):
463
+ self.note(path, "free-form arguments can't be described: the tool gets "
464
+ "no arguments")
465
+ if not self.gemini:
466
+ out["required"] = []
467
+ out["additionalProperties"] = False
468
+ self.finish(s, out, path, skip={"additionalProperties", "patternProperties",
469
+ "propertyNames"})
470
+ return out, _Restore()
471
+ return self.json_string(s, path, "object without fixed properties")
472
+
473
+ out = {"type": "object", "properties": {}}
474
+ restore = _Restore()
475
+ for name, ps in props.items():
476
+ child_path = _join(path, name)
477
+ child, child_restore = self.convert(ps, child_path, stack, depth + 1)
478
+ if not self.gemini and name not in required:
479
+ if not _is_nullable(child):
480
+ child = _openai_null(child)
481
+ child_restore.drop_null = True
482
+ out["properties"][name] = child
483
+ if not child_restore.empty():
484
+ restore.props[name] = child_restore
485
+ optional = [n for n in props if n not in required]
486
+ if self.gemini:
487
+ if required:
488
+ out["required"] = required
489
+ if "additionalProperties" in s:
490
+ if extra not in (False, None):
491
+ self.note(path, "extra keys (additionalProperties) are not supported: "
492
+ "only the listed properties can be sent")
493
+ else:
494
+ self.note(path, "removed additionalProperties (not supported)")
495
+ else:
496
+ out["required"] = list(props)
497
+ out["additionalProperties"] = False
498
+ if optional:
499
+ self.note(path, "strict mode needs every property required: optional "
500
+ + ", ".join(optional) + " made nullable")
501
+ if extra is None:
502
+ self.note(path, "strict mode needs additionalProperties: false")
503
+ elif extra is not False:
504
+ self.note(path, "extra keys (additionalProperties) are not allowed in strict "
505
+ "mode: only the listed properties can be sent")
506
+ self.finish(s, out, path, skip={"additionalProperties"})
507
+ return out, restore
508
+
509
+ def convert_array(self, s, path, stack, depth):
510
+ items = s.get("items")
511
+ if "prefixItems" in s or isinstance(items, list):
512
+ return self.json_string(s, path, "tuple arrays are not supported")
513
+ if items is None or items is True or items == {}:
514
+ return self.json_string(s, path, "array without an items type")
515
+ child, child_restore = self.convert(items, path + "[]", stack, depth + 1)
516
+ out = {"type": "array", "items": child}
517
+ self.finish(s, out, path)
518
+ return out, _Restore(items=None if child_restore.empty() else child_restore)
519
+
520
+ # keywords ----------------------------------------------------------------------------
521
+ def finish(self, s, out, path, skip=frozenset()):
522
+ """Copy the keywords the target accepts; describe or drop the rest."""
523
+ allowed = _GEMINI_KEYS if self.gemini else _OPENAI_KEYS
524
+ t = out.get("type")
525
+ moved = []
526
+ for k, v in s.items():
527
+ if k in out or k in skip or k == "__free__":
528
+ continue
529
+ if k in _STRUCTURAL and k not in ("const",):
530
+ continue
531
+ if k in _SILENT:
532
+ continue
533
+ if k == "default" and v is None:
534
+ continue
535
+ if k == "format":
536
+ if self.gemini:
537
+ ok = (t == "string" and v in _GEMINI_STRING_FORMATS) or (
538
+ t in ("number", "integer") and v in _GEMINI_NUMBER_FORMATS)
539
+ else:
540
+ ok = t == "string" and v in _OPENAI_FORMATS
541
+ if ok:
542
+ out[k] = v
543
+ else:
544
+ moved.append((k, v))
545
+ continue
546
+ if k == "pattern" and not self.gemini and isinstance(v, str) and _LOOKAROUND.search(v):
547
+ moved.append((k, v))
548
+ continue
549
+ if k in allowed:
550
+ out[k] = v
551
+ elif k in _PHRASES:
552
+ moved.append((k, v))
553
+ elif k in ("description", "title"):
554
+ out[k] = v
555
+ else:
556
+ self.note(path, f"removed unsupported keyword {k}")
557
+ if moved:
558
+ self.add_note(out, "; ".join(_PHRASES[k].format(_fmt(v)) for k, v in moved))
559
+ self.note(path, "moved " + ", ".join(k for k, _ in moved) + " to the description "
560
+ "(not supported)")
561
+
562
+ @staticmethod
563
+ def add_note(out, text):
564
+ desc = out.get("description")
565
+ out["description"] = f"{desc} ({text})" if desc else text[0].upper() + text[1:]
566
+
567
+
568
+ def _infer(s: Dict[str, Any]) -> Optional[str]:
569
+ if "properties" in s or "additionalProperties" in s or "required" in s:
570
+ return "object"
571
+ if "items" in s or "prefixItems" in s:
572
+ return "array"
573
+ values = s.get("enum")
574
+ if isinstance(values, list) and values:
575
+ if all(isinstance(v, str) for v in values):
576
+ return "string"
577
+ if all(isinstance(v, bool) for v in values):
578
+ return "boolean"
579
+ if all(isinstance(v, int) and not isinstance(v, bool) for v in values):
580
+ return "integer"
581
+ if all(isinstance(v, (int, float)) and not isinstance(v, bool) for v in values):
582
+ return "number"
583
+ if any(k in s for k in ("minLength", "maxLength", "pattern")):
584
+ return "string"
585
+ if any(k in s for k in ("minimum", "maximum", "exclusiveMinimum", "exclusiveMaximum")):
586
+ return "number"
587
+ return None
588
+
589
+
590
+ def _is_nullable(out: Dict[str, Any]) -> bool:
591
+ t = out.get("type")
592
+ if isinstance(t, list) and "null" in t:
593
+ return True
594
+ return any(b.get("type") == "null" for b in out.get("anyOf") or [] if isinstance(b, dict))
595
+
596
+
597
+ def _openai_null(out: Dict[str, Any]) -> Dict[str, Any]:
598
+ if _is_nullable(out):
599
+ return out
600
+ if "anyOf" in out:
601
+ out["anyOf"] = out["anyOf"] + [{"type": "null"}]
602
+ return out
603
+ t = out.get("type")
604
+ if isinstance(t, str):
605
+ out["type"] = [t, "null"]
606
+ if isinstance(out.get("enum"), list) and None not in out["enum"]:
607
+ out["enum"] = out["enum"] + [None]
608
+ return out
609
+ return {"anyOf": [out, {"type": "null"}]}
schemashim/names.py ADDED
@@ -0,0 +1,54 @@
1
+ """Tool names each provider accepts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import re
7
+
8
+ from .core import GEMINI, OPENAI_STRICT, TARGETS
9
+
10
+ MAX_LEN = 64
11
+ _BAD = {
12
+ OPENAI_STRICT: re.compile(r"[^A-Za-z0-9_-]"),
13
+ GEMINI: re.compile(r"[^A-Za-z0-9_.:-]"),
14
+ }
15
+
16
+
17
+ def _digest(name: str) -> str:
18
+ return hashlib.sha1(name.encode("utf-8")).hexdigest()[:8]
19
+
20
+
21
+ def tool_name(name: str, target: str) -> str:
22
+ """Return ``name`` changed only as much as ``target`` needs.
23
+
24
+ OpenAI allows ``[A-Za-z0-9_-]{1,64}``; Gemini also allows ``.`` and ``:`` and the name must
25
+ start with a letter or underscore. Invalid characters become ``_``; names that had to change
26
+ in a lossy way (too long, or emptied) get a short hash of the original so two different
27
+ tools never end up with the same name.
28
+ """
29
+ if target not in TARGETS:
30
+ raise ValueError(f"unknown target {target!r}; use one of {', '.join(TARGETS)}")
31
+ if not isinstance(name, str):
32
+ raise TypeError("tool name must be a string")
33
+ out = _BAD[target].sub("_", name)
34
+ if target == GEMINI and not re.match(r"[A-Za-z_]", out[:1] or "0"):
35
+ out = "_" + out
36
+ if not out.strip("_"):
37
+ out = "tool_" + _digest(name)
38
+ if len(out) > MAX_LEN:
39
+ out = out[: MAX_LEN - 9] + "_" + _digest(name)
40
+ return out
41
+
42
+
43
+ def unique_name(name: str, target: str, taken: set) -> str:
44
+ """Like :func:`tool_name`, but different from every name in ``taken``."""
45
+ out = tool_name(name, target)
46
+ if out not in taken:
47
+ return out
48
+ base = out[: MAX_LEN - 9]
49
+ candidate = f"{base}_{_digest(name)}"
50
+ n = 2
51
+ while candidate in taken:
52
+ candidate = f"{base}_{_digest(name + '#' + str(n))}"
53
+ n += 1
54
+ return candidate
schemashim/tools.py ADDED
@@ -0,0 +1,123 @@
1
+ """Adapt whole MCP tools (name, description, inputSchema) for a provider."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from typing import Any, Dict, Iterable, List, Optional, Tuple
7
+
8
+ from .core import GEMINI, Change, Shim, adapt
9
+ from .names import unique_name
10
+
11
+
12
+ def _get(tool: Any, *keys: str) -> Any:
13
+ for key in keys:
14
+ if isinstance(tool, dict):
15
+ if key in tool:
16
+ return tool[key]
17
+ elif hasattr(tool, key):
18
+ return getattr(tool, key)
19
+ return None
20
+
21
+
22
+ @dataclass
23
+ class AdaptedTool:
24
+ """One tool, ready to send to the provider."""
25
+
26
+ name: str
27
+ original_name: str
28
+ description: str
29
+ target: str
30
+ shim: Shim
31
+ changes: List[Change] = field(default_factory=list)
32
+
33
+ @property
34
+ def parameters(self) -> Dict[str, Any]:
35
+ return self.shim.schema
36
+
37
+ @property
38
+ def function(self) -> Dict[str, Any]:
39
+ """``{"name", "description", "parameters"}`` (plus ``"strict": True`` for OpenAI).
40
+
41
+ For the OpenAI Responses API send ``{"type": "function", **tool.function}``.
42
+ """
43
+ out: Dict[str, Any] = {"name": self.name, "description": self.description}
44
+ if self.target == GEMINI:
45
+ if self.parameters.get("properties"):
46
+ out["parameters"] = self.parameters
47
+ return out
48
+ out["parameters"] = self.parameters
49
+ out["strict"] = True
50
+ return out
51
+
52
+ @property
53
+ def declaration(self) -> Dict[str, Any]:
54
+ """A Gemini ``functionDeclaration``, or an OpenAI Chat Completions ``tools`` entry."""
55
+ if self.target == GEMINI:
56
+ return self.function
57
+ return {"type": "function", "function": self.function}
58
+
59
+ def restore(self, args: Any) -> Any:
60
+ return self.shim.restore(args)
61
+
62
+
63
+ def adapt_tool(tool: Any, target: str, *, name: Optional[str] = None,
64
+ taken: Optional[set] = None, max_depth: int = 10) -> AdaptedTool:
65
+ """Adapt one MCP tool: a ``tools/list`` entry as a dict, or an ``mcp.types.Tool``."""
66
+ original = _get(tool, "name")
67
+ if not isinstance(original, str) or not original:
68
+ raise ValueError("tool has no name")
69
+ schema = _get(tool, "inputSchema", "input_schema", "parameters")
70
+ description = _get(tool, "description") or ""
71
+ shim = adapt(schema, target, max_depth=max_depth)
72
+ provider_name = name or unique_name(original, target, taken or set())
73
+ changes = list(shim.changes)
74
+ if provider_name != original:
75
+ changes.insert(0, Change("(name)", f"renamed to {provider_name}"))
76
+ return AdaptedTool(provider_name, original, description, target, shim, changes)
77
+
78
+
79
+ class ToolKit:
80
+ """A set of adapted tools that maps the model's calls back to the original tools."""
81
+
82
+ def __init__(self, tools: Iterable[Any], target: str, *, max_depth: int = 10) -> None:
83
+ if isinstance(tools, dict) and isinstance(tools.get("tools"), list):
84
+ tools = tools["tools"]
85
+ elif _get(tools, "tools") is not None and not isinstance(tools, (list, tuple)):
86
+ tools = _get(tools, "tools")
87
+ self.target = target
88
+ self.tools: List[AdaptedTool] = []
89
+ self._by_name: Dict[str, AdaptedTool] = {}
90
+ seen = set()
91
+ for tool in tools:
92
+ adapted = adapt_tool(tool, target, taken=set(self._by_name), max_depth=max_depth)
93
+ if adapted.original_name in seen:
94
+ raise ValueError(f"duplicate tool name {adapted.original_name!r}")
95
+ seen.add(adapted.original_name)
96
+ self.tools.append(adapted)
97
+ self._by_name[adapted.name] = adapted
98
+
99
+ def __iter__(self):
100
+ return iter(self.tools)
101
+
102
+ def __len__(self) -> int:
103
+ return len(self.tools)
104
+
105
+ @property
106
+ def declarations(self) -> List[Dict[str, Any]]:
107
+ return [t.declaration for t in self.tools]
108
+
109
+ def get(self, name: str) -> AdaptedTool:
110
+ try:
111
+ return self._by_name[name]
112
+ except KeyError:
113
+ raise KeyError(f"the model called an unknown tool {name!r}") from None
114
+
115
+ def restore(self, name: str, args: Any) -> Tuple[str, Any]:
116
+ """Map a call from the model to ``(original tool name, original-shaped arguments)``."""
117
+ tool = self.get(name)
118
+ return tool.original_name, tool.restore(args)
119
+
120
+
121
+ def adapt_tools(tools: Iterable[Any], target: str, *, max_depth: int = 10) -> ToolKit:
122
+ """Adapt a list of MCP tools (or a ``tools/list`` result) for ``target``."""
123
+ return ToolKit(tools, target, max_depth=max_depth)
@@ -0,0 +1,170 @@
1
+ Metadata-Version: 2.5
2
+ Name: schemashim
3
+ Version: 0.1.0
4
+ Summary: Fix MCP and agent tool JSON Schemas that Gemini and OpenAI strict mode reject ($ref, anyOf null, type lists, arrays without items, enums), and map the model's arguments back
5
+ Project-URL: Homepage, https://github.com/srikrishnaL/kitforge/tree/main/packages/schemashim
6
+ Project-URL: Source, https://github.com/srikrishnaL/kitforge
7
+ Project-URL: Issues, https://github.com/srikrishnaL/kitforge/issues
8
+ Project-URL: Changelog, https://github.com/srikrishnaL/kitforge/blob/main/packages/schemashim/CHANGELOG.md
9
+ Author: Sri Krishna Lingamaneni
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: agents,function calling,function declarations,gemini,invalid_argument,json schema,llm,mcp,model context protocol,openai,strict mode,structured outputs,tool calling,vertex ai
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3 :: Only
18
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
19
+ Classifier: Topic :: Software Development :: Libraries
20
+ Requires-Python: >=3.9
21
+ Provides-Extra: test
22
+ Requires-Dist: pydantic>=2; extra == 'test'
23
+ Requires-Dist: pytest>=7; extra == 'test'
24
+ Description-Content-Type: text/markdown
25
+
26
+ # schemashim
27
+
28
+ Make MCP and agent tool schemas work on Gemini and OpenAI strict mode, then turn the model's
29
+ arguments back into what the tool expects.
30
+
31
+ Gemini and OpenAI's `strict: true` function calling accept only a subset of JSON Schema, so one
32
+ ordinary tool (a pydantic `$ref`, `Optional[int]`, an array without `items`, an `enum` with `""`,
33
+ an untyped field) makes **every** request fail with `400 INVALID_ARGUMENT` or `Invalid schema`.
34
+ schemashim rewrites a schema into the subset each provider accepts, says what it changed, and
35
+ undoes the changes on the arguments the model sends back.
36
+
37
+ ```bash
38
+ pip install schemashim
39
+ ```
40
+
41
+ Python 3.9+, no dependencies.
42
+
43
+ ## Fix one schema
44
+
45
+ ```python
46
+ from schemashim import adapt
47
+
48
+ schema = {
49
+ "type": "object",
50
+ "$defs": {"Point": {"type": "object", "required": ["lat", "lon"],
51
+ "properties": {"lat": {"type": "number"}, "lon": {"type": "number"}}}},
52
+ "properties": {
53
+ "start": {"$ref": "#/$defs/Point"},
54
+ "limit": {"anyOf": [{"type": "integer"}, {"type": "null"}], "default": None},
55
+ "tags": {"type": "array"},
56
+ },
57
+ "required": ["start"],
58
+ }
59
+
60
+ shim = adapt(schema, "gemini") # or "openai-strict"
61
+ shim.schema # send this as the function's parameters
62
+ for change in shim.changes:
63
+ print(change)
64
+ # start: inlined $ref #/$defs/Point (references are not supported)
65
+ # limit: null branch of anyOf changed to nullable
66
+ # tags: array without an items type: passed as a JSON string
67
+
68
+ # When the model calls the tool, map its arguments back before calling the real tool:
69
+ shim.restore({"start": {"lat": 1, "lon": 2}, "tags": '["a", "b"]'})
70
+ # {'start': {'lat': 1, 'lon': 2}, 'tags': ['a', 'b']}
71
+ ```
72
+
73
+ `shim.ok` is `True` when the original was already accepted as is; `check(schema, target)`
74
+ returns only the list of changes.
75
+
76
+ ## Bridge MCP tools to Gemini or OpenAI
77
+
78
+ ```python
79
+ from schemashim import adapt_tools
80
+
81
+ tools = (await session.list_tools()).tools # mcp.types.Tool objects, or dicts
82
+ kit = adapt_tools(tools, "openai-strict") # or "gemini"
83
+
84
+ response = client.chat.completions.create(model=..., messages=..., tools=kit.declarations)
85
+ for call in response.choices[0].message.tool_calls or []:
86
+ name, args = kit.restore(call.function.name, json.loads(call.function.arguments))
87
+ result = await session.call_tool(name, args)
88
+ ```
89
+
90
+ For Gemini, `kit.declarations` are `functionDeclarations`:
91
+ `types.Tool(function_declarations=kit.declarations)` in `google-genai`, or
92
+ `{"functionDeclarations": kit.declarations}` over REST. For the OpenAI Responses API use
93
+ `[{"type": "function", **t.function} for t in kit]`.
94
+
95
+ Tool names are fixed too: `github/search_issues` becomes `github_search_issues`, names over 64
96
+ characters get a short hash, and two tools never end up with the same name. `kit.restore` maps
97
+ the name back.
98
+
99
+ ## What it changes
100
+
101
+ | Problem in the schema | Gemini | OpenAI strict |
102
+ | --- | --- | --- |
103
+ | `$ref` / `$defs` / `definitions` | inlined | inlined |
104
+ | Recursive `$ref` (a tree) | the recursive field is a JSON string | same |
105
+ | `anyOf: [X, {type: null}]`, `type: [X, "null"]`, OpenAPI `nullable` | `nullable: true` | `type: [X, "null"]` |
106
+ | `type: [A, B]`, `oneOf` | `anyOf` | `anyOf` |
107
+ | `allOf` | merged | merged |
108
+ | Optional properties | kept optional | made required and nullable (strict mode needs every property in `required`); `restore` drops the nulls |
109
+ | `additionalProperties` | removed | set to `false` on every object |
110
+ | Array without `items`, object without `properties`, untyped "any" field, tuple arrays | sent as a JSON string; `restore` decodes it | same |
111
+ | `enum` with `""` or non-strings | `""` dropped; number enums move to the description | kept |
112
+ | `const` | one-value `enum` | kept |
113
+ | Missing `type` | inferred from `properties`, `items`, `enum`, bounds | same |
114
+ | `required` naming undefined properties, `"required": true` on a property | fixed | fixed |
115
+ | Root not an object, root `anyOf` of objects | made an object / merged | same |
116
+ | `format`, `exclusiveMinimum`, `multipleOf`, `minLength`, `default`, regex lookarounds... that the target rejects | written into the description so the model still sees them | same |
117
+ | `$schema`, `$id`, `not`, `if`/`then`, `patternProperties`... | removed | removed |
118
+
119
+ `restore` also turns `5.0` back into `5` for integer fields (Gemini sends numbers as floats).
120
+
121
+ ## In CI
122
+
123
+ ```bash
124
+ schemashim check tools.json # exit 1 if Gemini or OpenAI strict would reject a tool
125
+ schemashim check tools.json --target gemini --json
126
+ schemashim fix tools.json --target openai-strict > tools.openai.json
127
+ ```
128
+
129
+ The file can be a schema, one tool, a list of tools, a `tools/list` response or an
130
+ [mcptest](../mcptest/) snapshot, and `-` reads stdin. To check a running MCP server:
131
+
132
+ ```bash
133
+ mcptest tools -- python my_server.py | schemashim check -
134
+ ```
135
+
136
+ ## Limits
137
+
138
+ - A JSON-string field is a workaround: the model sees "JSON-encoded value" instead of the real
139
+ shape, so describe what goes in it. It only happens where the provider cannot express the
140
+ shape at all.
141
+ - Strict mode can't express "optional": the model sends `null`, and `restore` removes it. If a
142
+ field was already nullable, a `null` is kept, because it may be meaningful.
143
+ - Merging a root `anyOf` makes fields that are not in every choice optional; the tool must still
144
+ validate which combination is valid.
145
+ - Gemini's newer `parametersJsonSchema` field accepts more of JSON Schema. schemashim targets the
146
+ classic `parameters` field that most clients and SDKs send, and its output is valid there too.
147
+ - Only local `#/$defs/...` and `#/definitions/...` references are followed; remote ones become
148
+ JSON-string fields.
149
+ - Provider rules change. If a schema still gets rejected, open an issue with the error text.
150
+
151
+ ## Why this exists
152
+
153
+ Every MCP client and agent framework that talks to Gemini or OpenAI strict mode hits this and
154
+ writes its own partial sanitizer. A few of the reports:
155
+
156
+ - [google-gemini/gemini-cli#13326](https://github.com/google-gemini/gemini-cli/issues/13326): Gemini API rejects MCP tool schemas with `$defs` references
157
+ - [modelcontextprotocol/servers#1624](https://github.com/modelcontextprotocol/servers/issues/1624): mcp-server-fetch's schema can't be used with Gemini 2.5 Pro
158
+ - [Portkey-AI/gateway#1203](https://github.com/Portkey-AI/gateway/issues/1203): not converting JSON Schema to Gemini compatible tool definitions
159
+ - [chatboxai/chatbox#2606](https://github.com/chatboxai/chatbox/issues/2606): Gemini tool integration fails with `INVALID_ARGUMENT`
160
+ - [langchain-ai/langchain-google#535](https://github.com/langchain-ai/langchain-google/issues/535): "should be non-empty for OBJECT type"
161
+ - [BerriAI/litellm#18442](https://github.com/BerriAI/litellm/issues/18442): "functionDeclaration parameters schema should be of type OBJECT"
162
+ - [Kilo-Org/kilocode#15512](https://github.com/Kilo-Org/kilocode/issues/15512), [cline/cline#11162](https://github.com/cline/cline/issues/11162), [CherryHQ/cherry-studio#18890](https://github.com/CherryHQ/cherry-studio/issues/18890), [anomalyco/opencode#48073](https://github.com/anomalyco/opencode/issues/48073): empty enums, undefined required properties, arrays without items, nullable arrays
163
+ - [openclaw/openclaw#166877](https://github.com/openclaw/openclaw/issues/166877), [NousResearch/hermes-agent#104796](https://github.com/NousResearch/hermes-agent/issues/104796), [cloudflare/mcp-server-cloudflare#378](https://github.com/cloudflare/mcp-server-cloudflare/issues/378): one MCP tool breaks every strict-mode request
164
+
165
+ GitHub search finds over 300 issues titled "tool schema" + Gemini alone. The demand evidence
166
+ is in [`demand/`](../../demand/) (idea `tool-schema-compat`).
167
+
168
+ ## License
169
+
170
+ MIT
@@ -0,0 +1,11 @@
1
+ schemashim/__init__.py,sha256=3N9JGFDBNo5a7vywMnq-ZcK71wEgot8GRTOVC_SHAYA,464
2
+ schemashim/__main__.py,sha256=k1ocEWawweo1qCJWNFAAvyxz3tcY13dzvCenHszij30,48
3
+ schemashim/cli.py,sha256=INmFXX4Se4iOO4S887Lcfrg_YeENtOs__vnqzuOb-4U,3963
4
+ schemashim/core.py,sha256=cQeIWr4b2ETqHCnz8nFxo9Mm8L3ukg0q-c9ZrovHRVM,26328
5
+ schemashim/names.py,sha256=xGkt_CWPc_8wYF4oo1wjWxzdIZfoRQ-xjfZlWSpwAAg,1746
6
+ schemashim/tools.py,sha256=HS9u_fFfAi_aSL6nk5Tzxvkpg1BMVnSc-AHRAudndxk,4535
7
+ schemashim-0.1.0.dist-info/METADATA,sha256=f8AmvHD4NhWi1RXIyyogcbT6_j2eoQWFDcBvxms56Z8,9076
8
+ schemashim-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
9
+ schemashim-0.1.0.dist-info/entry_points.txt,sha256=NEut_u1MpvqnmaKffciSpMg_E91I5KVtL_H6C2LO-8Q,51
10
+ schemashim-0.1.0.dist-info/licenses/LICENSE,sha256=fl9U1idgeyJw-8GWneEYxVzZM_C29cBR63IAlNv7KjE,1080
11
+ schemashim-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ schemashim = schemashim.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Sri Krishna Lingamaneni
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.