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 +12 -0
- schemashim/__main__.py +3 -0
- schemashim/cli.py +97 -0
- schemashim/core.py +609 -0
- schemashim/names.py +54 -0
- schemashim/tools.py +123 -0
- schemashim-0.1.0.dist-info/METADATA +170 -0
- schemashim-0.1.0.dist-info/RECORD +11 -0
- schemashim-0.1.0.dist-info/WHEEL +4 -0
- schemashim-0.1.0.dist-info/entry_points.txt +2 -0
- schemashim-0.1.0.dist-info/licenses/LICENSE +21 -0
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
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,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.
|