DEV Community

Andrey Gubanov
Andrey Gubanov

Posted on

Every LLM framework rebuilt the same tool object

If you give an LLM access to your code, you write tools. A tool is a function plus what a model needs to call it: a name, a description, and a schema for the arguments.

Then you write the same tools again. The first version uses the AI SDK's tool(). The project adds an MCP server, so the tools are rewritten for registerTool. Another team uses Genkit, and the tools are written a third time with defineTool. The functions stay the same. Only the wrapper changes.

Each wrapper is also tied to its framework. tool() comes from the ai package, defineTool is a method of a Genkit instance, and registerTool is a method of the MCP SDK's McpServer. A library that wants to ship tools has to choose one framework, and everyone who uses the library installs it.

Without the framework, a tool is a function that describes itself: the function, plus a name, a description, and schemas for its input and output. That is enough for a model to decide when and how to call it. It is also enough to generate docs, build a form, or add a CLI command. Written as a plain object with those fields, a tool belongs to your code. Moving it to another framework takes a small adapter instead of a rewrite.

The hardest part of that object is the schemas, and the schemas are already standardized.

What is Standard Schema?

Standard Schema is a TypeScript interface for validation libraries, designed by the creators of Zod, Valibot, and ArkType. Code that accepts a Standard Schema works with a schema from any library that implements it, with no adapter per library.

The whole interface is one property, ~standard. Trimmed to its fields:

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> is { value: Output } on success and { issues: Issue[] } on failure.
Enter fullscreen mode Exit fullscreen mode

Validation code is the same for every library:

const result = await schema['~standard'].validate(data);
if (result.issues) throw new Error(result.issues.map((issue) => issue.message).join('; '));
const value = result.value; // typed as the schema's output
Enter fullscreen mode Exit fullscreen mode

More than 30 libraries implement the spec, including Zod, Valibot, ArkType, yup, and joi. More than 60 tools accept it, including tRPC, TanStack Form and Router, Hono, Elysia, oRPC, and React Hook Form.

The spec is types only. The @standard-schema/spec package has no runtime code, and a library may copy the interface instead of depending on the package.

What is Standard JSON Schema?

Validation is half of what a tool needs from its schemas. The other half is JSON Schema: a model needs a JSON Schema of the arguments before it can call a tool.

Standard JSON Schema is a companion spec by the same authors. It adds a JSON Schema converter under the same ~standard property:

schema['~standard'].jsonSchema.input({ target: 'draft-2020-12' });
schema['~standard'].jsonSchema.output({ target: 'openapi-3.0' });
Enter fullscreen mode Exit fullscreen mode

target selects the JSON Schema dialect, because consumers need different ones. OpenAI, Anthropic, and MCP take JSON Schema draft 2020-12. Gemini's parameters field takes the OpenAPI 3.0 format.

input and output are separate because a schema can transform values. A schema that accepts "42" and returns 42 has one JSON Schema for its input and another for its output.

The two specs are independent, and an object can implement either or both. Zod 4.2+ and ArkType 2.1.28+ schemas implement both. In Valibot 1.2+, toStandardJsonSchema() from @valibot/to-json-schema wraps a schema so that it implements both.

What is Standard Tool?

Once the schemas validate and emit JSON Schema on their own, the rest of a tool is a name, a description, and a function. That part has no standard, so every framework defines its own object for it.

StandardToolV0 is a proposal for that object:

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>;
}
Enter fullscreen mode Exit fullscreen mode
  • name is the identifier the model uses to call the tool.
  • description tells the model what the tool does and when to use it.
  • title is an optional label for people, which MCP clients can show in tool lists.
  • inputSchema and outputSchema must implement both specs, so each one validates and emits JSON Schema. Input is the input side of the input schema, and Output is the output side of the output schema, so schemas that transform values fit.
  • meta is static data about the tool, such as { destructive: true }. Consumers read it, and execute never sees it.
  • execute runs the tool. Its optional second argument, context, carries per-call data such as a locale or an auth token. context is not validated and does not appear in the JSON Schema.
  • FormattedOutput is what execute returns when a wrapper changes the result, for example to return errors as data. It defaults to Output.

Like the two specs, StandardToolV0 is a type. Any object with these fields conforms:

import { z } from 'zod'; // or ArkType, or 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) }),
};
Enter fullscreen mode Exit fullscreen mode

The import is types only, and you can paste the interface into your project instead.

The standard-tool package also contains an optional reference implementation of about 90 lines. standardTool() wraps a definition so that execute validates the input before your function runs and the output after it, and throws StandardToolValidationError on a mismatch. withFormattedOutput() catches errors and returns them as data, so a model can read what went wrong.

How it compares

Every framework has this object. The differences are mostly names and argument positions:

Package Identifier Input schema Output schema Function
AI SDK ai key in the tools object inputSchema outputSchema execute
Mastra @mastra/core id inputSchema outputSchema execute
Genkit genkit name inputSchema outputSchema 2nd argument of defineTool
LangChain @langchain/core name schema none 1st argument of tool
MCP SDK @modelcontextprotocol/sdk 1st argument of registerTool inputSchema outputSchema 3rd argument of registerTool
StandardToolV0 none, it is a type name inputSchema outputSchema execute

What each framework accepts as a schema differs more:

  • AI SDK: Standard Schema, Zod, or JSON Schema
  • Mastra: Standard Schema with Standard JSON Schema, Zod, or JSON Schema
  • Genkit: Zod or JSON Schema
  • LangChain: Zod or JSON Schema
  • MCP SDK: Zod only

Checked against ai 7.0, @mastra/core 1.72, genkit 1.42, @langchain/core 1.2, and @modelcontextprotocol/sdk 1.31.

The objects look alike, but they are not interchangeable, and each one needs its framework's package. As a rule, moving a tool to another framework means rewriting its wrapper. Mastra is an exception in one direction: its agents also accept AI SDK tools. Reusing a tool written for another framework means installing that framework.

This matters even with one framework. A framework's tool object is made for that framework. A plain object can also be called from a script or a test, read by a docs generator, or exported from a library whose users don't install your framework.

How it can be used

The object has more than one reader. A model is one of them.

Call it. execute is a function:

const { tempC } = await getWeather.execute({ city: 'Paris' });
Enter fullscreen mode Exit fullscreen mode

Give it to a model. name, description, and the JSON Schema from inputSchema become the provider's tool definition. When the model calls the tool, its arguments go to execute. The next section shows this per provider.

Read it. The fields are enough for reference docs, a list of tools in a prompt, a form built from inputSchema, or a CLI command:

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' }),
  }));
}
Enter fullscreen mode Exit fullscreen mode

Ship it from a library. A library can export tools as ordinary values:

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}`),
};
Enter fullscreen mode Exit fullscreen mode

The library's users can run the tool, document it, or give it to a model, and the library depends on no AI framework.

Reuse RPC procedures. A tRPC or oRPC procedure already has input and output schemas and a handler. If its schemas implement Standard JSON Schema, they become the tool's schemas, and execute calls the procedure through the framework's server-side caller. Example with tRPC.

Adapting to frameworks and models

Every integration does two things. It builds the provider's tool definition from name, description, and the JSON Schema. Then, when the model calls the tool, it runs execute and sends the result back. Only the field names and the JSON Schema dialect change between providers:

Consumer Schema field target Result goes back as
OpenAI Responses API parameters draft-2020-12 a function_call_output item
Anthropic input_schema draft-2020-12 a tool_result block
Gemini parameters openapi-3.0 a functionResponse part
MCP inputSchema in the tool descriptor draft-2020-12 { content, structuredContent?, isError? }
AI SDK inputSchema, which takes the Standard Schema as is none the SDK runs the loop

Anthropic, both halves:

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 };
  }
}
Enter fullscreen mode Exit fullscreen mode

execute receives the model's arguments unchecked. A tool made with standardTool() validates them against inputSchema; a hand-written tool has to check them itself.

For another provider, change the field and the target from the table. Each adapter is written once, and adding a provider changes no tools.

Conclusion

A tool written as a function that describes itself belongs to your code. You can call it, test it, document it, and give it to any model or framework, and it stays the same object.

StandardToolV0 is one TypeScript interface with no runtime. It is a proposal. The V0 shape is frozen, so feedback that changes it goes into a new interface, StandardToolV1. The spec, the reference implementation, and the reasoning behind them are at standard-tool.js.org.

The obvious objection is XKCD 927: until other projects produce or read this shape, it is one more competing format. Standard Schema shows that a small interface with no runtime can be adopted widely, but it started with the authors of Zod, Valibot, and ArkType behind it. This proposal has one maintainer and no such backing.

The most useful feedback now is where the shape is wrong: open an issue.

Top comments (0)