From ef59f9765f1db573eefb0a89882a5ded1d87f89b Mon Sep 17 00:00:00 2001 From: nandanpugalia Date: Mon, 15 Jun 2026 00:58:53 +0530 Subject: [PATCH] feat: add annotation titles to MCP tools --- src/docs-search.ts | 1 + src/server.ts | 180 ++++++++++++++++++++----------------- tests/non-codemode.test.ts | 6 ++ 3 files changed, 104 insertions(+), 83 deletions(-) diff --git a/src/docs-search.ts b/src/docs-search.ts index 9bec6dd..9e79d2c 100644 --- a/src/docs-search.ts +++ b/src/docs-search.ts @@ -75,6 +75,7 @@ export function registerDocsTool(server: McpServer, env: DocsSearchEnv) { ) }, annotations: { + title: 'Search Cloudflare Docs', readOnlyHint: true } }, diff --git a/src/server.ts b/src/server.ts index d970289..57ec622 100644 --- a/src/server.ts +++ b/src/server.ts @@ -334,6 +334,7 @@ async function registerNonCodemodeTools( (operation.description ? `\n\n${operation.description}` : '') const inputSchema = buildInputSchema(operation, path) + const title = operation.summary || `${method.toUpperCase()} ${path}` // account_id is auto-resolved at call time for account-token and // single-account user-token sessions. The MCP SDK validates arguments @@ -365,106 +366,110 @@ async function registerNonCodemodeTools( .describe('Cloudflare account ID. Required for multi-account tokens.') } - server.registerTool(toolName, { description, inputSchema }, async (params) => { - try { - // Build the URL with path parameters substituted - let resolvedPath = path - const pathParams = [...path.matchAll(/\{([^}]+)\}/g)].map((m) => m[1]) - for (const paramName of pathParams) { - let value = params[paramName] as string | undefined - - // Auto-resolve account_id - if (paramName === 'account_id' && !value) { - if (accountId) { - value = accountId - } else if (props?.type === 'user_token' && props.accounts.length === 1) { - value = props.accounts[0].id + server.registerTool( + toolName, + { description, inputSchema, annotations: { title } }, + async (params) => { + try { + // Build the URL with path parameters substituted + let resolvedPath = path + const pathParams = [...path.matchAll(/\{([^}]+)\}/g)].map((m) => m[1]) + for (const paramName of pathParams) { + let value = params[paramName] as string | undefined + + // Auto-resolve account_id + if (paramName === 'account_id' && !value) { + if (accountId) { + value = accountId + } else if (props?.type === 'user_token' && props.accounts.length === 1) { + value = props.accounts[0].id + } } - } - if (!value) { - return { - content: [ - { - type: 'text' as const, - text: `Error: missing required path parameter: ${paramName}` - } - ], - isError: true + if (!value) { + return { + content: [ + { + type: 'text' as const, + text: `Error: missing required path parameter: ${paramName}` + } + ], + isError: true + } } + resolvedPath = resolvedPath.replace(`{${paramName}}`, encodeURIComponent(value)) } - resolvedPath = resolvedPath.replace(`{${paramName}}`, encodeURIComponent(value)) - } - // Build query string - const url = new URL(apiBase + resolvedPath) - if (operation.parameters) { - for (const param of operation.parameters) { - if (param.in === 'query' && params[param.name] !== undefined) { - url.searchParams.set(param.name, String(params[param.name])) + // Build query string + const url = new URL(apiBase + resolvedPath) + if (operation.parameters) { + for (const param of operation.parameters) { + if (param.in === 'query' && params[param.name] !== undefined) { + url.searchParams.set(param.name, String(params[param.name])) + } } } - } - // Build request - const headers: Record = { - Authorization: `Bearer ${apiToken}` - } + // Build request + const headers: Record = { + Authorization: `Bearer ${apiToken}` + } - // Add header parameters - if (operation.parameters) { - for (const param of operation.parameters) { - if (param.in === 'header') { - const headerKey = `header_${param.name.toLowerCase().replace(/-/g, '_')}` - if (params[headerKey] !== undefined) { - headers[param.name] = String(params[headerKey]) + // Add header parameters + if (operation.parameters) { + for (const param of operation.parameters) { + if (param.in === 'header') { + const headerKey = `header_${param.name.toLowerCase().replace(/-/g, '_')}` + if (params[headerKey] !== undefined) { + headers[param.name] = String(params[headerKey]) + } } } } - } - - let requestBody: string | undefined - if (params['body']) { - headers['Content-Type'] = (params['content_type'] as string) || 'application/json' - requestBody = params['body'] as string - } - const response = await fetchWithRetry( - url.toString(), - { - method: method.toUpperCase(), - headers, - body: requestBody - }, - { caller: 'non_codemode_tool_call' } - ) - - const contentType = response.headers.get('content-type') || '' - let result: string - - if (contentType.includes('application/json')) { - const data = await response.json() - result = JSON.stringify(data, null, 2) - } else { - result = await response.text() - } + let requestBody: string | undefined + if (params['body']) { + headers['Content-Type'] = (params['content_type'] as string) || 'application/json' + requestBody = params['body'] as string + } - return { - content: [{ type: 'text' as const, text: truncateResponse(result) }], - isError: !response.ok - } - } catch (error) { - return { - content: [ + const response = await fetchWithRetry( + url.toString(), { - type: 'text' as const, - text: `Error: ${error instanceof Error ? error.message : String(error)}` - } - ], - isError: true + method: method.toUpperCase(), + headers, + body: requestBody + }, + { caller: 'non_codemode_tool_call' } + ) + + const contentType = response.headers.get('content-type') || '' + let result: string + + if (contentType.includes('application/json')) { + const data = await response.json() + result = JSON.stringify(data, null, 2) + } else { + result = await response.text() + } + + return { + content: [{ type: 'text' as const, text: truncateResponse(result) }], + isError: !response.ok + } + } catch (error) { + return { + content: [ + { + type: 'text' as const, + text: `Error: ${error instanceof Error ? error.message : String(error)}` + } + ], + isError: true + } } } - }) + ) } } } @@ -542,6 +547,9 @@ async () => { }`, inputSchema: { code: z.string().describe('JavaScript async arrow function to search the OpenAPI spec') + }, + annotations: { + title: 'Search Cloudflare API' } }, async ({ code }) => { @@ -583,6 +591,9 @@ async () => { description: executeDescription, inputSchema: { code: z.string().describe('JavaScript async arrow function to execute') + }, + annotations: { + title: 'Execute Cloudflare API Code' } }, async ({ code }) => { @@ -613,6 +624,9 @@ async () => { ? `Your Cloudflare account ID. Required — this token has access to multiple accounts: ${props.accounts.map((a) => `${a.id} (${a.name})`).join(', ')}` : 'Your Cloudflare account ID. Optional if you have only one account (will be auto-selected)' ) + }, + annotations: { + title: 'Execute Cloudflare API Code' } }, async ({ code, account_id }) => { diff --git a/tests/non-codemode.test.ts b/tests/non-codemode.test.ts index a42a443..adfaf0a 100644 --- a/tests/non-codemode.test.ts +++ b/tests/non-codemode.test.ts @@ -515,6 +515,9 @@ describe('createServer with codemode=false', () => { expect(toolNames).toContain('get_accounts_workers_scripts') expect(toolNames).toContain('post_accounts_workers_scripts') expect(toolNames).toContain('get_zones_dns_records') + expect(tools['get_accounts_workers_scripts'].annotations?.title).toBe('List Workers') + expect(tools['post_accounts_workers_scripts'].annotations?.title).toBe('Create Worker') + expect(tools['get_zones_dns_records'].annotations?.title).toBe('List DNS Records') // Should NOT have codemode tools expect(toolNames).not.toContain('search') @@ -530,6 +533,7 @@ describe('createServer with codemode=false', () => { const server = await createServer(env, ctx, 'test-token', 'test-account', undefined, true) const docsTool = (server as any)._registeredTools['docs'] + expect(docsTool.annotations?.title).toBe('Search Cloudflare Docs') expect(docsTool.description).toContain( 'This tool should be used to answer any question about Cloudflare products or features' ) @@ -558,6 +562,8 @@ describe('createServer with codemode=false', () => { expect(toolNames).toContain('docs') expect(toolNames).toContain('search') expect(toolNames).toContain('execute') + expect(tools['search'].annotations?.title).toBe('Search Cloudflare API') + expect(tools['execute'].annotations?.title).toBe('Execute Cloudflare API Code') expect(toolNames).not.toContain('get_accounts_workers_scripts') })