Skip to Content
MCP Formatting

MCP (Model Context Protocol) Output Formatting

With the ToModelOutput.MCP formatter, derived tools work as MCP tools. It formats the tool output to the MCP tool specification , with text, image and audio output, and meta information in an annotations object.

const tools = deriveTools({ modules: { UserController }, toModelOutput: ToModelOutput.MCP, });

JSON Content

A JSON response becomes text content with a structuredContent field. This includes responses made with Response or NextResponse, and +json types such as application/problem+json. MCP structured content is an object, so an array goes under structuredContent.items. A Response with an error status gives isError: true and no structuredContent. A thrown error is reported the same way. In production, only an HttpException keeps its message, and any other error reads “Internal server error”.

export default class UserController { @operation({ summary: 'Get user' }) @get('{id}') static getUser = procedure().handle(async (req, { id }) => { return { hello: 'world' }; }); }

With the ToModelOutput.MCP formatter, the tool’s output is:

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

Audio and Image Content

When a procedure returns a Response with the Content-Type header set to audio/* or image/*, the output is audio or image content. Its data field holds the body in base64.

export default class MediaController { @operation({ summary: 'Get image' }) @get('image') static getImage = procedure().handle(() => { return new Response(buffer, { headers: { 'Content-Type': 'image/png' }, }); }); }

With the ToModelOutput.MCP formatter, the tool’s output is:

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

You can create the response with the toDownloadResponse utility, or get the media from another source with fetch.

import { get, operation, procedure, toDownloadResponse } from 'vovk'; export default class MediaController { @operation({ summary: 'Get audio' }) @get('audio') static getAudio = procedure().handle(() => { return toDownloadResponse(buffer, { type: 'audio/mpeg' }); }); }
export default class MediaController { @operation({ summary: 'Get audio from a URL' }) @get('from-url') static getAudioFromURL = procedure().handle(() => { return fetch('https://example.com/audio.mp3'); }); }

Text Content

When a procedure returns a Response with the Content-Type header set to text/* or another text type, such as XML, the output is text content.

export default class TextController { @operation({ summary: 'Get greeting' }) @get('greeting') static getGreeting = procedure().handle(() => { return new Response('Hello, world!', { headers: { 'Content-Type': 'text/plain' }, }); }); }

With the ToModelOutput.MCP formatter, the tool’s output is:

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

A Response of any other content type, such as application/pdf, gives isError: true and the text Unsupported response content type application/pdf.

annotations

To add annotations to the output, set the mcpOutput metadata key with the req.vovk.meta function. When the procedure serves as an endpoint, its response stays the same. MCP takes a priority from 0 (least important) to 1 (most important).

export default class AnnotatedController { @operation({ summary: 'Get annotated image' }) @get('annotated-image') static getAnnotatedImage = procedure().handle((req) => { req.vovk.meta({ mcpOutput: { annotations: { audience: ['user'], priority: 0.8 } }, }); return fetch('https://example.com/image.jpg'); }); }

With the ToModelOutput.MCP formatter, each content item gets the annotations object, as MCP annotates content items:

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

The mcpOutput key can also override other MCP output properties, including content and structuredContent; its annotations go on each content item. This way you can change the MCP output without changing the procedure’s response.

MCP Handler Example

With the mcp-handler  package, you can create an MCP API route that controls what your back end exposes to MCP clients.

The tool’s inputSchema is a Standard Schema with JSON Schema, which registerTool in mcp-handler 2 accepts as is.

src/app/api/mcp/route.ts
import { createMcpHandler } from "mcp-handler"; import { deriveTools, ToModelOutput } from "vovk"; import UserController from "@/modules/user/user-controller"; const tools = deriveTools({ modules: { UserController }, toModelOutput: ToModelOutput.MCP, }); const handler = createMcpHandler( (server) => { tools.forEach(({ title, name, execute, description, inputSchema }) => { server.registerTool(name, { title, description, inputSchema }, execute); }); }, ); export { handler as GET, handler as POST };

Standalone Tools with withFormattedOutput

ToModelOutput.MCP works with the withFormattedOutput helper from the standard-tool  package: both take a result or an Error. Wrap a standalone tool with it, and pass null as the request argument. Its execute then gives the same MCP output as derived tools, so you can register it on the same MCP server:

import { standardTool, withFormattedOutput } from 'standard-tool'; import { ToModelOutput } from 'vovk'; import { z } from 'zod'; const sumNumbers = standardTool({ name: 'sum_numbers', description: 'Returns the sum of two numbers provided as input.', inputSchema: z.object({ a: z.number(), b: z.number() }), outputSchema: z.number(), execute({ a, b }) { return a + b; }, }); const sumNumbersMCP = withFormattedOutput(sumNumbers, (result) => ToModelOutput.MCP(result, sumNumbers, null) );

See Realtime Kanban / MCP for more.

Last updated on