schemashim
Make MCP and agent tool schemas work on Gemini and OpenAI strict mode, then turn the model's arguments back into what the tool expects.
Gemini and OpenAI's strict: true function calling accept only a subset of JSON Schema, so one
ordinary tool (a pydantic $ref, Optional[int], an array without items, an enum with "",
an untyped field) makes every request fail with 400 INVALID_ARGUMENT or Invalid schema.
schemashim rewrites a schema into the subset each provider accepts, says what it changed, and
undoes the changes on the arguments the model sends back.
pip install schemashim
Python 3.9+, no dependencies.
Fix one schema
from schemashim import adapt
schema = {
"type": "object",
"$defs": {"Point": {"type": "object", "required": ["lat", "lon"],
"properties": {"lat": {"type": "number"}, "lon": {"type": "number"}}}},
"properties": {
"start": {"$ref": "#/$defs/Point"},
"limit": {"anyOf": [{"type": "integer"}, {"type": "null"}], "default": None},
"tags": {"type": "array"},
},
"required": ["start"],
}
shim = adapt(schema, "gemini") # or "openai-strict"
shim.schema # send this as the function's parameters
for change in shim.changes:
print(change)
# start: inlined $ref #/$defs/Point (references are not supported)
# limit: null branch of anyOf changed to nullable
# tags: array without an items type: passed as a JSON string
# When the model calls the tool, map its arguments back before calling the real tool:
shim.restore({"start": {"lat": 1, "lon": 2}, "tags": '["a", "b"]'})
# {'start': {'lat': 1, 'lon': 2}, 'tags': ['a', 'b']}
shim.ok is True when the original was already accepted as is; check(schema, target)
returns only the list of changes.
Bridge MCP tools to Gemini or OpenAI
from schemashim import adapt_tools
tools = (await session.list_tools()).tools # mcp.types.Tool objects, or dicts
kit = adapt_tools(tools, "openai-strict") # or "gemini"
response = client.chat.completions.create(model=..., messages=..., tools=kit.declarations)
for call in response.choices[0].message.tool_calls or []:
name, args = kit.restore(call.function.name, json.loads(call.function.arguments))
result = await session.call_tool(name, args)
For Gemini, kit.declarations are functionDeclarations:
types.Tool(function_declarations=kit.declarations) in google-genai, or
{"functionDeclarations": kit.declarations} over REST. For the OpenAI Responses API use
[{"type": "function", **t.function} for t in kit].
Tool names are fixed too: github/search_issues becomes github_search_issues, names over 64
characters get a short hash, and two tools never end up with the same name. kit.restore maps
the name back.
What it changes
| Problem in the schema | Gemini | OpenAI strict |
|---|---|---|
$ref / $defs / definitions |
inlined | inlined |
Recursive $ref (a tree) |
the recursive field is a JSON string | same |
anyOf: [X, {type: null}], type: [X, "null"], OpenAPI nullable |
nullable: true |
type: [X, "null"] |
type: [A, B], oneOf |
anyOf |
anyOf |
allOf |
merged | merged |
| Optional properties | kept optional | made required and nullable (strict mode needs every property in required); restore drops the nulls |
additionalProperties |
removed | set to false on every object |
Array without items, object without properties, untyped "any" field, tuple arrays |
sent as a JSON string; restore decodes it |
same |
enum with "" or non-strings |
"" dropped; number enums move to the description |
kept |
const |
one-value enum |
kept |
Missing type |
inferred from properties, items, enum, bounds |
same |
required naming undefined properties, "required": true on a property |
fixed | fixed |
Root not an object, root anyOf of objects |
made an object / merged | same |
format, exclusiveMinimum, multipleOf, minLength, default, regex lookarounds... that the target rejects |
written into the description so the model still sees them | same |
$schema, $id, not, if/then, patternProperties... |
removed | removed |
restore also turns 5.0 back into 5 for integer fields (Gemini sends numbers as floats).
In CI
schemashim check tools.json # exit 1 if Gemini or OpenAI strict would reject a tool
schemashim check tools.json --target gemini --json
schemashim fix tools.json --target openai-strict > tools.openai.json
The file can be a schema, one tool, a list of tools, a tools/list response or an
mcptest snapshot, and - reads stdin. To check a running MCP server:
mcptest tools -- python my_server.py | schemashim check -
Limits
- A JSON-string field is a workaround: the model sees "JSON-encoded value" instead of the real shape, so describe what goes in it. It only happens where the provider cannot express the shape at all.
- Strict mode can't express "optional": the model sends
null, andrestoreremoves it. If a field was already nullable, anullis kept, because it may be meaningful. - Merging a root
anyOfmakes fields that are not in every choice optional; the tool must still validate which combination is valid. - Gemini's newer
parametersJsonSchemafield accepts more of JSON Schema. schemashim targets the classicparametersfield that most clients and SDKs send, and its output is valid there too. - Only local
#/$defs/...and#/definitions/...references are followed; remote ones become JSON-string fields. - Provider rules change. If a schema still gets rejected, open an issue with the error text.
Why this exists
Every MCP client and agent framework that talks to Gemini or OpenAI strict mode hits this and writes its own partial sanitizer. A few of the reports:
- google-gemini/gemini-cli#13326: Gemini API rejects MCP tool schemas with
$defsreferences - modelcontextprotocol/servers#1624: mcp-server-fetch's schema can't be used with Gemini 2.5 Pro
- Portkey-AI/gateway#1203: not converting JSON Schema to Gemini compatible tool definitions
- chatboxai/chatbox#2606: Gemini tool integration fails with
INVALID_ARGUMENT - langchain-ai/langchain-google#535: "should be non-empty for OBJECT type"
- BerriAI/litellm#18442: "functionDeclaration parameters schema should be of type OBJECT"
- Kilo-Org/kilocode#15512, cline/cline#11162, CherryHQ/cherry-studio#18890, anomalyco/opencode#48073: empty enums, undefined required properties, arrays without items, nullable arrays
- openclaw/openclaw#166877, NousResearch/hermes-agent#104796, cloudflare/mcp-server-cloudflare#378: one MCP tool breaks every strict-mode request
GitHub search finds over 300 issues titled "tool schema" + Gemini alone. The demand evidence
is in demand/ (idea tool-schema-compat).
License
MIT
Metadata
Release files for schemashim 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | Actions |
|---|---|---|---|---|
| schemashim-0.1.0.tar.gz | 20.8 kB | Without provenance | Details for schemashim-0.1.0.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| schemashim-0.1.0-py3-none-any.whl | Python 3 | none | any | Details for schemashim-0.1.0-py3-none-any.whl |
Total release size: 38.1 kB
Release files / schemashim-0.1.0.tar.gz
| Download URL | schemashim-0.1.0.tar.gz |
|---|---|
| Size | 20.8 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
1aaa3292d77646736e816844d8cb3bdaf2b2759eb25c690f1d34e9e8e404dbeb
|
|
BLAKE2b-256 checksum How to use checksums |
70b1111b8a3f61e02fe0b747cf6a11854113bfc3e51781ee7d9fd75d7889218b
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
Release files / schemashim-0.1.0-py3-none-any.whl
| Download URL | schemashim-0.1.0-py3-none-any.whl |
|---|---|
| Size | 17.3 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
2a0b0bad6c86107b862787427672f774df95da7ca13b0394d6164665bd38bcdf
|
|
BLAKE2b-256 checksum How to use checksums |
255eaa81fa5ff5941ef56090b6c710a63074fdfb2d39875302f4986977c66f7a
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
Security for schemashim 0.1.0
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
No provenance found
None of the files in this release were uploaded with provenance metadata.
A note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.