From 1575e483c84e2dc98ac0da49c337f3648f09a2ce Mon Sep 17 00:00:00 2001 From: Hugo Date: Thu, 27 Nov 2025 15:49:34 +0000 Subject: [PATCH 1/3] feat(module): add result helper functions for tool responses (#36) --- apps/docs/content/2.core-concepts/2.tools.md | 62 +++++++++ packages/nuxt-mcp-toolkit/src/module.ts | 28 ++-- .../runtime/server/mcp/definitions/index.ts | 1 + .../runtime/server/mcp/definitions/results.ts | 68 ++++++++++ .../nuxt-mcp-toolkit/test/results.test.ts | 122 ++++++++++++++++++ 5 files changed, 264 insertions(+), 17 deletions(-) create mode 100644 packages/nuxt-mcp-toolkit/src/runtime/server/mcp/definitions/results.ts create mode 100644 packages/nuxt-mcp-toolkit/test/results.test.ts diff --git a/apps/docs/content/2.core-concepts/2.tools.md b/apps/docs/content/2.core-concepts/2.tools.md index 409ee3a..034b8c3 100644 --- a/apps/docs/content/2.core-concepts/2.tools.md +++ b/apps/docs/content/2.core-concepts/2.tools.md @@ -256,6 +256,68 @@ return { :: +### Result Helpers + +To simplify creating tool responses, the module provides auto-imported helper functions: + +::code-group + +```typescript [textResult] +// Simple text response +export default defineMcpTool({ + description: 'Echo a message', + inputSchema: { message: z.string() }, + handler: async ({ message }) => textResult(`Echo: ${message}`), +}) +``` + +```typescript [jsonResult] +// JSON response (auto-stringified) +export default defineMcpTool({ + description: 'Get user data', + inputSchema: { id: z.string() }, + handler: async ({ id }) => { + const user = await getUser(id) + return jsonResult(user) + // or jsonResult(user, false) for compact JSON + }, +}) +``` + +```typescript [errorResult] +// Error response +export default defineMcpTool({ + description: 'Find resource', + inputSchema: { id: z.string() }, + handler: async ({ id }) => { + const resource = await findResource(id) + if (!resource) return errorResult('Resource not found') + return jsonResult(resource) + }, +}) +``` + +```typescript [imageResult] +// Image response (base64) +export default defineMcpTool({ + description: 'Generate chart', + inputSchema: { data: z.array(z.number()) }, + handler: async ({ data }) => { + const base64 = await generateChart(data) + return imageResult(base64, 'image/png') + }, +}) +``` + +:: + +| Helper | Description | Parameters | +|--------|-------------|------------| +| `textResult(text)` | Simple text response | `text: string` | +| `jsonResult(data, pretty?)` | JSON response (auto-stringify) | `data: unknown`, `pretty?: boolean` (default: `true`) | +| `errorResult(message)` | Error response with `isError: true` | `message: string` | +| `imageResult(data, mimeType)` | Base64 image response | `data: string`, `mimeType: string` | + ## Tool Annotations You can provide metadata and behavioral hints to clients using the `annotations` property. These annotations help the AI assistant understand how to use your tool appropriately. diff --git a/packages/nuxt-mcp-toolkit/src/module.ts b/packages/nuxt-mcp-toolkit/src/module.ts index 762bde1..579f235 100644 --- a/packages/nuxt-mcp-toolkit/src/module.ts +++ b/packages/nuxt-mcp-toolkit/src/module.ts @@ -138,24 +138,18 @@ export default defineNuxtModule({ nuxt.options.nitro.typescript.tsConfig.include ??= [] nuxt.options.nitro.typescript.tsConfig.include.push(resolver.resolve('runtime/server/types.server.d.ts')) + const mcpDefinitionsPath = resolver.resolve('runtime/server/mcp/definitions') + addServerImports([ - { - name: 'defineMcpTool', - from: resolver.resolve('runtime/server/mcp/definitions'), - }, - { - name: 'defineMcpResource', - from: resolver.resolve('runtime/server/mcp/definitions'), - }, - { - name: 'defineMcpPrompt', - from: resolver.resolve('runtime/server/mcp/definitions'), - }, - { - name: 'defineMcpHandler', - from: resolver.resolve('runtime/server/mcp/definitions'), - }, - ]) + 'defineMcpTool', + 'defineMcpResource', + 'defineMcpPrompt', + 'defineMcpHandler', + 'textResult', + 'jsonResult', + 'errorResult', + 'imageResult', + ].map(name => ({ name, from: mcpDefinitionsPath }))) addServerHandler({ route: options.route, diff --git a/packages/nuxt-mcp-toolkit/src/runtime/server/mcp/definitions/index.ts b/packages/nuxt-mcp-toolkit/src/runtime/server/mcp/definitions/index.ts index 2f3ab95..cc4c18b 100644 --- a/packages/nuxt-mcp-toolkit/src/runtime/server/mcp/definitions/index.ts +++ b/packages/nuxt-mcp-toolkit/src/runtime/server/mcp/definitions/index.ts @@ -2,3 +2,4 @@ export * from './tools' export * from './resources' export * from './prompts' export * from './handlers' +export * from './results' diff --git a/packages/nuxt-mcp-toolkit/src/runtime/server/mcp/definitions/results.ts b/packages/nuxt-mcp-toolkit/src/runtime/server/mcp/definitions/results.ts new file mode 100644 index 0000000..1986891 --- /dev/null +++ b/packages/nuxt-mcp-toolkit/src/runtime/server/mcp/definitions/results.ts @@ -0,0 +1,68 @@ +import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js' + +/** + * Create a text result for an MCP tool response. + * + * @example + * ```ts + * export default defineMcpTool({ + * handler: async () => textResult('Hello world') + * }) + * ``` + */ +export function textResult(text: string): CallToolResult { + return { content: [{ type: 'text', text }] } +} + +/** + * Create a JSON result for an MCP tool response. + * Automatically stringifies the data. + * + * @param data - The data to serialize as JSON + * @param pretty - Whether to pretty-print the JSON (default: true) + * + * @example + * ```ts + * export default defineMcpTool({ + * handler: async () => jsonResult({ foo: 'bar', count: 42 }) + * }) + * ``` + */ +export function jsonResult(data: unknown, pretty = true): CallToolResult { + const text = pretty ? JSON.stringify(data, null, 2) : JSON.stringify(data) + return { content: [{ type: 'text', text }] } +} + +/** + * Create an error result for an MCP tool response. + * + * @example + * ```ts + * export default defineMcpTool({ + * handler: async () => { + * if (!found) return errorResult('Resource not found') + * return textResult('Success') + * } + * }) + * ``` + */ +export function errorResult(message: string): CallToolResult { + return { content: [{ type: 'text', text: message }], isError: true } +} + +/** + * Create an image result for an MCP tool response. + * + * @param data - Base64-encoded image data + * @param mimeType - The MIME type of the image (e.g., 'image/png', 'image/jpeg') + * + * @example + * ```ts + * export default defineMcpTool({ + * handler: async () => imageResult(base64Data, 'image/png') + * }) + * ``` + */ +export function imageResult(data: string, mimeType: string): CallToolResult { + return { content: [{ type: 'image', data, mimeType }] } +} diff --git a/packages/nuxt-mcp-toolkit/test/results.test.ts b/packages/nuxt-mcp-toolkit/test/results.test.ts new file mode 100644 index 0000000..e17a709 --- /dev/null +++ b/packages/nuxt-mcp-toolkit/test/results.test.ts @@ -0,0 +1,122 @@ +import { describe, it, expect } from 'vitest' +import { textResult, jsonResult, errorResult, imageResult } from '../src/runtime/server/mcp/definitions/results' + +describe('Result Helpers', () => { + describe('textResult', () => { + it('should create a text result', () => { + const result = textResult('Hello world') + + expect(result).toEqual({ + content: [{ type: 'text', text: 'Hello world' }], + }) + }) + + it('should handle empty string', () => { + const result = textResult('') + + expect(result).toEqual({ + content: [{ type: 'text', text: '' }], + }) + }) + + it('should handle special characters', () => { + const result = textResult('Hello\nWorld\t!') + + expect(result).toEqual({ + content: [{ type: 'text', text: 'Hello\nWorld\t!' }], + }) + }) + }) + + describe('jsonResult', () => { + it('should create a JSON result with pretty printing by default', () => { + const data = { foo: 'bar', count: 42 } + const result = jsonResult(data) + + expect(result).toEqual({ + content: [{ type: 'text', text: JSON.stringify(data, null, 2) }], + }) + }) + + it('should create a compact JSON result when pretty is false', () => { + const data = { foo: 'bar', count: 42 } + const result = jsonResult(data, false) + + expect(result).toEqual({ + content: [{ type: 'text', text: JSON.stringify(data) }], + }) + }) + + it('should handle arrays', () => { + const data = [1, 2, 3] + const result = jsonResult(data) + + expect(result).toEqual({ + content: [{ type: 'text', text: JSON.stringify(data, null, 2) }], + }) + }) + + it('should handle nested objects', () => { + const data = { user: { name: 'John', settings: { theme: 'dark' } } } + const result = jsonResult(data) + + expect(result).toEqual({ + content: [{ type: 'text', text: JSON.stringify(data, null, 2) }], + }) + }) + + it('should handle null', () => { + const result = jsonResult(null) + + expect(result).toEqual({ + content: [{ type: 'text', text: 'null' }], + }) + }) + }) + + describe('errorResult', () => { + it('should create an error result', () => { + const result = errorResult('Something went wrong') + + expect(result).toEqual({ + content: [{ type: 'text', text: 'Something went wrong' }], + isError: true, + }) + }) + + it('should set isError to true', () => { + const result = errorResult('Error message') + + expect(result.isError).toBe(true) + }) + }) + + describe('imageResult', () => { + it('should create an image result', () => { + const base64Data = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==' + const result = imageResult(base64Data, 'image/png') + + expect(result).toEqual({ + content: [{ type: 'image', data: base64Data, mimeType: 'image/png' }], + }) + }) + + it('should handle different mime types', () => { + const base64Data = '/9j/4AAQSkZJRg==' + const result = imageResult(base64Data, 'image/jpeg') + + expect(result).toEqual({ + content: [{ type: 'image', data: base64Data, mimeType: 'image/jpeg' }], + }) + }) + + it('should handle webp mime type', () => { + const base64Data = 'UklGRh4AAABXRUJQVlA4' + const result = imageResult(base64Data, 'image/webp') + + expect(result).toEqual({ + content: [{ type: 'image', data: base64Data, mimeType: 'image/webp' }], + }) + }) + }) +}) From ff154a07ae515ebbbdf0ecaf003558a4586fc09d Mon Sep 17 00:00:00 2001 From: Hugo Date: Thu, 27 Nov 2025 15:59:06 +0000 Subject: [PATCH 2/3] feat(module): implement caching for resource (#37) --- .../server/mcp/tools/hello_world.ts | 9 +- .../runtime/server/mcp/definitions/cache.ts | 97 +++++++++++++++++++ .../runtime/server/mcp/definitions/index.ts | 1 + .../server/mcp/definitions/resources.ts | 34 ++++++- .../runtime/server/mcp/definitions/tools.ts | 83 ++-------------- 5 files changed, 140 insertions(+), 84 deletions(-) create mode 100644 packages/nuxt-mcp-toolkit/src/runtime/server/mcp/definitions/cache.ts diff --git a/apps/playground/server/mcp/tools/hello_world.ts b/apps/playground/server/mcp/tools/hello_world.ts index 3c907eb..b7e857f 100644 --- a/apps/playground/server/mcp/tools/hello_world.ts +++ b/apps/playground/server/mcp/tools/hello_world.ts @@ -8,13 +8,6 @@ export default defineMcpTool({ message: z.string().describe('A message to echo back'), }, handler: async ({ message }) => { - return { - content: [ - { - type: 'text', - text: `Echo: ${message}`, - }, - ], - } + return textResult(`Hello, ${message}!`) }, }) diff --git a/packages/nuxt-mcp-toolkit/src/runtime/server/mcp/definitions/cache.ts b/packages/nuxt-mcp-toolkit/src/runtime/server/mcp/definitions/cache.ts new file mode 100644 index 0000000..5659811 --- /dev/null +++ b/packages/nuxt-mcp-toolkit/src/runtime/server/mcp/definitions/cache.ts @@ -0,0 +1,97 @@ +import { defineCachedFunction } from 'nitropack/runtime' +import ms from 'ms' + +/** + * Cache duration strings supported by the `ms` package + */ +export type MsCacheDuration + = '1s' | '5s' | '10s' | '15s' | '30s' | '45s' // seconds + | '1m' | '2m' | '5m' | '10m' | '15m' | '30m' | '45m' // minutes + | '1h' | '2h' | '3h' | '4h' | '6h' | '8h' | '12h' | '24h' // hours + | '1d' | '2d' | '3d' | '7d' | '14d' | '30d' // days + | '1w' | '2w' | '4w' // weeks + | '1 second' | '1 minute' | '1 hour' | '1 day' | '1 week' + | '2 seconds' | '5 seconds' | '10 seconds' | '30 seconds' + | '2 minutes' | '5 minutes' | '10 minutes' | '15 minutes' | '30 minutes' + | '2 hours' | '3 hours' | '6 hours' | '12 hours' | '24 hours' + | '2 days' | '3 days' | '7 days' | '14 days' | '30 days' + | '2 weeks' | '4 weeks' + | (string & Record) + +/** + * Base cache options using Nitro's caching system + * @see https://nitro.build/guide/cache#options + */ +export interface McpCacheOptions { + /** Cache duration as string (e.g. '1h') or milliseconds (required) */ + maxAge: MsCacheDuration | number + /** Duration for stale-while-revalidate */ + staleMaxAge?: number + /** Cache name (auto-generated by default) */ + name?: string + /** Function to generate cache key */ + getKey?: (args: Args) => string + /** Cache group (default: 'mcp') */ + group?: string + /** Enable stale-while-revalidate behavior */ + swr?: boolean +} + +/** + * Cache configuration: string duration, milliseconds, or full options + * @see https://nitro.build/guide/cache#options + */ +export type McpCache = MsCacheDuration | number | McpCacheOptions + +/** + * Parse cache duration to milliseconds + */ +export function parseCacheDuration(duration: MsCacheDuration | number): number { + if (typeof duration === 'number') { + return duration + } + const parsed = ms(duration as Parameters[0]) + if (parsed === undefined) { + throw new Error(`Invalid cache duration: ${duration}`) + } + return parsed +} + +/** + * Create cache options from McpCache config + */ +export function createCacheOptions( + cache: McpCache, + name: string, + defaultGetKey?: (args: Args) => string, +): Record { + if (typeof cache === 'object') { + return { + getKey: defaultGetKey, + ...cache, + maxAge: parseCacheDuration(cache.maxAge), + name: cache.name ?? name, + group: cache.group ?? 'mcp', + } + } + + return { + maxAge: parseCacheDuration(cache), + name, + group: 'mcp', + getKey: defaultGetKey, + } +} + +/** + * Wrap a function with caching + */ +export function wrapWithCache unknown>( + fn: T, + cacheOptions: Record, +): T { + return defineCachedFunction( + fn, + cacheOptions as Parameters[1], + ) as T +} diff --git a/packages/nuxt-mcp-toolkit/src/runtime/server/mcp/definitions/index.ts b/packages/nuxt-mcp-toolkit/src/runtime/server/mcp/definitions/index.ts index cc4c18b..b2fd876 100644 --- a/packages/nuxt-mcp-toolkit/src/runtime/server/mcp/definitions/index.ts +++ b/packages/nuxt-mcp-toolkit/src/runtime/server/mcp/definitions/index.ts @@ -1,3 +1,4 @@ +export * from './cache' export * from './tools' export * from './resources' export * from './prompts' diff --git a/packages/nuxt-mcp-toolkit/src/runtime/server/mcp/definitions/resources.ts b/packages/nuxt-mcp-toolkit/src/runtime/server/mcp/definitions/resources.ts index 6454a4b..944015c 100644 --- a/packages/nuxt-mcp-toolkit/src/runtime/server/mcp/definitions/resources.ts +++ b/packages/nuxt-mcp-toolkit/src/runtime/server/mcp/definitions/resources.ts @@ -3,6 +3,11 @@ import { readFile } from 'node:fs/promises' import { resolve, extname } from 'node:path' import { pathToFileURL } from 'node:url' import { enrichNameTitle } from './utils' +import { type McpCacheOptions, type McpCache, createCacheOptions, wrapWithCache } from './cache' + +// Re-export cache types for convenience +export type McpResourceCacheOptions = McpCacheOptions +export type McpResourceCache = McpCache /** * Annotations for a resource @@ -27,6 +32,14 @@ export interface StandardMcpResourceDefinition { _meta?: Record handler: ReadResourceCallback | ReadResourceTemplateCallback file?: never + /** + * Cache configuration for the resource response + * - string: Duration parsed by `ms` (e.g., '1h', '2 days', '30m') + * - number: Duration in milliseconds + * - object: Full cache options with getKey, group, swr, etc. + * @see https://nitro.build/guide/cache#options + */ + cache?: McpResourceCache } /** @@ -45,6 +58,14 @@ export interface FileMcpResourceDefinition { * Relative to the project root */ file: string + /** + * Cache configuration for the resource response + * - string: Duration parsed by `ms` (e.g., '1h', '2 days', '30m') + * - number: Duration in milliseconds + * - object: Full cache options with getKey, group, swr, etc. + * @see https://nitro.build/guide/cache#options + */ + cache?: McpResourceCache } /** @@ -124,8 +145,6 @@ export function registerResourceFromDefinition( } } catch (error) { - // Return error as content or throw depending on preference - // Throwing will return an error result to the client throw new Error(`Failed to read file ${filePath}: ${error instanceof Error ? error.message : String(error)}`) } } @@ -140,6 +159,17 @@ export function registerResourceFromDefinition( throw new Error(`Resource ${name} is missing a handler`) } + // Wrap handler with cache if cache is defined + if (resource.cache !== undefined) { + const defaultGetKey = (requestUri: URL) => requestUri.pathname.replace(/\//g, '-').replace(/^-/, '') + const cacheOptions = createCacheOptions(resource.cache, `mcp-resource:${name}`, defaultGetKey) + + handler = wrapWithCache( + handler as (...args: unknown[]) => unknown, + cacheOptions, + ) as ReadResourceCallback + } + if (typeof uri === 'string') { return server.registerResource( name, diff --git a/packages/nuxt-mcp-toolkit/src/runtime/server/mcp/definitions/tools.ts b/packages/nuxt-mcp-toolkit/src/runtime/server/mcp/definitions/tools.ts index ac9eca8..500a2d3 100644 --- a/packages/nuxt-mcp-toolkit/src/runtime/server/mcp/definitions/tools.ts +++ b/packages/nuxt-mcp-toolkit/src/runtime/server/mcp/definitions/tools.ts @@ -3,51 +3,13 @@ import type { CallToolResult, ServerRequest, ServerNotification, ToolAnnotations import type { RequestHandlerExtra } from '@modelcontextprotocol/sdk/shared/protocol.js' import type { McpServer, ToolCallback } from '@modelcontextprotocol/sdk/server/mcp.js' import type { ShapeOutput } from '@modelcontextprotocol/sdk/server/zod-compat.js' -import { defineCachedFunction } from 'nitropack/runtime' import { enrichNameTitle } from './utils' -import ms from 'ms' +import { type MsCacheDuration, type McpCacheOptions, type McpCache, createCacheOptions, wrapWithCache } from './cache' -/** - * Cache duration strings supported by the `ms` package - */ -export type MsCacheDuration - = | '1s' | '5s' | '10s' | '15s' | '30s' | '45s' // seconds - | '1m' | '2m' | '5m' | '10m' | '15m' | '30m' | '45m' // minutes - | '1h' | '2h' | '3h' | '4h' | '6h' | '8h' | '12h' | '24h' // hours - | '1d' | '2d' | '3d' | '7d' | '14d' | '30d' // days - | '1w' | '2w' | '4w' // weeks - | '1 second' | '1 minute' | '1 hour' | '1 day' | '1 week' - | '2 seconds' | '5 seconds' | '10 seconds' | '30 seconds' - | '2 minutes' | '5 minutes' | '10 minutes' | '15 minutes' | '30 minutes' - | '2 hours' | '3 hours' | '6 hours' | '12 hours' | '24 hours' - | '2 days' | '3 days' | '7 days' | '14 days' | '30 days' - | '2 weeks' | '4 weeks' - | (string & Record) - -/** - * Cache options for MCP tools using Nitro's caching system - * @see https://nitro.build/guide/cache#options - */ -export interface McpToolCacheOptions { - /** Cache duration as string (e.g. '1h') or milliseconds (required) */ - maxAge: MsCacheDuration | number - /** Duration for stale-while-revalidate */ - staleMaxAge?: number - /** Cache name (auto-generated from tool name by default) */ - name?: string - /** Function to generate cache key from arguments */ - getKey?: (args: Args) => string - /** Cache group (default: 'mcp') */ - group?: string - /** Enable stale-while-revalidate behavior */ - swr?: boolean -} - -/** - * Cache configuration: string duration, milliseconds, or full options - * @see https://nitro.build/guide/cache#options - */ -export type McpToolCache = MsCacheDuration | number | McpToolCacheOptions +// Re-export cache types for convenience +export type { MsCacheDuration } +export type McpToolCacheOptions = McpCacheOptions +export type McpToolCache = McpCache /** * Handler callback type for MCP tools @@ -82,20 +44,6 @@ export interface McpToolDefinition< cache?: McpToolCache : undefined> } -/** - * Parse cache duration to milliseconds - */ -function parseCacheDuration(duration: MsCacheDuration | number): number { - if (typeof duration === 'number') { - return duration - } - const parsed = ms(duration as Parameters[0]) - if (parsed === undefined) { - throw new Error(`Invalid cache duration: ${duration}`) - } - return parsed -} - /** * Register a tool from a McpToolDefinition * @internal @@ -125,24 +73,11 @@ export function registerToolFromDefinition< } : undefined - const cacheOptions = typeof tool.cache === 'object' - ? { - getKey: defaultGetKey, - ...tool.cache, - maxAge: parseCacheDuration(tool.cache.maxAge), - name: tool.cache.name ?? `mcp-tool:${name}`, - group: tool.cache.group ?? 'mcp', - } - : { - maxAge: parseCacheDuration(tool.cache), - name: `mcp-tool:${name}`, - group: 'mcp', - getKey: defaultGetKey, - } + const cacheOptions = createCacheOptions(tool.cache, `mcp-tool:${name}`, defaultGetKey) - handler = defineCachedFunction( - tool.handler as unknown as ToolCallback, - cacheOptions as Parameters[1], + handler = wrapWithCache( + tool.handler as unknown as (...args: unknown[]) => unknown, + cacheOptions, ) as unknown as ToolCallback } From 5b58c1cf8594beee3708d63ec63bf187d38dc022 Mon Sep 17 00:00:00 2001 From: Hugo Richard Date: Thu, 27 Nov 2025 15:59:40 +0000 Subject: [PATCH 3/3] bump version --- packages/nuxt-mcp-toolkit/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/nuxt-mcp-toolkit/package.json b/packages/nuxt-mcp-toolkit/package.json index 37a9ad5..99eb7ab 100644 --- a/packages/nuxt-mcp-toolkit/package.json +++ b/packages/nuxt-mcp-toolkit/package.json @@ -1,6 +1,6 @@ { "name": "@nuxtjs/mcp-toolkit", - "version": "0.3.0", + "version": "0.4.0", "description": "Create MCP servers directly in your Nuxt application. Define tools, resources, and prompts with a simple and intuitive API.", "repository": { "type": "git",