Чтобы дать LLM доступ к своему коду, пишут инструменты (tools). Инструмент — это функция и всё, что модели нужно знать, чтобы её вызвать: имя, описание и схема аргументов.
Потом те же инструменты приходится писать ещё раз. Первая версия сделана на tool() из AI SDK. В проекте появляется MCP-сервер, и инструменты переписывают под registerTool. Соседняя команда использует Genkit, и те же инструменты пишут в третий раз, через defineTool. Функции остаются прежними, меняется только обёртка.
К тому же каждая обёртка привязана к своему фреймворку. tool() берётся из пакета ai, defineTool — метод экземпляра Genkit, registerTool — метод McpServer из MCP SDK. Библиотеке, которая хочет поставлять инструменты, приходится выбрать один фреймворк, и его устанавливает каждый, кто её использует.
Без фреймворка инструмент — это функция, которая описывает сама себя: код, имя, описание и схемы входа и выхода. Модели этого достаточно, чтобы решить, когда и как вызвать инструмент. Этого же хватит, чтобы сгенерировать документацию, построить форму или добавить команду в CLI. Если оформить инструмент обычным объектом с такими полями, он останется частью вашего кода. Чтобы перенести его в другой фреймворк, нужен только небольшой адаптер — переписывать ничего не придётся.
Самое сложное в этом объекте — схемы, и они уже стандартизированы.
Что такое Standard Schema
Standard Schema — TypeScript-интерфейс для библиотек валидации. Его разработали авторы Zod, Valibot и ArkType. Код, который принимает Standard Schema, работает со схемой из любой библиотеки с поддержкой спецификации, и адаптер под каждую библиотеку не нужен.
Весь интерфейс — одно свойство, ~standard. В сокращённом виде:
interface StandardSchemaV1<Input = unknown, Output = Input> {
readonly '~standard': {
readonly version: 1;
readonly vendor: string;
readonly validate: (value: unknown) => Result<Output> | Promise<Result<Output>>;
readonly types?: { readonly input: Input; readonly output: Output };
};
}
// Result<Output> — это { value: Output } при успехе и { issues: Issue[] } при ошибке.
Код валидации одинаковый для любой библиотеки:
const result = await schema['~standard'].validate(data);
if (result.issues) throw new Error(result.issues.map((issue) => issue.message).join('; '));
const value = result.value; // тип берётся из output схемы
Спецификацию реализуют больше 30 библиотек, среди них Zod, Valibot, ArkType, yup и joi. Принимают её больше 60 проектов, в том числе tRPC, TanStack Form и Router, Hono, Elysia, oRPC и React Hook Form.
Спецификация состоит только из типов. В пакете @standard-schema/spec нет рантайм-кода, и библиотека может скопировать интерфейс к себе, а не зависеть от пакета.
Что такое Standard JSON Schema
Валидация — только половина того, что инструменту нужно от схем. Вторая половина — JSON Schema: прежде чем вызвать инструмент, модель должна получить JSON Schema его аргументов.
Standard JSON Schema — вторая спецификация тех же авторов. Она добавляет в то же свойство ~standard конвертер в JSON Schema:
schema['~standard'].jsonSchema.input({ target: 'draft-2020-12' });
schema['~standard'].jsonSchema.output({ target: 'openapi-3.0' });
target выбирает диалект JSON Schema, потому что разным потребителям нужны разные. OpenAI, Anthropic и MCP принимают JSON Schema draft 2020-12, а поле parameters у Gemini — формат OpenAPI 3.0.
input и output разделены, потому что схема может преобразовывать значения. Если схема принимает "42" и возвращает 42, у её входа одна JSON Schema, а у выхода — другая.
Спецификации независимы: объект может реализовать любую из них или обе. Схемы Zod 4.2+ и ArkType 2.1.28+ реализуют обе. В Valibot 1.2+ схему оборачивают в toStandardJsonSchema() из @valibot/to-json-schema, и тогда она тоже реализует обе.
Что такое Standard Tool
Когда схемы сами валидируют данные и выдают JSON Schema, от инструмента остаются имя, описание и функция. Для этой части стандарта нет, поэтому каждый фреймворк определяет для неё свой объект.
StandardToolV0 — предложение, каким должен быть этот объект:
import type { StandardSchemaV1, StandardJSONSchemaV1 } from '@standard-schema/spec';
interface StandardToolV0<
Input = unknown, Output = unknown, FormattedOutput = Output, Context = unknown,
> {
name: string;
title?: string;
description: string;
inputSchema?: StandardSchemaV1<Input, unknown> & StandardJSONSchemaV1<Input, unknown>;
outputSchema?: StandardSchemaV1<unknown, Output> & StandardJSONSchemaV1<unknown, Output>;
meta?: Record<string, unknown>;
execute(input: Input, context?: Context): FormattedOutput | Promise<FormattedOutput>;
}
-
name— идентификатор, по которому модель вызывает инструмент. -
descriptionобъясняет модели, что делает инструмент и когда его вызывать. -
title— необязательное название для людей. MCP-клиенты могут показывать его в списке инструментов. -
inputSchemaиoutputSchemaдолжны реализовывать обе спецификации, то есть и валидировать, и выдавать JSON Schema.Input— входной типinputSchema, аOutput— выходной типoutputSchema, поэтому подходят и схемы, которые преобразуют значения. -
meta— статические данные об инструменте, например{ destructive: true }. Их читают потребители, аexecuteих не получает. -
executeвыполняет инструмент. Необязательный второй аргумент,context, передаёт данные конкретного вызова, например локаль или токен авторизации.contextне валидируется и не попадает в JSON Schema. -
FormattedOutput— то, что возвращаетexecute, если обёртка меняет результат, например чтобы возвращать ошибки как данные. По умолчанию этоOutput.
Как и обе спецификации, StandardToolV0 — это только тип. Подходит любой объект с этими полями:
import { z } from 'zod'; // или ArkType, или Valibot
import type { StandardToolV0 } from 'standard-tool';
export const getWeather: StandardToolV0<{ city: string }, { tempC: number }> = {
name: 'get_weather',
description: 'Current temperature for a city',
inputSchema: z.object({ city: z.string() }),
outputSchema: z.object({ tempC: z.number() }),
execute: async ({ city }) => ({ tempC: await fetchTemperature(city) }),
};
Импорт здесь только для типов, и вместо него можно вставить интерфейс прямо в свой проект.
В пакете standard-tool есть и необязательная эталонная реализация, около 90 строк. standardTool() оборачивает определение так, что execute проверяет вход до вызова вашей функции и выход после, а при несовпадении бросает StandardToolValidationError. withFormattedOutput() перехватывает ошибки и возвращает их как данные, чтобы модель могла прочитать, что пошло не так.
Сравнение с фреймворками
Такой объект есть в каждом фреймворке. Отличаются в основном названия и позиции аргументов:
|
|
Пакет |
Идентификатор |
Схема входа |
Схема выхода |
Функция |
|---|---|---|---|---|---|
|
AI SDK |
|
ключ в объекте |
|
|
|
|
Mastra |
|
|
|
|
|
|
Genkit |
|
|
|
|
2-й аргумент |
|
LangChain |
|
|
|
нет |
1-й аргумент |
|
MCP SDK |
|
1-й аргумент |
|
|
3-й аргумент |
|
|
нет, это тип |
|
|
|
|
Сильнее различается то, что фреймворки принимают в качестве схемы:
-
AI SDK: Standard Schema, Zod или JSON Schema
-
Mastra: Standard Schema вместе со Standard JSON Schema, Zod или JSON Schema
-
Genkit: Zod или JSON Schema
-
LangChain: Zod или JSON Schema
-
MCP SDK: только Zod
Проверено на версиях ai 7.0, @mastra/core 1.72, genkit 1.42, @langchain/core 1.2 и @modelcontextprotocol/sdk 1.31.
Объекты похожи, но не взаимозаменяемы, и каждому нужен пакет своего фреймворка. Как правило, чтобы перенести инструмент в другой фреймворк, обёртку приходится переписывать. Mastra — исключение, и то в одну сторону: её агенты принимают и инструменты из AI SDK. А чтобы использовать инструмент, написанный под другой фреймворк, нужно установить этот фреймворк.
Это важно даже при одном фреймворке. Объект инструмента рассчитан на свой фреймворк. Обычный объект можно ещё и вызвать из скрипта или теста, прочитать генератором документации или экспортировать из библиотеки, пользователи которой ваш фреймворк не ставят.
Как это использовать
У объекта не один читатель, и модель — только один из них.
Вызвать. execute — обычная функция:
const { tempC } = await getWeather.execute({ city: 'Paris' });
Отдать модели. name, description и JSON Schema, которую выдаёт inputSchema, превращаются в описание инструмента для провайдера. Когда модель вызывает инструмент, её аргументы уходят в execute. Как это выглядит у разных провайдеров — в следующем разделе.
Прочитать. Полей хватает для справочной документации, списка инструментов в промпте, формы по inputSchema или команды CLI:
function describeTools(tools: StandardToolV0[]) {
return tools.map((tool) => ({
name: tool.name,
description: tool.description,
input: tool.inputSchema?.['~standard'].jsonSchema.input({ target: 'draft-2020-12' }),
output: tool.outputSchema?.['~standard'].jsonSchema.output({ target: 'draft-2020-12' }),
}));
}
Экспортировать из библиотеки. Инструменты — обычные значения, и библиотека экспортирует их как любые другие:
export const getOrders: StandardToolV0<{ userId: string }, Order[]> = {
name: 'get_orders',
description: "List a user's orders",
inputSchema: z.object({ userId: z.string() }),
execute: ({ userId }) => api.get(`/orders/${userId}`),
};
Пользователи библиотеки могут запустить инструмент, задокументировать его или отдать модели, а сама библиотека не зависит ни от одного AI-фреймворка.
Взять готовые RPC-процедуры. У процедуры tRPC или oRPC уже есть схемы входа и выхода и обработчик. Если схемы реализуют Standard JSON Schema, они становятся схемами инструмента, а execute вызывает процедуру через серверный вызов фреймворка (в tRPC это createCaller). Пример с tRPC.
Подключение к моделям и фреймворкам
Любая интеграция делает две вещи. Сначала собирает описание инструмента для провайдера из name, description и JSON Schema. Потом, когда модель вызывает инструмент, запускает execute и отправляет результат обратно. От провайдера к провайдеру меняются только названия полей и диалект JSON Schema:
|
Потребитель |
Поле схемы |
|
Как возвращается результат |
|---|---|---|---|
|
OpenAI Responses API |
|
|
элемент |
|
Anthropic |
|
|
блок |
|
Gemini |
|
|
часть |
|
MCP |
|
|
|
|
AI SDK |
|
не нужен |
SDK сам ведёт цикл |
Обе части на примере Anthropic:
import type Anthropic from '@anthropic-ai/sdk';
import type { StandardToolV0 } from 'standard-tool';
export function toAnthropicTool(tool: StandardToolV0): Anthropic.Tool {
const schema = tool.inputSchema?.['~standard'].jsonSchema.input({ target: 'draft-2020-12' });
return {
name: tool.name,
description: tool.description,
input_schema: (schema ?? { type: 'object', properties: {} }) as Anthropic.Tool.InputSchema,
};
}
export async function runToolUse(
tools: StandardToolV0[],
block: Anthropic.ToolUseBlock,
): Promise<Anthropic.ToolResultBlockParam> {
try {
const tool = tools.find((t) => t.name === block.name);
if (!tool) throw new Error(`Unknown tool: ${block.name}`);
const result = await tool.execute(block.input);
return { type: 'tool_result', tool_use_id: block.id, content: JSON.stringify(result) };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return { type: 'tool_result', tool_use_id: block.id, content: message, is_error: true };
}
}
execute получает аргументы модели без проверки. Инструмент из standardTool() проверяет их по inputSchema, а в написанном вручную это нужно делать самому.
Для другого провайдера поменяйте поле и target по таблице. Адаптер пишется один раз, и новый провайдер не требует правок в инструментах.
Итог
Инструмент, оформленный как функция, которая описывает сама себя, остаётся частью вашего кода. Его можно вызвать, протестировать, задокументировать и отдать любой модели или фреймворку, и это будет всё тот же объект.
StandardToolV0 — это предложение: один TypeScript-интерфейс без рантайма. Форма V0 заморожена, так что правки, которые её меняют, пойдут в новый интерфейс — StandardToolV1. Спецификация, эталонная реализация и обоснование — на standard-tool.js.org.
Очевидное возражение — XKCD 927: пока другие проекты не создают и не читают такие объекты, это просто ещё один конкурирующий формат. Standard Schema показала, что маленький интерфейс без рантайма может широко распространиться, но за ней с самого начала стояли авторы Zod, Valibot и ArkType. У этого предложения один мейнтейнер и такой поддержки нет.
Автор: Finom


