schemashim 0.1.0__tar.gz
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-0.1.0/.gitignore +12 -0
- schemashim-0.1.0/CHANGELOG.md +21 -0
- schemashim-0.1.0/LICENSE +21 -0
- schemashim-0.1.0/PKG-INFO +170 -0
- schemashim-0.1.0/README.md +145 -0
- schemashim-0.1.0/pyproject.toml +46 -0
- schemashim-0.1.0/src/schemashim/__init__.py +12 -0
- schemashim-0.1.0/src/schemashim/__main__.py +3 -0
- schemashim-0.1.0/src/schemashim/cli.py +97 -0
- schemashim-0.1.0/src/schemashim/core.py +609 -0
- schemashim-0.1.0/src/schemashim/names.py +54 -0
- schemashim-0.1.0/src/schemashim/tools.py +123 -0
- schemashim-0.1.0/tests/test_cli.py +67 -0
- schemashim-0.1.0/tests/test_gemini.py +273 -0
- schemashim-0.1.0/tests/test_openai.py +125 -0
- schemashim-0.1.0/tests/test_pydantic.py +54 -0
- schemashim-0.1.0/tests/test_readme.py +26 -0
- schemashim-0.1.0/tests/test_tools.py +81 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0 (2026-11-10)
|
|
4
|
+
|
|
5
|
+
First release.
|
|
6
|
+
|
|
7
|
+
- `adapt(schema, "gemini" | "openai-strict")`: rewrites a tool's JSON Schema into the subset the
|
|
8
|
+
provider accepts and lists every change. Inlines `$ref`/`$defs`, turns null branches and type
|
|
9
|
+
lists into `nullable` (Gemini) or `[T, "null"]` (OpenAI), merges `allOf`, changes `oneOf` to
|
|
10
|
+
`anyOf`, closes objects and makes every property required for strict mode, infers missing
|
|
11
|
+
types, fixes `required` lists, drops empty enum values, and moves constraints the provider
|
|
12
|
+
rejects (`format`, `exclusiveMinimum`, `minLength`, `default`, regex lookarounds...) into the
|
|
13
|
+
description. Shapes the provider can't express (recursive schemas, free-form objects, arrays
|
|
14
|
+
without `items`) become JSON-string fields.
|
|
15
|
+
- `Shim.restore(args)`: maps the model's arguments back: decodes JSON-string fields, drops the
|
|
16
|
+
nulls strict mode forces for optional fields, turns `5.0` into `5` for integers.
|
|
17
|
+
- `adapt_tools(tools, target)` / `ToolKit`: adapts MCP `tools/list` entries or `mcp.types.Tool`
|
|
18
|
+
objects into Gemini `functionDeclarations` or OpenAI `tools`, with valid, unique tool names,
|
|
19
|
+
and maps calls back with `kit.restore(name, args)`.
|
|
20
|
+
- `tool_name(name, target)`: valid tool names for each provider.
|
|
21
|
+
- CLI: `schemashim check FILE` (exit 1 on problems, `--json`) and `schemashim fix FILE --target`.
|
schemashim-0.1.0/LICENSE
ADDED
|
@@ -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.
|
|
@@ -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,145 @@
|
|
|
1
|
+
# schemashim
|
|
2
|
+
|
|
3
|
+
Make MCP and agent tool schemas work on Gemini and OpenAI strict mode, then turn the model's
|
|
4
|
+
arguments back into what the tool expects.
|
|
5
|
+
|
|
6
|
+
Gemini and OpenAI's `strict: true` function calling accept only a subset of JSON Schema, so one
|
|
7
|
+
ordinary tool (a pydantic `$ref`, `Optional[int]`, an array without `items`, an `enum` with `""`,
|
|
8
|
+
an untyped field) makes **every** request fail with `400 INVALID_ARGUMENT` or `Invalid schema`.
|
|
9
|
+
schemashim rewrites a schema into the subset each provider accepts, says what it changed, and
|
|
10
|
+
undoes the changes on the arguments the model sends back.
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
pip install schemashim
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Python 3.9+, no dependencies.
|
|
17
|
+
|
|
18
|
+
## Fix one schema
|
|
19
|
+
|
|
20
|
+
```python
|
|
21
|
+
from schemashim import adapt
|
|
22
|
+
|
|
23
|
+
schema = {
|
|
24
|
+
"type": "object",
|
|
25
|
+
"$defs": {"Point": {"type": "object", "required": ["lat", "lon"],
|
|
26
|
+
"properties": {"lat": {"type": "number"}, "lon": {"type": "number"}}}},
|
|
27
|
+
"properties": {
|
|
28
|
+
"start": {"$ref": "#/$defs/Point"},
|
|
29
|
+
"limit": {"anyOf": [{"type": "integer"}, {"type": "null"}], "default": None},
|
|
30
|
+
"tags": {"type": "array"},
|
|
31
|
+
},
|
|
32
|
+
"required": ["start"],
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
shim = adapt(schema, "gemini") # or "openai-strict"
|
|
36
|
+
shim.schema # send this as the function's parameters
|
|
37
|
+
for change in shim.changes:
|
|
38
|
+
print(change)
|
|
39
|
+
# start: inlined $ref #/$defs/Point (references are not supported)
|
|
40
|
+
# limit: null branch of anyOf changed to nullable
|
|
41
|
+
# tags: array without an items type: passed as a JSON string
|
|
42
|
+
|
|
43
|
+
# When the model calls the tool, map its arguments back before calling the real tool:
|
|
44
|
+
shim.restore({"start": {"lat": 1, "lon": 2}, "tags": '["a", "b"]'})
|
|
45
|
+
# {'start': {'lat': 1, 'lon': 2}, 'tags': ['a', 'b']}
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
`shim.ok` is `True` when the original was already accepted as is; `check(schema, target)`
|
|
49
|
+
returns only the list of changes.
|
|
50
|
+
|
|
51
|
+
## Bridge MCP tools to Gemini or OpenAI
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
from schemashim import adapt_tools
|
|
55
|
+
|
|
56
|
+
tools = (await session.list_tools()).tools # mcp.types.Tool objects, or dicts
|
|
57
|
+
kit = adapt_tools(tools, "openai-strict") # or "gemini"
|
|
58
|
+
|
|
59
|
+
response = client.chat.completions.create(model=..., messages=..., tools=kit.declarations)
|
|
60
|
+
for call in response.choices[0].message.tool_calls or []:
|
|
61
|
+
name, args = kit.restore(call.function.name, json.loads(call.function.arguments))
|
|
62
|
+
result = await session.call_tool(name, args)
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
For Gemini, `kit.declarations` are `functionDeclarations`:
|
|
66
|
+
`types.Tool(function_declarations=kit.declarations)` in `google-genai`, or
|
|
67
|
+
`{"functionDeclarations": kit.declarations}` over REST. For the OpenAI Responses API use
|
|
68
|
+
`[{"type": "function", **t.function} for t in kit]`.
|
|
69
|
+
|
|
70
|
+
Tool names are fixed too: `github/search_issues` becomes `github_search_issues`, names over 64
|
|
71
|
+
characters get a short hash, and two tools never end up with the same name. `kit.restore` maps
|
|
72
|
+
the name back.
|
|
73
|
+
|
|
74
|
+
## What it changes
|
|
75
|
+
|
|
76
|
+
| Problem in the schema | Gemini | OpenAI strict |
|
|
77
|
+
| --- | --- | --- |
|
|
78
|
+
| `$ref` / `$defs` / `definitions` | inlined | inlined |
|
|
79
|
+
| Recursive `$ref` (a tree) | the recursive field is a JSON string | same |
|
|
80
|
+
| `anyOf: [X, {type: null}]`, `type: [X, "null"]`, OpenAPI `nullable` | `nullable: true` | `type: [X, "null"]` |
|
|
81
|
+
| `type: [A, B]`, `oneOf` | `anyOf` | `anyOf` |
|
|
82
|
+
| `allOf` | merged | merged |
|
|
83
|
+
| Optional properties | kept optional | made required and nullable (strict mode needs every property in `required`); `restore` drops the nulls |
|
|
84
|
+
| `additionalProperties` | removed | set to `false` on every object |
|
|
85
|
+
| Array without `items`, object without `properties`, untyped "any" field, tuple arrays | sent as a JSON string; `restore` decodes it | same |
|
|
86
|
+
| `enum` with `""` or non-strings | `""` dropped; number enums move to the description | kept |
|
|
87
|
+
| `const` | one-value `enum` | kept |
|
|
88
|
+
| Missing `type` | inferred from `properties`, `items`, `enum`, bounds | same |
|
|
89
|
+
| `required` naming undefined properties, `"required": true` on a property | fixed | fixed |
|
|
90
|
+
| Root not an object, root `anyOf` of objects | made an object / merged | same |
|
|
91
|
+
| `format`, `exclusiveMinimum`, `multipleOf`, `minLength`, `default`, regex lookarounds... that the target rejects | written into the description so the model still sees them | same |
|
|
92
|
+
| `$schema`, `$id`, `not`, `if`/`then`, `patternProperties`... | removed | removed |
|
|
93
|
+
|
|
94
|
+
`restore` also turns `5.0` back into `5` for integer fields (Gemini sends numbers as floats).
|
|
95
|
+
|
|
96
|
+
## In CI
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
schemashim check tools.json # exit 1 if Gemini or OpenAI strict would reject a tool
|
|
100
|
+
schemashim check tools.json --target gemini --json
|
|
101
|
+
schemashim fix tools.json --target openai-strict > tools.openai.json
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
The file can be a schema, one tool, a list of tools, a `tools/list` response or an
|
|
105
|
+
[mcptest](../mcptest/) snapshot, and `-` reads stdin. To check a running MCP server:
|
|
106
|
+
|
|
107
|
+
```bash
|
|
108
|
+
mcptest tools -- python my_server.py | schemashim check -
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
## Limits
|
|
112
|
+
|
|
113
|
+
- A JSON-string field is a workaround: the model sees "JSON-encoded value" instead of the real
|
|
114
|
+
shape, so describe what goes in it. It only happens where the provider cannot express the
|
|
115
|
+
shape at all.
|
|
116
|
+
- Strict mode can't express "optional": the model sends `null`, and `restore` removes it. If a
|
|
117
|
+
field was already nullable, a `null` is kept, because it may be meaningful.
|
|
118
|
+
- Merging a root `anyOf` makes fields that are not in every choice optional; the tool must still
|
|
119
|
+
validate which combination is valid.
|
|
120
|
+
- Gemini's newer `parametersJsonSchema` field accepts more of JSON Schema. schemashim targets the
|
|
121
|
+
classic `parameters` field that most clients and SDKs send, and its output is valid there too.
|
|
122
|
+
- Only local `#/$defs/...` and `#/definitions/...` references are followed; remote ones become
|
|
123
|
+
JSON-string fields.
|
|
124
|
+
- Provider rules change. If a schema still gets rejected, open an issue with the error text.
|
|
125
|
+
|
|
126
|
+
## Why this exists
|
|
127
|
+
|
|
128
|
+
Every MCP client and agent framework that talks to Gemini or OpenAI strict mode hits this and
|
|
129
|
+
writes its own partial sanitizer. A few of the reports:
|
|
130
|
+
|
|
131
|
+
- [google-gemini/gemini-cli#13326](https://github.com/google-gemini/gemini-cli/issues/13326): Gemini API rejects MCP tool schemas with `$defs` references
|
|
132
|
+
- [modelcontextprotocol/servers#1624](https://github.com/modelcontextprotocol/servers/issues/1624): mcp-server-fetch's schema can't be used with Gemini 2.5 Pro
|
|
133
|
+
- [Portkey-AI/gateway#1203](https://github.com/Portkey-AI/gateway/issues/1203): not converting JSON Schema to Gemini compatible tool definitions
|
|
134
|
+
- [chatboxai/chatbox#2606](https://github.com/chatboxai/chatbox/issues/2606): Gemini tool integration fails with `INVALID_ARGUMENT`
|
|
135
|
+
- [langchain-ai/langchain-google#535](https://github.com/langchain-ai/langchain-google/issues/535): "should be non-empty for OBJECT type"
|
|
136
|
+
- [BerriAI/litellm#18442](https://github.com/BerriAI/litellm/issues/18442): "functionDeclaration parameters schema should be of type OBJECT"
|
|
137
|
+
- [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
|
|
138
|
+
- [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
|
|
139
|
+
|
|
140
|
+
GitHub search finds over 300 issues titled "tool schema" + Gemini alone. The demand evidence
|
|
141
|
+
is in [`demand/`](../../demand/) (idea `tool-schema-compat`).
|
|
142
|
+
|
|
143
|
+
## License
|
|
144
|
+
|
|
145
|
+
MIT
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.27"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "schemashim"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "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"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
requires-python = ">=3.9"
|
|
13
|
+
authors = [{ name = "Sri Krishna Lingamaneni" }]
|
|
14
|
+
keywords = [
|
|
15
|
+
"json schema", "gemini", "vertex ai", "openai", "structured outputs", "strict mode",
|
|
16
|
+
"function calling", "tool calling", "mcp", "model context protocol", "invalid_argument",
|
|
17
|
+
"function declarations", "llm", "agents",
|
|
18
|
+
]
|
|
19
|
+
classifiers = [
|
|
20
|
+
"Development Status :: 4 - Beta",
|
|
21
|
+
"Intended Audience :: Developers",
|
|
22
|
+
"Operating System :: OS Independent",
|
|
23
|
+
"Programming Language :: Python :: 3",
|
|
24
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
25
|
+
"Topic :: Software Development :: Libraries",
|
|
26
|
+
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
|
27
|
+
]
|
|
28
|
+
dependencies = []
|
|
29
|
+
|
|
30
|
+
[project.optional-dependencies]
|
|
31
|
+
test = ["pytest>=7", "pydantic>=2"]
|
|
32
|
+
|
|
33
|
+
[project.scripts]
|
|
34
|
+
schemashim = "schemashim.cli:main"
|
|
35
|
+
|
|
36
|
+
[project.urls]
|
|
37
|
+
Homepage = "https://github.com/srikrishnaL/kitforge/tree/main/packages/schemashim"
|
|
38
|
+
Source = "https://github.com/srikrishnaL/kitforge"
|
|
39
|
+
Issues = "https://github.com/srikrishnaL/kitforge/issues"
|
|
40
|
+
Changelog = "https://github.com/srikrishnaL/kitforge/blob/main/packages/schemashim/CHANGELOG.md"
|
|
41
|
+
|
|
42
|
+
[tool.hatch.build.targets.wheel]
|
|
43
|
+
packages = ["src/schemashim"]
|
|
44
|
+
|
|
45
|
+
[tool.pytest.ini_options]
|
|
46
|
+
testpaths = ["tests"]
|
|
@@ -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
|
+
]
|
|
@@ -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
|