diff --git a/src/metrics.ts b/src/metrics.ts index 91572c9..243c49e 100644 --- a/src/metrics.ts +++ b/src/metrics.ts @@ -255,6 +255,13 @@ export function attachMetrics(server: McpServer, props?: AuthProps): void { ) => ReturnType server.registerTool = ((name: string, ...rest: unknown[]) => { + const config = rest[0] as { title?: string; annotations?: { title?: string } } | undefined + // Mirror any tool.title into annotations.title so clients consistently see + // a display label regardless of which field they prefer. + if (config?.title && config.annotations && config.annotations.title === undefined) { + config.annotations.title = config.title + } + const lastIndex = rest.length - 1 const cb = rest[lastIndex] as (...cbArgs: unknown[]) => unknown rest[lastIndex] = (...cbArgs: unknown[]) => { diff --git a/src/openapi.ts b/src/openapi.ts index 2be9ea7..b63e5e3 100644 --- a/src/openapi.ts +++ b/src/openapi.ts @@ -53,6 +53,10 @@ declare const spec: { * e.g. GET /accounts/{account_id}/workers/scripts → get_accounts_workers_scripts */ export function pathToToolName(method: string, path: string): string { + return `${method.toLowerCase()}_${pathToToolNameSuffix(path)}` +} + +function pathToToolNameSuffix(path: string): string { let cleaned = path // Check if path ends with a {param} — keep it for disambiguation @@ -60,21 +64,46 @@ export function pathToToolName(method: string, path: string): string { const suffix = trailingParam ? `_by_${trailingParam[1]}` : '' const name = - method.toLowerCase() + - '_' + cleaned .replace(/^\//, '') .replace(/\/\{[^}]+\}/g, '') // strip all {param} segments .replace(/\//g, '_') .replace(/[^a-z0-9_]/gi, '') .replace(/_+/g, '_') - .replace(/_$/, '') + - suffix + .replace(/_$/, '') + suffix // MCP spec: tool names SHOULD be between 1 and 128 characters return name.length > 128 ? name.slice(0, 128).replace(/_$/, '') : name } +/** + * Build a human-readable title for a non-Code-Mode tool from its + * machine-friendly name. The title mirrors the tool name structure but with + * each segment title-cased so clients can display consistent, readable labels. + * + * e.g. get_accounts_workers_scripts → Get Accounts Workers Scripts + */ +export function toolNameToTitle(name: string): string { + const withPrepositions = name + .replace(/_by_/g, ' by ') + .replace(/_for_/g, ' for ') + .replace(/_in_/g, ' in ') + .replace(/_of_/g, ' of ') + .replace(/_on_/g, ' on ') + .replace(/_to_/g, ' to ') + .replace(/_with_/g, ' with ') + return withPrepositions.replace(/_/g, ' ').replace(/\b\w/g, (letter, offset) => + // Keep prepositions lower-case when preceded by a space and followed by a space/end. + offset > 0 && + /\s/.test(withPrepositions[offset - 1] ?? '') && + ['by ', 'for ', 'in ', 'of ', 'on ', 'to ', 'with '].some((prep) => + withPrepositions.slice(offset).toLowerCase().startsWith(prep) + ) + ? letter.toLowerCase() + : letter.toUpperCase() + ) +} + const HTTP_METHODS = ['get', 'post', 'put', 'patch', 'delete'] as const export type HttpMethod = (typeof HTTP_METHODS)[number] @@ -92,6 +121,7 @@ export type JsonObjectSchema = { */ export interface NonCodemodeTool { name: string + title?: string description: string inputSchema: JsonObjectSchema method: HttpMethod @@ -159,6 +189,7 @@ export function buildNonCodemodeTools( return listNonCodemodeOperations(paths).map( ({ toolName, description, method, path, operation }) => ({ name: toolName, + title: toolNameToTitle(toolName), description, inputSchema: buildJsonInputSchema(operation, path), method, diff --git a/src/server.ts b/src/server.ts index 840566e..a986b46 100644 --- a/src/server.ts +++ b/src/server.ts @@ -15,7 +15,8 @@ export async function createServer(props: AuthProps, codemode = true): Promise { try { @@ -324,11 +326,13 @@ export function registerExecuteTool(server: McpServer, props: AuthProps): void { server.registerTool( 'execute', { + title: 'Cloudflare API Code Executor', description, inputSchema: z.object({ code: z.string().describe('JavaScript async arrow function to execute'), account_id: z.string().optional().describe(accountIdParamDescription()) - }) + }), + annotations: { title: 'Cloudflare API Code Executor' } }, async ({ code, account_id }) => { try { diff --git a/src/tools/non-codemode.ts b/src/tools/non-codemode.ts index 2a8f80b..a858415 100644 --- a/src/tools/non-codemode.ts +++ b/src/tools/non-codemode.ts @@ -132,8 +132,8 @@ function toolError(message: string): CallToolResult { } function toWireTool(tool: NonCodemodeTool): Tool { - const { name, description, inputSchema } = tool - return { name, description, inputSchema } + const { name, title, description, inputSchema } = tool + return { name, title, description, inputSchema } } function toolForAccountAccess( diff --git a/src/tools/search.ts b/src/tools/search.ts index 339bffc..d5bae22 100644 --- a/src/tools/search.ts +++ b/src/tools/search.ts @@ -106,10 +106,15 @@ export async function registerSearchTool(server: McpServer): Promise { server.registerTool( 'search', { + title: 'Cloudflare API Spec Search', description: searchToolDescription(products), inputSchema: z.object({ code: z.string().describe('JavaScript async arrow function to search the OpenAPI spec') - }) + }), + annotations: { + title: 'Cloudflare API Spec Search', + readOnlyHint: true + } }, async ({ code }) => { try { diff --git a/tests/executor.test.ts b/tests/executor.test.ts index 261feab..95c779a 100644 --- a/tests/executor.test.ts +++ b/tests/executor.test.ts @@ -40,6 +40,29 @@ async function runExecute(path: string, body: unknown, init?: ResponseInit): Pro return toolText(result) } +describe('codemode tool titles', () => { + it('exposes a title on the execute tool', async () => { + await seedSpec({}) + mockIdentityProbe({ accounts: [{ id: ACCOUNT_ID, name: 'Acc' }] }) + + const result = await callTool(API_TOKEN, 'execute', null, { method: 'tools/list' }) + const tool = result.result?.tools?.find((t: { name: string }) => t.name === 'execute') + expect(tool?.annotations?.title).toBe('Cloudflare API Code Executor') + expect(tool?.title).toBe('Cloudflare API Code Executor') + }) + + it('exposes a title on the search tool', async () => { + await seedSpec({}) + mockIdentityProbe({ accounts: [{ id: ACCOUNT_ID, name: 'Acc' }] }) + + const result = await callTool(API_TOKEN, 'search', null, { method: 'tools/list' }) + const tool = result.result?.tools?.find((t: { name: string }) => t.name === 'search') + expect(tool?.title).toBe('Cloudflare API Spec Search') + expect(tool?.annotations?.readOnlyHint).toBe(true) + expect(tool?.annotations?.title).toBe('Cloudflare API Spec Search') + }) +}) + describe('execute: REST responses', () => { it('returns the success envelope with the response status', async () => { const text = await runExecute( diff --git a/tests/helpers/mcp.ts b/tests/helpers/mcp.ts index 11d7534..94a5631 100644 --- a/tests/helpers/mcp.ts +++ b/tests/helpers/mcp.ts @@ -2,7 +2,11 @@ import { exports } from 'cloudflare:workers' /** Result envelope of an MCP `tools/call` over Streamable HTTP. */ export interface McpToolResult { - result?: { content?: Array<{ type: string; text: string }>; isError?: boolean } + result?: { + content?: Array<{ type: string; text: string }> + isError?: boolean + tools?: Array<{ name: string; title?: string; annotations?: { title?: string; readOnlyHint?: boolean } }> + } error?: { code: number; message: string } } @@ -58,9 +62,14 @@ export async function parseMcpResult(res: Response): Promise { export async function callTool( token: string, name: string, - args: Record + args: Record | null, + options?: { method: 'tools/list' | 'tools/call' } ): Promise { - const res = await exports.default.fetch(mcpToolCallRequest(token, name, args)) + const req = + options?.method === 'tools/list' + ? mcpToolListRequest(token) + : mcpToolCallRequest(token, name, args ?? {}) + const res = await exports.default.fetch(req) return parseMcpResult(res) } diff --git a/tests/non-codemode.test.ts b/tests/non-codemode.test.ts index fe6ae47..bd91499 100644 --- a/tests/non-codemode.test.ts +++ b/tests/non-codemode.test.ts @@ -2,7 +2,12 @@ import { afterEach, describe, it, expect, vi } from 'vitest' import { Client } from '@modelcontextprotocol/client' import { InMemoryTransport, McpServer } from '@modelcontextprotocol/server' import { createServer } from '../src/server' -import { buildInputSchema, buildNonCodemodeTools, pathToToolName } from '../src/openapi' +import { + buildInputSchema, + buildNonCodemodeTools, + pathToToolName, + toolNameToTitle +} from '../src/openapi' import type { OperationInfo } from '../src/openapi' import { AUTH_PROPS_VERSION, type AuthProps } from '../src/auth/types' import { DOCS_TOOL, registerDocsTool } from '../src/tools/docs-search' @@ -52,6 +57,33 @@ describe('precomputed tool contracts', () => { expect(JSON.parse(JSON.stringify(await listTools(server)))).toEqual([DOCS_TOOL]) }) + + it('includes annotations.title on the docs tool', async () => { + const server = new McpServer({ name: 'docs-title-test', version: '1.0.0' }) + registerDocsTool(server) + + const [tool] = await listTools(server) + expect(tool.name).toBe('docs') + expect(tool.title).toBe('Cloudflare Docs Search') + expect(tool.annotations?.title).toBe('Cloudflare Docs Search') + expect(tool.annotations?.readOnlyHint).toBe(true) + }) +}) + +describe('toolNameToTitle', () => { + it('title-cases a non-codemode tool name', () => { + expect(toolNameToTitle('get_accounts_workers_scripts')).toBe('Get Accounts Workers Scripts') + }) + + it('expands the trailing by-param suffix', () => { + expect(toolNameToTitle('get_zones_dns_records_by_record_id')).toBe( + 'Get Zones Dns Records by Record Id' + ) + }) + + it('handles short tool names', () => { + expect(toolNameToTitle('get_user')).toBe('Get User') + }) }) describe('pathToToolName', () => { @@ -616,6 +648,29 @@ describe('createServer with codemode=false', () => { expect(toolNames).not.toContain('execute') }) + it('exposes a title for each non-codemode endpoint', async () => { + const specPaths = { + '/accounts/{account_id}/workers/scripts': { + get: { summary: 'List Workers' } as OperationInfo + }, + '/accounts/{account_id}/workers/scripts/{script_name}': { + get: { summary: 'Get Worker' } as OperationInfo + } + } + + await seedSpec(specPaths) + const server = await createServer(acctProps('test-account'), false) + + const tools = await listTools(server) + const listTool = tools.find((tool) => tool.name === 'get_accounts_workers_scripts') + const getTool = tools.find( + (tool) => tool.name === 'get_accounts_workers_scripts_by_script_name' + ) + + expect(listTool?.title).toBe('Get Accounts Workers Scripts') + expect(getTool?.title).toBe('Get Accounts Workers Scripts by Script Name') + }) + it('registers docs with the Cloudflare docs server description and output schema', async () => { await seedSpec({}) const server = await createServer(acctProps('test-account'), true)