PhoenixKit.API.ToolLint (phoenix_kit v2.63.0)

Copy Markdown View Source

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())
end

PhoenixKit.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, pattern and the annotations the validator accepts and ignores (PhoenixKit.API.Validator.annotation?/1). That is a whitelist: oneOf, const, nullable, dependencies, uniqueItems and a keyword nobody has heard of are all caught. (In an output schema the combinators are fine: core generates anyOf itself for the MCP outputSchema, and outputs are not validated at run time.)
  • $ref, $defs and definitions anywhere: 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: lists cart_token in required (a dry run is answered before the cart token is checked, so the schema has to refuse a call without one), 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

check_schema(schema, kind)

@spec check_schema(term(), :input | :output) :: :ok | {:error, [String.t()]}

Checks one JSON Schema. kind is :input for a tool's input_schema and :output for its output_schema.

check_tool(tool)

@spec check_tool(PhoenixKit.API.Tool.t()) :: :ok | {:error, [String.t()]}

Checks the schemas, the roots and the description of one tool.

check_tools(tools)

@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.