Skip to content

feat(tools): add EnableStrictToolSchemas and keep MCP additionalProperties - #81

Merged
mudler merged 1 commit into
mudler:mainfrom
mudler-agent:feat/strict-tool-schemas
Sep 27, 2026
Merged

mudler merged 1 commit into
mudler:mainfrom
mudler-agent:feat/strict-tool-schemas

Conversation

@mudler-agent

@mudler-agent mudler-agent commented Sep 27, 2026 •

Copy link
Copy Markdown
Contributor

What changed

EnableStrictToolSchemas (opt-in). Every tool usableTools returns is wrapped so it is sent with "strict": true and a schema that follows strict mode's rules:

  • additionalProperties: false on every object (nested, array items, anyOf/allOf, $defs);
  • every property in required; a formerly optional property becomes nullable (type: [T, "null"], null added to an enum, or anyOf: [..., {type: null}] when there is no type).

The wrapper's Execute drops the null values the model then sends for an argument it leaves out, so the tool sees an omitted argument, as before. A schema strict mode cannot express (an object with schema-valued or true additionalProperties, oneOf, a boolean schema, a non-object top level) is sent unchanged and not strict. Wrapping is idempotent.

MCP tools keep additionalProperties. They were advertised as a langchaingo jsonschema.Definition, which has no additionalProperties field, so a server's "additionalProperties": false (the go-sdk infers it for every Go struct) never reached the model. They now use a small mcpToolParameters struct that carries it. This applies with or without the option.

Why

In nib, models called the bash tool with command instead of script. The MCP server rejected each call as unexpected additional properties ["command"], and the model concluded it had no shell. Nothing constrained the arguments while the model wrote them:

  • LocalAI disables its own tool grammar for models that use the backend's chat template, which includes every auto-imported GGUF (core/config/gguf.go, model_config.go).
  • llama.cpp's tool grammar is lazy under tool_choice: auto: it applies only after the model emits its format's tool-call trigger.

LocalAI turns its grammar back on for a request with a strict tool (core/http/endpoints/openai/chat.go, strictMode). OpenAI enforces the schema.

Caveats

  • Opt-in because strict mode is not safe everywhere. Some OpenAI-compatible servers reject the strict field, and on LocalAI a model using the backend's template can conflict with LocalAI's own grammar (the comment around issue #10052 in model_config.go). nib will expose it per endpoint.
  • The existing Background dispatch and should spawn agent in background specs fail intermittently on main too (5 runs on main: FFFPF; with this change: FPFFP). Not touched here.

How to verify

go test . -ginkgo.label-filter='!e2e' -ginkgo.focus='strict'. The new specs in strict_test.go serve a typed go-sdk tool like nib's bash tool:

  • the discovered MCP tool carries additionalProperties: false;
  • the wrapped tool is strict, with timeout nullable and required, and a call with "timeout": null succeeds (the server rejects the null as "want integer" without the wrapper; verified by removing it);
  • usableTools wraps only with the option set;
  • nested schemas, anyOf wrapping, and the refused cases.

Used by mudler/nib#132 (strict_tools), which pins this branch's commit until this merges. The same branch also exists on mudler/cogito for that reason.

🤖 Generated with Claude Code

…rties

A tool's schema only described the arguments; nothing made the model
follow it. In nib, models called the bash tool with "command" instead
of "script", the MCP server rejected every call, and the model gave up
on the shell. LocalAI disables its own tool grammar for models that use
the backend's chat template, and llama.cpp's grammar is lazy under
tool_choice auto, so those calls were never constrained.

EnableStrictToolSchemas wraps every tool usableTools returns so it is
sent with "strict": true and a schema that follows strict mode's rules:
additionalProperties false on every object, every property required,
formerly optional ones nullable. The wrapper drops the nulls the model
then sends before it calls the tool. A schema strict mode cannot
express is sent unchanged. LocalAI switches its tool grammar on for a
request with a strict tool; OpenAI enforces the schema.

MCP tools were advertised with a jsonschema.Definition, which has no
additionalProperties field, so a server's "additionalProperties": false
never reached the model. They now carry it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@mudler
mudler merged commit d35c7af into mudler:main Sep 27, 2026
2 of 3 checks passed
@mudler-agent

Copy link
Copy Markdown
Contributor Author

Follow-up on the "Why" section above, after checking LocalAI and llama.cpp more closely: on LocalAI with a model that uses the backend's chat template, llama.cpp's autoparser does build a tool-call grammar with argument names as literals from the schema. The command calls in nib came through LocalAI's Go-side text-parsing fallback instead: the autoparser's grammar is lazy under tool_choice: auto, a call written without the trigger marker comes back as text, and ParseFunctionCall turns it into a tool call without checking the schema.

So on LocalAI, EnableStrictToolSchemas is not the fix: a strict tool makes LocalAI send its own grammar in place of the autoparser's, which can break template-based models (LocalAI #10052). It remains useful for OpenAI and servers without a native parser; nib keeps it opt-in (strict_tools, mudler/nib#132). The LocalAI-side fix is to validate fallback-parsed calls against the schema.

The additionalProperties change for MCP tools applies regardless and is correct as is.

Also: nib pinned this PR's branch head (bca971d); mudler/nib#133 moves it to the squash commit d35c7af, after which feat/strict-tool-schemas on mudler/cogito can be deleted.

mudler added a commit that referenced this pull request Sep 28, 2026
An MCP tool that takes no arguments has no "properties" in its input
schema, so the unmarshal leaves the property map nil. Before #81 the
tool was sent as a jsonschema.Definition, whose MarshalJSON encodes a
nil map as {}. mcpToolParameters has no such hook, so the tool went out
with "properties": null.

vLLM validates every tool schema and rejects the whole request with a
400 ("None is not of type 'object'"), so one argument-less MCP tool
broke every call to such a server. Strict mode was not affected: it
already fills in an empty properties object.

Tool() now sends an empty object when the map is nil.

Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants