Skip to content

Commit fe731a8

Browse files
fix: add annotation titles to all MCP tools (cloudflare#149) (cloudflare#172)
* fix: add annotation titles to all MCP tools (cloudflare#149) * chore: remove PR body artifact --------- Co-authored-by: agent-think[bot] <agent-think[bot]@users.noreply.github.com> Co-authored-by: Matt Carey <mcarey@cloudflare.com> Co-authored-by: Matt <77928207+mattzcarey@users.noreply.github.com>
1 parent e1aebc7 commit fe731a8

10 files changed

Lines changed: 153 additions & 15 deletions

File tree

src/metrics.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -255,6 +255,13 @@ export function attachMetrics(server: McpServer, props?: AuthProps): void {
255255
) => ReturnType<McpServer['registerTool']>
256256

257257
server.registerTool = ((name: string, ...rest: unknown[]) => {
258+
const config = rest[0] as { title?: string; annotations?: { title?: string } } | undefined
259+
// Mirror any tool.title into annotations.title so clients consistently see
260+
// a display label regardless of which field they prefer.
261+
if (config?.title && config.annotations && config.annotations.title === undefined) {
262+
config.annotations.title = config.title
263+
}
264+
258265
const lastIndex = rest.length - 1
259266
const cb = rest[lastIndex] as (...cbArgs: unknown[]) => unknown
260267
rest[lastIndex] = (...cbArgs: unknown[]) => {

src/openapi.ts

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -53,28 +53,57 @@ declare const spec: {
5353
* e.g. GET /accounts/{account_id}/workers/scripts → get_accounts_workers_scripts
5454
*/
5555
export function pathToToolName(method: string, path: string): string {
56+
return `${method.toLowerCase()}_${pathToToolNameSuffix(path)}`
57+
}
58+
59+
function pathToToolNameSuffix(path: string): string {
5660
let cleaned = path
5761

5862
// Check if path ends with a {param} — keep it for disambiguation
5963
const trailingParam = cleaned.match(/\/\{([^}]+)\}$/)
6064
const suffix = trailingParam ? `_by_${trailingParam[1]}` : ''
6165

6266
const name =
63-
method.toLowerCase() +
64-
'_' +
6567
cleaned
6668
.replace(/^\//, '')
6769
.replace(/\/\{[^}]+\}/g, '') // strip all {param} segments
6870
.replace(/\//g, '_')
6971
.replace(/[^a-z0-9_]/gi, '')
7072
.replace(/_+/g, '_')
71-
.replace(/_$/, '') +
72-
suffix
73+
.replace(/_$/, '') + suffix
7374

7475
// MCP spec: tool names SHOULD be between 1 and 128 characters
7576
return name.length > 128 ? name.slice(0, 128).replace(/_$/, '') : name
7677
}
7778

79+
/**
80+
* Build a human-readable title for a non-Code-Mode tool from its
81+
* machine-friendly name. The title mirrors the tool name structure but with
82+
* each segment title-cased so clients can display consistent, readable labels.
83+
*
84+
* e.g. get_accounts_workers_scripts → Get Accounts Workers Scripts
85+
*/
86+
export function toolNameToTitle(name: string): string {
87+
const withPrepositions = name
88+
.replace(/_by_/g, ' by ')
89+
.replace(/_for_/g, ' for ')
90+
.replace(/_in_/g, ' in ')
91+
.replace(/_of_/g, ' of ')
92+
.replace(/_on_/g, ' on ')
93+
.replace(/_to_/g, ' to ')
94+
.replace(/_with_/g, ' with ')
95+
return withPrepositions.replace(/_/g, ' ').replace(/\b\w/g, (letter, offset) =>
96+
// Keep prepositions lower-case when preceded by a space and followed by a space/end.
97+
offset > 0 &&
98+
/\s/.test(withPrepositions[offset - 1] ?? '') &&
99+
['by ', 'for ', 'in ', 'of ', 'on ', 'to ', 'with '].some((prep) =>
100+
withPrepositions.slice(offset).toLowerCase().startsWith(prep)
101+
)
102+
? letter.toLowerCase()
103+
: letter.toUpperCase()
104+
)
105+
}
106+
78107
const HTTP_METHODS = ['get', 'post', 'put', 'patch', 'delete'] as const
79108

80109
export type HttpMethod = (typeof HTTP_METHODS)[number]
@@ -92,6 +121,7 @@ export type JsonObjectSchema = {
92121
*/
93122
export interface NonCodemodeTool {
94123
name: string
124+
title?: string
95125
description: string
96126
inputSchema: JsonObjectSchema
97127
method: HttpMethod
@@ -159,6 +189,7 @@ export function buildNonCodemodeTools(
159189
return listNonCodemodeOperations(paths).map(
160190
({ toolName, description, method, path, operation }) => ({
161191
name: toolName,
192+
title: toolNameToTitle(toolName),
162193
description,
163194
inputSchema: buildJsonInputSchema(operation, path),
164195
method,

src/server.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@ export async function createServer(props: AuthProps, codemode = true): Promise<M
1515
return server
1616
}
1717

18-
// Track tool_call metrics for every Code-Mode tool registered below.
18+
// Track tool_call metrics for every Code-Mode tool registered below. The
19+
// metrics wrapper also mirrors tool.title into annotations.title.
1920
attachMetrics(server, props)
2021
registerDocsTool(server)
2122
await registerSearchTool(server)

src/tools/docs-search.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ export const docsToolDescription = `Search the Cloudflare documentation.
5656
/** Wire-format definition used by the precomputed non-Code-Mode tools/list. */
5757
export const DOCS_TOOL: Tool = {
5858
name: 'docs',
59+
title: 'Cloudflare Docs Search',
5960
description: docsToolDescription,
6061
inputSchema: {
6162
$schema: 'https://json-schema.org/draft/2020-12/schema',
@@ -88,7 +89,7 @@ export const DOCS_TOOL: Tool = {
8889
required: ['results'],
8990
additionalProperties: false
9091
},
91-
annotations: { readOnlyHint: true }
92+
annotations: { title: 'Cloudflare Docs Search', readOnlyHint: true }
9293
}
9394

9495
export async function runDocsTool(query: string) {
@@ -109,6 +110,7 @@ export function registerDocsTool(server: McpServer) {
109110
server.registerTool(
110111
'docs',
111112
{
113+
title: 'Cloudflare Docs Search',
112114
description: docsToolDescription,
113115
inputSchema: z.object({
114116
query: z.string().describe('Cloudflare documentation search query')
@@ -125,6 +127,7 @@ export function registerDocsTool(server: McpServer) {
125127
)
126128
}),
127129
annotations: {
130+
title: 'Cloudflare Docs Search',
128131
readOnlyHint: true
129132
}
130133
},

src/tools/execute.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -304,10 +304,12 @@ export function registerExecuteTool(server: McpServer, props: AuthProps): void {
304304
server.registerTool(
305305
'execute',
306306
{
307+
title: 'Cloudflare API Code Executor',
307308
description,
308309
inputSchema: z.object({
309310
code: z.string().describe('JavaScript async arrow function to execute')
310-
})
311+
}),
312+
annotations: { title: 'Cloudflare API Code Executor' }
311313
},
312314
async ({ code }) => {
313315
try {
@@ -324,11 +326,13 @@ export function registerExecuteTool(server: McpServer, props: AuthProps): void {
324326
server.registerTool(
325327
'execute',
326328
{
329+
title: 'Cloudflare API Code Executor',
327330
description,
328331
inputSchema: z.object({
329332
code: z.string().describe('JavaScript async arrow function to execute'),
330333
account_id: z.string().optional().describe(accountIdParamDescription())
331-
})
334+
}),
335+
annotations: { title: 'Cloudflare API Code Executor' }
332336
},
333337
async ({ code, account_id }) => {
334338
try {

src/tools/non-codemode.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -132,8 +132,8 @@ function toolError(message: string): CallToolResult {
132132
}
133133

134134
function toWireTool(tool: NonCodemodeTool): Tool {
135-
const { name, description, inputSchema } = tool
136-
return { name, description, inputSchema }
135+
const { name, title, description, inputSchema } = tool
136+
return { name, title, description, inputSchema }
137137
}
138138

139139
function toolForAccountAccess(

src/tools/search.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,10 +106,15 @@ export async function registerSearchTool(server: McpServer): Promise<void> {
106106
server.registerTool(
107107
'search',
108108
{
109+
title: 'Cloudflare API Spec Search',
109110
description: searchToolDescription(products),
110111
inputSchema: z.object({
111112
code: z.string().describe('JavaScript async arrow function to search the OpenAPI spec')
112-
})
113+
}),
114+
annotations: {
115+
title: 'Cloudflare API Spec Search',
116+
readOnlyHint: true
117+
}
113118
},
114119
async ({ code }) => {
115120
try {

tests/executor.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,29 @@ async function runExecute(path: string, body: unknown, init?: ResponseInit): Pro
4040
return toolText(result)
4141
}
4242

43+
describe('codemode tool titles', () => {
44+
it('exposes a title on the execute tool', async () => {
45+
await seedSpec({})
46+
mockIdentityProbe({ accounts: [{ id: ACCOUNT_ID, name: 'Acc' }] })
47+
48+
const result = await callTool(API_TOKEN, 'execute', null, { method: 'tools/list' })
49+
const tool = result.result?.tools?.find((t: { name: string }) => t.name === 'execute')
50+
expect(tool?.annotations?.title).toBe('Cloudflare API Code Executor')
51+
expect(tool?.title).toBe('Cloudflare API Code Executor')
52+
})
53+
54+
it('exposes a title on the search tool', async () => {
55+
await seedSpec({})
56+
mockIdentityProbe({ accounts: [{ id: ACCOUNT_ID, name: 'Acc' }] })
57+
58+
const result = await callTool(API_TOKEN, 'search', null, { method: 'tools/list' })
59+
const tool = result.result?.tools?.find((t: { name: string }) => t.name === 'search')
60+
expect(tool?.title).toBe('Cloudflare API Spec Search')
61+
expect(tool?.annotations?.readOnlyHint).toBe(true)
62+
expect(tool?.annotations?.title).toBe('Cloudflare API Spec Search')
63+
})
64+
})
65+
4366
describe('execute: REST responses', () => {
4467
it('returns the success envelope with the response status', async () => {
4568
const text = await runExecute(

tests/helpers/mcp.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,11 @@ import { exports } from 'cloudflare:workers'
22

33
/** Result envelope of an MCP `tools/call` over Streamable HTTP. */
44
export interface McpToolResult {
5-
result?: { content?: Array<{ type: string; text: string }>; isError?: boolean }
5+
result?: {
6+
content?: Array<{ type: string; text: string }>
7+
isError?: boolean
8+
tools?: Array<{ name: string; title?: string; annotations?: { title?: string; readOnlyHint?: boolean } }>
9+
}
610
error?: { code: number; message: string }
711
}
812

@@ -58,9 +62,14 @@ export async function parseMcpResult(res: Response): Promise<McpToolResult> {
5862
export async function callTool(
5963
token: string,
6064
name: string,
61-
args: Record<string, unknown>
65+
args: Record<string, unknown> | null,
66+
options?: { method: 'tools/list' | 'tools/call' }
6267
): Promise<McpToolResult> {
63-
const res = await exports.default.fetch(mcpToolCallRequest(token, name, args))
68+
const req =
69+
options?.method === 'tools/list'
70+
? mcpToolListRequest(token)
71+
: mcpToolCallRequest(token, name, args ?? {})
72+
const res = await exports.default.fetch(req)
6473
return parseMcpResult(res)
6574
}
6675

tests/non-codemode.test.ts

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,12 @@ import { afterEach, describe, it, expect, vi } from 'vitest'
22
import { Client } from '@modelcontextprotocol/client'
33
import { InMemoryTransport, McpServer } from '@modelcontextprotocol/server'
44
import { createServer } from '../src/server'
5-
import { buildInputSchema, buildNonCodemodeTools, pathToToolName } from '../src/openapi'
5+
import {
6+
buildInputSchema,
7+
buildNonCodemodeTools,
8+
pathToToolName,
9+
toolNameToTitle
10+
} from '../src/openapi'
611
import type { OperationInfo } from '../src/openapi'
712
import { AUTH_PROPS_VERSION, type AuthProps } from '../src/auth/types'
813
import { DOCS_TOOL, registerDocsTool } from '../src/tools/docs-search'
@@ -52,6 +57,33 @@ describe('precomputed tool contracts', () => {
5257

5358
expect(JSON.parse(JSON.stringify(await listTools(server)))).toEqual([DOCS_TOOL])
5459
})
60+
61+
it('includes annotations.title on the docs tool', async () => {
62+
const server = new McpServer({ name: 'docs-title-test', version: '1.0.0' })
63+
registerDocsTool(server)
64+
65+
const [tool] = await listTools(server)
66+
expect(tool.name).toBe('docs')
67+
expect(tool.title).toBe('Cloudflare Docs Search')
68+
expect(tool.annotations?.title).toBe('Cloudflare Docs Search')
69+
expect(tool.annotations?.readOnlyHint).toBe(true)
70+
})
71+
})
72+
73+
describe('toolNameToTitle', () => {
74+
it('title-cases a non-codemode tool name', () => {
75+
expect(toolNameToTitle('get_accounts_workers_scripts')).toBe('Get Accounts Workers Scripts')
76+
})
77+
78+
it('expands the trailing by-param suffix', () => {
79+
expect(toolNameToTitle('get_zones_dns_records_by_record_id')).toBe(
80+
'Get Zones Dns Records by Record Id'
81+
)
82+
})
83+
84+
it('handles short tool names', () => {
85+
expect(toolNameToTitle('get_user')).toBe('Get User')
86+
})
5587
})
5688

5789
describe('pathToToolName', () => {
@@ -616,6 +648,29 @@ describe('createServer with codemode=false', () => {
616648
expect(toolNames).not.toContain('execute')
617649
})
618650

651+
it('exposes a title for each non-codemode endpoint', async () => {
652+
const specPaths = {
653+
'/accounts/{account_id}/workers/scripts': {
654+
get: { summary: 'List Workers' } as OperationInfo
655+
},
656+
'/accounts/{account_id}/workers/scripts/{script_name}': {
657+
get: { summary: 'Get Worker' } as OperationInfo
658+
}
659+
}
660+
661+
await seedSpec(specPaths)
662+
const server = await createServer(acctProps('test-account'), false)
663+
664+
const tools = await listTools(server)
665+
const listTool = tools.find((tool) => tool.name === 'get_accounts_workers_scripts')
666+
const getTool = tools.find(
667+
(tool) => tool.name === 'get_accounts_workers_scripts_by_script_name'
668+
)
669+
670+
expect(listTool?.title).toBe('Get Accounts Workers Scripts')
671+
expect(getTool?.title).toBe('Get Accounts Workers Scripts by Script Name')
672+
})
673+
619674
it('registers docs with the Cloudflare docs server description and output schema', async () => {
620675
await seedSpec({})
621676
const server = await createServer(acctProps('test-account'), true)

0 commit comments

Comments
 (0)