Static checks for API tools, for the test suite of every module that
declares PhoenixKit.Module.api_tools/0.
test "the tools are well formed" do
assert :ok = PhoenixKit.API.ToolLint.check_tools(MyModule.api_tools())
endPhoenixKit.API.Validator enforces a small subset of JSON Schema and
silently ignores every other keyword. A schema that uses an ignored keyword
in a tool's input promises a check nobody makes, so this module flags:
- in an input schema, every keyword outside the supported subset:
anything but
type,required,properties,additionalProperties,enum,minimum,maximum,minLength,maxLength,minItems,maxItems,items,patternand the annotations the validator accepts and ignores (PhoenixKit.API.Validator.annotation?/1). That is a whitelist:oneOf,const,nullable,dependencies,uniqueItemsand a keyword nobody has heard of are all caught. (In an output schema the combinators are fine: core generatesanyOfitself for the MCPoutputSchema, and outputs are not validated at run time.) $ref,$defsanddefinitionsanywhere: schemas are served as they are and nothing resolves a reference.
It also checks the shape of what the validator does read (type,
required, properties, additionalProperties, items, enum, the
numeric and count bounds, pattern), that both roots are objects (MCP
needs an object inputSchema, and structuredContent is an object), that a
required name is declared when the object is closed, that a tool has a
description, that a public tool with a danger: declares a client_token:
(or, until 3.0.0, has the input PhoenixKit.API.Legacy.legacy_client_token/1
names, which the executor reads the same way) and lists that field in required (a dry run is answered before the client
token is checked, so the schema has to refuse a call without one), that a
client_token: is a string property of the input schema and belongs to a
public tool (an admin tool is identified by its key), and that no two tools
of one audience share a name or an MCP name (a.b_c and a_b.c are both
a_b_c on MCP).
Relation to the validator
PhoenixKit.API.Tool.new/1 already runs PhoenixKit.API.Validator.check_schema/2
on every tool, so a tool built through it is refused for an unsupported
keyword, a bad bound or a bad pattern. This module is defence in depth:
it also sees a tool built as a struct literal, reports every problem with its
path instead of the first one, and checks what the validator does not (a
required name that is not declared, an object root, an empty description,
the MCP-name collisions). Where both read a keyword they agree: pattern is compiled with
the validator's options, the annotations come from the validator, and an atom
type or required name is read like a string. A test pins the agreement.
The lint is stricter in a few places on purpose: a type list with a
repeated name, a minimum above a maximum, an empty enum, an empty
description.
Every function returns :ok or {:error, messages} with all the problems
found, in English. A schema message starts with the path inside the schema
it is about (properties.sku: ..., (root): ...); check_tool/1 adds
input_schema: or output_schema: in front, and check_tools/1 the tool
name. A name message (a.b_c, a_b.c share the MCP name a_b_c) starts with
the names.
Summary
Functions
Checks one JSON Schema. kind is :input for a tool's input_schema and
:output for its output_schema.
Checks the schemas, the roots and the description of one tool.
Checks every tool of a set and the names across the set: a name declared twice in one audience, and two names of one audience that are the same on MCP.
Functions
Checks one JSON Schema. kind is :input for a tool's input_schema and
:output for its output_schema.
@spec check_tool(PhoenixKit.API.Tool.t()) :: :ok | {:error, [String.t()]}
Checks the schemas, the roots and the description of one tool.
@spec check_tools([PhoenixKit.API.Tool.t()]) :: :ok | {:error, [String.t()]}
Checks every tool of a set and the names across the set: a name declared twice in one audience, and two names of one audience that are the same on MCP.