Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions src/metrics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,13 @@ export function attachMetrics(server: McpServer, props?: AuthProps): void {
) => ReturnType<McpServer['registerTool']>

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[]) => {
Expand Down
39 changes: 35 additions & 4 deletions src/openapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,28 +53,57 @@ 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
const trailingParam = cleaned.match(/\/\{([^}]+)\}$/)
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]
Expand All @@ -92,6 +121,7 @@ export type JsonObjectSchema = {
*/
export interface NonCodemodeTool {
name: string
title?: string
description: string
inputSchema: JsonObjectSchema
method: HttpMethod
Expand Down Expand Up @@ -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,
Expand Down
3 changes: 2 additions & 1 deletion src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ export async function createServer(props: AuthProps, codemode = true): Promise<M
return server
}

// Track tool_call metrics for every Code-Mode tool registered below.
// Track tool_call metrics for every Code-Mode tool registered below. The
// metrics wrapper also mirrors tool.title into annotations.title.
attachMetrics(server, props)
registerDocsTool(server)
await registerSearchTool(server)
Expand Down
5 changes: 4 additions & 1 deletion src/tools/docs-search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ export const docsToolDescription = `Search the Cloudflare documentation.
/** Wire-format definition used by the precomputed non-Code-Mode tools/list. */
export const DOCS_TOOL: Tool = {
name: 'docs',
title: 'Cloudflare Docs Search',
description: docsToolDescription,
inputSchema: {
$schema: 'https://json-schema.org/draft/2020-12/schema',
Expand Down Expand Up @@ -88,7 +89,7 @@ export const DOCS_TOOL: Tool = {
required: ['results'],
additionalProperties: false
},
annotations: { readOnlyHint: true }
annotations: { title: 'Cloudflare Docs Search', readOnlyHint: true }
}

export async function runDocsTool(query: string) {
Expand All @@ -109,6 +110,7 @@ export function registerDocsTool(server: McpServer) {
server.registerTool(
'docs',
{
title: 'Cloudflare Docs Search',
description: docsToolDescription,
inputSchema: z.object({
query: z.string().describe('Cloudflare documentation search query')
Expand All @@ -125,6 +127,7 @@ export function registerDocsTool(server: McpServer) {
)
}),
annotations: {
title: 'Cloudflare Docs Search',
readOnlyHint: true
}
},
Expand Down
8 changes: 6 additions & 2 deletions src/tools/execute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -304,10 +304,12 @@ 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')
})
}),
annotations: { title: 'Cloudflare API Code Executor' }
},
async ({ code }) => {
try {
Expand All @@ -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 {
Expand Down
4 changes: 2 additions & 2 deletions src/tools/non-codemode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
7 changes: 6 additions & 1 deletion src/tools/search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,10 +106,15 @@ export async function registerSearchTool(server: McpServer): Promise<void> {
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 {
Expand Down
23 changes: 23 additions & 0 deletions tests/executor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
15 changes: 12 additions & 3 deletions tests/helpers/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
}

Expand Down Expand Up @@ -58,9 +62,14 @@ export async function parseMcpResult(res: Response): Promise<McpToolResult> {
export async function callTool(
token: string,
name: string,
args: Record<string, unknown>
args: Record<string, unknown> | null,
options?: { method: 'tools/list' | 'tools/call' }
): Promise<McpToolResult> {
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)
}

Expand Down
57 changes: 56 additions & 1 deletion tests/non-codemode.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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)
Expand Down