Skip to Content
Deriving AI Tools

Deriving AI Tools from Controllers and RPC Modules

Controllers as well as generated RPC/API modules can be converted into AI tools for LLM function calling, using deriveTools utility. This makes your back-end functionality accessible to AI models (including MCP clients) with minimum code. The function accepts modules record with

  • Controllers for same-context execution to be used on back-end.
  • Modules generated from controllers for HTTP calls or be used on front-end (see Realtime UI) or other environments that support fetch.
  • Third-party OpenAPI-based modules (called OpenAPI mixins in this documentation), enabling to combine back-end functionality with external APIs in a single agent.
src/modules/user/UserController.ts
import { deriveTools } from 'vovk'; import { TaskRPC } from 'vovk-client'; import UserController from '@/modules/user/UserController'; const { tools, toolsByName } = deriveTools({ modules: { UserController, TaskRPC, }, }); console.log('Derived tools:', tools); // [{ name, description, parameters, execute }, ...]

The function returns an array of tools and a toolsByName object for easy access by tool name. Each tool implements VovkTool type and includes the name, description, parameters (JSON Schema), and execute function (+ some extra properties, such as type: "function", outputSchema, inputSchemas, when available).

deriveTools distinguishes between RPC modules and controllers by checking for the isRPC property on each module method. If isRPC: true is present, the method is treated as an RPC method that performs an HTTP request. Otherwise, it is treated as a controller method that exposes fn (see fn page) that is called in the current context without HTTP.

deriveTools Options

The deriveTools function accepts an options object with the following properties:

  • modules: Record<string, object> - a record of modules (RPC/API modules or controllers) to derive tools from.
  • onExecute?: (tool: VovkTool, result: unknown) => void - optional callback invoked when a tool’s execute function completes successfully.
  • onError?: (tool: VovkTool, error: Error) => void - optional callback invoked when a tool’s execute function throws an error.
  • toModelOutput?: ToModelOutputFn<TInput, TOutput, TFormattedOutput> - optional function to format the output returned to the LLM. Can be set to a custom function or one of the built-in formatters, defined in ToModelOutput object, exported from vovk, such as ToModelOutput.MCP for MCP formatting and ToModelOutput.DEFAULT that’s used by default when toModelOutput is not provided.
  • meta?: Record<string, unknown> - optional metadata passed to each controller/RPC method. The meta can be read on the back end using req.vovk.meta. When passed to a controller procedure, it’s merged with procedure-level meta normally. When passed to an RPC method, it’s available as xMetaHeader key.

Custom Operation Attributes with x-tool or @operation.tool Decorator

By default, tool description is derived from OpenAPI summary and description fields and the tool name is generated in the form of ${moduleName}_${handlerName}. You can override these values and add tool-specific attributes using x-tool custom attributes in the @operation decorator.

src/modules/user/UserController.ts
import { prefix, get, operation } from 'vovk'; @prefix('user') export default class UserController { @operation({ summary: 'Get user by ID', description: 'Retrieves a user by their unique ID.', 'x-tool': { // tool-specific attributes } }) @get('{id}') static getUser() { // ... } }

@operation also provides tool property that defines tool-specific attributes for deriveTools function. It’s set under x-tool key in the OpenAPI operation object and created for cleaner syntax.

src/modules/user/UserController.ts
import { prefix, get, operation } from 'vovk'; @prefix('user') export default class UserController { @operation.tool({ title: 'Get user by ID', name: 'get_user_by_id', description: 'Retrieves a user by their unique ID, including name and email.', }) @operation({ summary: 'Get user by ID', description: 'Retrieves a user by their unique ID.', }) @get('{id}') static getUser() { // ... } }

The tool attributes available under x-tool are:

  • hidden?: boolean - if set to true, the tool is excluded from the derived tools.
  • name?: string - overrides the generated tool name.
  • title?: string - optional title for the tool. Used mainly for MCPs.
  • description?: string - overrides the generated tool description.

MCP (Model Context Protocol) Output Formatting

ToModelOutput.MCP formatter formats the tool output to meet MCP tool specificationΒ , supporting the folowing types of outputs: text, image, audio, alongside with meta information, described with annotations object.

JSON Content

JSON responses (including ones created with Response or NextResponse) will be formatted as text content with structuredContent field.

export default class UserController { @get('{id}') static getUser() { return { hello: 'world' }; } }

When the tool is executed with ToModelOutput.MCP formatter, the output will be:

{ "content": [ { "type": "text", "text": "{\"hello\":\"world\"}", } ], "structuredContent": { "hello": "world" } }

Audio and Image Content

If procedure returns a Response with Content-Type header set to audio/* or image/*, the output will be formatted accordingly.

export default class MediaController { @get('image') static getImage() { return new Response(buffer, { headers: { 'Content-Type': 'image/png' }, }); }; }

When the tool is executed with ToModelOutput.MCP formatter, the output will be:

{ "content": [ { "type": "image", "data": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAA...", "mimeType": "image/png" } ] }

The response can be created with toDownloadResponse utility, or using fetch to get media from an external source.

import { toDownloadResponse } from 'vovk'; export default class MediaController { @get('audio') static getAudio() { return toDownloadResponse(buffer, { contentType: 'audio/mpeg' }); } }
export default class MediaController { @get('from-url') static async getAudioFromURL() { return fetch('https://example.com/image.jpg'); } }

Text Content

If procedure returns a Response with Content-Type header set to text/* or other text-based types, such as XML, the output will be formatted as text content.

export default class TextController { @get('greeting') static getGreeting() { return new Response('Hello, world!', { headers: { 'Content-Type': 'text/plain' }, }); } }

When the tool is executed with ToModelOutput.MCP formatter, the output will be:

{ "content": [ { "type": "text", "text": "Hello, world!" } ] }

annotations

Annotations can be added to the output by setting a special metadata key mcpOutput using req.vovk.meta function. This approach makes sure that if the procedure used as an endpoint, the response will not be affected.

export default class AnnotatedController { @get('annotated-image') static async getAnnotatedImage(req: VovkRequest) { req.vovk.meta({ mcpOutput: { annotations: { audience: ['user'], priority: 5 } }, }); return fetch('https://example.com/image.jpg'); } }

When the tool is executed with ToModelOutput.MCP formatter, the output will include the annotations object:

{ "content": [ { "type": "image", "data": "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD...", "mimeType": "image/jpeg" } ], "annotations": { "audience": ["user"], "priority": 5 } }

Note that mcpOutput metadata key can also override other MCP output properties, such as data, text, type, and mimeType. This might be useful if you want to customize the MCP output without changing the actual procedure response.

Tips

Selecting Specific Procedures

To include only certain procedures from a module (besides using hidden attribute), use the pick/omit pattern from lodash or a similar utility.

import { deriveTools } from 'vovk'; import { PostRPC } from 'vovk-client'; import { pick, omit } from 'lodash'; import UserController from '../user/UserController'; const { tools } = deriveTools({ modules: { PostRPC: pick(PostRPC, ['createPost', 'getPost']), UserController: omit(UserController, ['deleteUser']), }, });

The resulting tools include createPost and getPost from PostRPC, and all methods from UserController except deleteUser.

Authorizing API Calls

Third-party API calls may require authorization headers that can be passed by using withDefaults function, available for all generated RPC/API modules. Having GithubIssuesAPI module, described in OpenAPI mixins, you can create authorized tools for Github Issues API:

import { deriveTools } from 'vovk'; import { GithubIssuesAPI } from 'vovk-client'; const { tools } = deriveTools({ modules: { AuthorizedGithubIssuesAPI: GithubIssuesAPI.withDefaults({ init: { headers: { Authorization: `Bearer ${process.env.GITHUB_TOKEN}`, 'X-GitHub-Api-Version': '2022-11-28' }, }, }), }, });

Standalone Tools

You can create standalone tools that are not derived from controllers or RPC modules by implementing the VovkTool interface directly. createTool is a small utility that covers cases when you want to create custom tools that don’t map to existing back-end functionality and would be useful if you use LLM APIs directly, without 3rd-party libraries.

An example case would be using OpenAI Realtime APIΒ  that, by the time of writing, isn’t supported by Vercel AI SDK. For more details, see Realtime UI articles.

createTool accepts the following options, some of which are similar to deriveTools options:

  • name: string - the name of the tool.
  • title?: string - optional title for the tool. Used mainly for MCPs.
  • description: string - the description of the tool.
  • toModelOutput?: ToModelOutputFn<TInput, TOutput, TFormattedOutput> - optional
  • inputSchema?: StandardSchemaV1 & StandardJSONSchemaV1 - optional input schema for the tool, supports the same libraries as procedure function.
  • outputSchema?: StandardSchemaV1 & StandardJSONSchemaV1 - optional output schema for the tool, supports the same libraries as procedure function.
  • execute: (input: TInput) => Promise<TOutput> | TOutput - the function to execute the tool logic.
import { createTool, ToModelOutput } from 'vovk'; import { z } from 'zod'; const sumNumbers = createTool({ name: 'sum_numbers', title: 'Get Sum of two Numbers', description: 'Returns the sum of two numbers provided as input.', toModelOutput: ToModelOutput.MCP, inputSchema: z.object({ a: z.number().description('The first number to sum.'), b: z.number().description('The second number to sum.'), }), outputSchema: z.number().description('The sum of the two numbers.'), execute({ a, b }) { return a + b; }, }); console.log('Standalone tool:', sumNumbers); // { name, description, parameters, execute, inputSchema, outputSchema }

The standalone tool can be merged with derived tools or used independently.

// ... const { tools: derivedTools } = deriveTools({ modules: { UserController }, }); const allTools = [...derivedTools, sumNumbers];

The sumNumbers tool can now be used like any other derived tool with name, description, parameters, and execute function, but also mirrors inputSchema and outputSchema.

Examples

Vercel AI SDK Example

That’s an example of Vercel AI SDK chat that uses UserController to derive tools. View live example on examples.vovk.dev » 

First, create an empty controller. The command will also update the root route.ts file.

npx vovk new controller aiSdk --empty

Paste the following into the newly created src/modules/ai-sdk/AiSdkController.ts, adjusting imports as needed:

src/modules/ai-sdk/AiSdkController.ts
import { deriveTools, post, prefix, type VovkRequest, } from 'vovk'; import { jsonSchema, streamText, tool, convertToModelMessages, type UIMessage, } from 'ai'; import { openai } from '@ai-sdk/openai'; import UserController from '@/modules/user/UserController'; @prefix('ai-sdk') export default class AiSdkController { @post('tools') static async functionCalling(req: VovkRequest<{ messages: UIMessage[] }>) { const { messages } = await req.json(); const { tools: llmTools } = deriveTools({ modules: { UserController }, }); const tools = Object.fromEntries( llmTools.map(({ name, execute, description, parameters }) => [ name, tool({ execute: (input) => execute(input), description, inputSchema: jsonSchema(parameters), }), ]) ); return streamText({ model: openai('gpt-5-nano'), system: 'You are a helpful assistant', messages: await convertToModelMessages(messages), tools, }).toUIMessageStreamResponse(); } }

Here, the tools are mapped to Vercel AI SDK tool instances using jsonSchema. For other libraries, you can map them differently.

On the client-side, create a component using the useChatΒ  hook:

src/app/page.tsx
'use client'; import { useChat } from '@ai-sdk/react'; import { DefaultChatTransport } from 'ai'; import { useState } from 'react'; export default function Page() { const [input, setInput] = useState(''); const { messages, sendMessage, error, status } = useChat({ transport: new DefaultChatTransport({ api: '/api/ai-sdk/tools', }), }); const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); if (input.trim()) { sendMessage({ text: input }); setInput(''); } }; return ( <form onSubmit={handleSubmit}> {messages.map((message) => ( <div key={message.id}> {message.role === 'assistant' ? 'πŸ€–' : 'πŸ‘€'}{' '} {message.parts.map((part, partIndex) => ( <span key={partIndex}>{part.type === 'text' ? part.text : ''}</span> ))} </div> ))} {error && <div>❌ {error.message}</div>} <div className="input-group"> <input type="text" placeholder="Send a message..." value={input} onChange={(e) => setInput(e.target.value)} /> <button>Send</button> </div> </form> ); }

MCP Handler Example

Coming soon…

Roadmap

  • ✨ Add a router option to deriveTools to support hundreds of functions without hitting LLM tools limits. Routing can be implemented using vector search or other approaches.
Last updated on