v1.5.2 - Schema dialect normalization for proxied tools
Schema dialect normalization for proxied tools
The MCP TypeScript SDK hardcodes "$schema": "http://json-schema.org/draft-07/schema#"
into every generated tool's inputSchema/outputSchema, with no config option for the
upstream server to change it. A strict client whose validator only accepts 2020-12 (what
the MCP spec itself targets) refuses every tool such a server advertises through
mcpelevator — even though the schema itself usually has no draft-07-specific keywords,
only the wrong dialect pointer. 1.5.2 adds an opt-in per-server toggle that rewrites the
dialect in place when it's safe to, and separately fixes a connection-pool exhaustion bug
that could take the whole control plane offline, login included.
Normalize schema dialect toggle (#124, closes #123)
New per-server toggle, normalize_schema_dialect (Server form → Exposure → "Normalize
schema dialect"). When on, the bridge rewrites $schema from draft-07 to 2020-12 on a
proxied tool's parameters/output schema — nothing else about the schema moves, and a
schema declaring no $schema has none injected.
It's applied inside _ToolTransform._scrub (app/bridge/host.py), the same place that
already strips the reserved identity key — so it reaches every surface that resolves
tools through this proxy (MCP tools/list, REST/OpenAPI, the group hub), composes with
hide/rename policy, and is part of config_hash (toggling it restarts the bridge to
re-apply). Plumbed end-to-end: Server.normalize_schema_dialect (forward-migrated
column) → every runner's ProcessSpec → the bridge spec JSON → _tool_transform →
ServerCreate/ServerUpdate/ServerDetail → the create/edit form and server-detail page.
The compatibility guard
The rewrite is refused whenever relabeling would mean something other than renaming the
dialect. _has_incompatible_draft07_construct dispatches over four hazard families,
checked recursively but only through positions the JSON Schema grammar declares as
sub-schemas:
- Removed or changed constructs — tuple-form
items,dependencies(except beside a
$ref, where draft-07 was already ignoring it as a sibling and no 2020-12 vocabulary
evaluates it either, so nothing is lost by relabeling). - Dormant assertions — anything added after draft-07 that asserts
(unevaluatedProperties,dependentRequired,prefixItems,$dynamicRef, …).
draft-07 ignores what it doesn't recognize; the relabel switches them on.minContains/
maxContainsare conditional: inert without a siblingcontains, so those normalize. - Reference resolution —
$anchor/$dynamicAnchor/$vocabulary;$idin a
subschema, beside a$ref, or with a non-empty fragment; a$refwhose target sits
somewhere the walk doesn't independently inspect (only#/$defs/…and
#/definitions/…targets are trusted, since those containers are walked
unconditionally). - Non-portable values — a keyword 2020-12 knows and draft-07 doesn't, carrying a value
2020-12's meta-schema rejects ({"contentSchema": 7},{"$defs": {"T": 7}},
{"deprecated": "yes"},{"$recursiveAnchor": true}, …). draft-07 never inspects an
unknown keyword's value, so the wrong shape is free until the relabel makes the name
meaningful.
Every shape was verified against the published 2020-12 meta-schemas rather than assumed.
Two deliberate scope calls, documented at their definitions:
formatis not guarded. 2020-12's default meta-schema requires the
format-annotationvocabulary, so a client that asserted formats under draft-07 may
stop. It's excluded because draft-07 never guaranteed assertion (its own spec makes
it optional and opt-outable), and because guarding it would refuse most real
TypeScript-SDK tool schemas — the population this toggle exists for.$reftargets are not resolved. A full JSON Pointer resolver would mean cycle
handling, escaping, and external documents in thetools/listpath, for a shape
generated schemas don't emit.
Two availability bugs were also closed here: a malformed non-string $schema raised
TypeError, and a deeply nested schema (~500+ levels) raised RecursionError — either
would have taken tools/list down for the whole server once the toggle was on. Both now
fail to the safe answer (leave the schema under draft-07) instead of raising.
Fixes and hardening
- Stopped long-lived requests from exhausting the DB connection pool (#122). Every
/apirequest could 500 withsqlalchemy.exc.TimeoutError: QueuePool limit of size 5 overflow 10 reached— including login, since the failure surfaced at the allowlist
middleware, the first DB touch of any control-plane request. The real cause: a request
holds its session for its whole lifetime, and the SSE log stream
(GET /api/servers/{id}/logs) holds one for as long as a viewer leaves the tab open. A
handful of open log views (or concurrent playground tool calls, which can run up to
300s) could park every one of the pool's 15 connections and block the next request for
the full 30s timeout. Fixed by dropping the connection pool for the SQLite engine
(opening a local SQLite connection costs microseconds; the pool bought nothing and
imposed a hard ceiling) and by releasing the log stream's session before it starts
streaming, since the stream re-validates visibility on its own short-lived sessions
afterward. No API, schema, or dependency changes.
Upgrade notes
- No migration and no new environment variables.
normalize_schema_dialectarrives
via the existing forward-onlyADD COLUMNpath and is nullable; a pre-column row reads
as off and hashes as off, so no bridge restarts on upgrade. - Nothing changes until you opt in. The toggle is off by default; existing servers
keep advertising their upstream's declared dialect exactly as before. - The DB pool fix is transparent — no config to set, no behavior change beyond no
longer exhausting under sustained SSE/playground load.
Full Changelog: v1.5.1...v1.5.2
License
MIT © pacnpal