From 98ae6fa06eb78ca7ff94b3e05b7ee8469149f925 Mon Sep 17 00:00:00 2001 From: Gautam Sharda Date: Tue, 9 Dec 2025 21:18:49 +0000 Subject: [PATCH 1/4] feat: add research_gcloud_command mcp tool --- packages/gcloud-mcp/src/index.ts | 2 + .../src/tools/research_gcloud_command.test.ts | 156 ++++++++++++++++++ .../src/tools/research_gcloud_command.ts | 117 +++++++++++++ 3 files changed, 275 insertions(+) create mode 100644 packages/gcloud-mcp/src/tools/research_gcloud_command.test.ts create mode 100644 packages/gcloud-mcp/src/tools/research_gcloud_command.ts diff --git a/packages/gcloud-mcp/src/index.ts b/packages/gcloud-mcp/src/index.ts index a33e6fa3..b471fd15 100644 --- a/packages/gcloud-mcp/src/index.ts +++ b/packages/gcloud-mcp/src/index.ts @@ -20,6 +20,7 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import pkg from '../package.json' with { type: 'json' }; import { createRunGcloudCommand } from './tools/run_gcloud_command.js'; +import { createResearchGcloudCommand } from './tools/research_gcloud_command.js'; import * as gcloud from './gcloud.js'; import yargs, { ArgumentsCamelCase, CommandModule } from 'yargs'; import { hideBin } from 'yargs/helpers'; @@ -114,6 +115,7 @@ const main = async () => { ); const acl = createAccessControlList(config.allow, [...default_deny, ...(config.deny ?? [])]); createRunGcloudCommand(acl).register(server); + createResearchGcloudCommand().register(server); await server.connect(new StdioServerTransport()); log.info('🚀 gcloud mcp server started'); diff --git a/packages/gcloud-mcp/src/tools/research_gcloud_command.test.ts b/packages/gcloud-mcp/src/tools/research_gcloud_command.test.ts new file mode 100644 index 00000000..d4d38674 --- /dev/null +++ b/packages/gcloud-mcp/src/tools/research_gcloud_command.test.ts @@ -0,0 +1,156 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { Mock, beforeEach, describe, expect, test, vi } from 'vitest'; +import * as gcloud from '../gcloud.js'; +import { createResearchGcloudCommand } from './research_gcloud_command.js'; + +vi.mock('../gcloud.js'); + +const mockServer = { + registerTool: vi.fn(), +} as unknown as McpServer; + +const getToolImplementation = () => { + expect(mockServer.registerTool).toHaveBeenCalledOnce(); + return (mockServer.registerTool as Mock).mock.calls[0]![2]; +}; + +const createTool = () => { + createResearchGcloudCommand().register(mockServer); + return getToolImplementation(); +}; + +describe('createResearchGcloudCommand', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test('returns help and global flags on success', async () => { + const tool = createTool(); + const inputArgs = ['compute', 'instances', 'list']; + + const helpStdout = '# Help Output'; + // Construct global flags output such that we can verify the slicing logic + // globalFlagsIndex will be 1 (0-based) + // We want lines after it. + const globalStdout = [ + 'PREAMBLE', + 'GLOBAL FLAGS', + 'flag1', + 'flag2', + 'flag3', + 'flag4', + 'flag5', + 'flag6', + 'flag7', + 'flag8', + 'flag9', + 'flag10', + 'flag11-should-be-excluded', + ].join('\n'); + + const mockedInvoke = vi.mocked(gcloud.invoke); + mockedInvoke.mockImplementation(async (args) => { + if (args.includes('--document=style=markdown')) { + return { code: 0, stdout: helpStdout, stderr: '' }; + } + if (args.includes('--format=markdown(global_flags)')) { + return { code: 0, stdout: globalStdout, stderr: '' }; + } + return { code: 1, stdout: '', stderr: 'Unknown command' }; + }); + + const result = await tool({ args: inputArgs }); + + expect(gcloud.invoke).toHaveBeenCalledTimes(2); + // Verify gcloud command for help was called with correct args + expect(gcloud.invoke).toHaveBeenCalledWith([ + 'compute', + 'instances', + 'list', + '--document=style=markdown', + ]); + // Verify gcloud command for global flags was called + expect(gcloud.invoke).toHaveBeenCalledWith(['help', '--format=markdown(global_flags)']); + + const output = result.content[0].text; + expect(output).toContain(helpStdout); + expect(output).toContain('flag1'); + expect(output).toContain('flag10'); + expect(output).not.toContain('PREAMBLE'); + expect(output).not.toContain('flag11-should-be-excluded'); + }); + + test('returns error when help command fails', async () => { + const tool = createTool(); + const inputArgs = ['compute', 'instances', 'list']; + + const mockedInvoke = vi.mocked(gcloud.invoke); + mockedInvoke.mockImplementation(async (args) => { + if (args.includes('--document=style=markdown')) { + return { code: 1, stdout: '', stderr: 'Help command failed' }; + } + return { code: 0, stdout: '', stderr: '' }; + }); + + const result = await tool({ args: inputArgs }); + + // Should stop after first failure + expect(gcloud.invoke).toHaveBeenCalledTimes(1); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Failed to get help'); + expect(result.content[0].text).toContain('Help command failed'); + }); + + test('returns success with warning when global flags command fails', async () => { + const tool = createTool(); + const inputArgs = ['compute', 'instances', 'list']; + const helpStdout = '# Help Output'; + + const mockedInvoke = vi.mocked(gcloud.invoke); + mockedInvoke.mockImplementation(async (args) => { + if (args.includes('--document=style=markdown')) { + return { code: 0, stdout: helpStdout, stderr: '' }; + } + if (args.includes('--format=markdown(global_flags)')) { + return { code: 1, stdout: '', stderr: 'Global flags failed' }; + } + return { code: 0, stdout: '', stderr: '' }; + }); + + const result = await tool({ args: inputArgs }); + + expect(gcloud.invoke).toHaveBeenCalledTimes(2); + expect(result.isError).toBeUndefined(); + expect(result.content[0].text).toContain(helpStdout); + // We expect the result to still be successful, just without global flags + }); + + test('returns error when unexpected exception occurs', async () => { + const tool = createTool(); + const inputArgs = ['compute', 'instances', 'list']; + + const mockedInvoke = vi.mocked(gcloud.invoke); + mockedInvoke.mockRejectedValue(new Error('Unexpected error')); + + const result = await tool({ args: inputArgs }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toBe('Unexpected error'); + }); +}); diff --git a/packages/gcloud-mcp/src/tools/research_gcloud_command.ts b/packages/gcloud-mcp/src/tools/research_gcloud_command.ts new file mode 100644 index 00000000..a3887af2 --- /dev/null +++ b/packages/gcloud-mcp/src/tools/research_gcloud_command.ts @@ -0,0 +1,117 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import * as gcloud from '../gcloud.js'; +import { z } from 'zod'; +import { log } from '../utility/logger.js'; + +export const createResearchGcloudCommand = () => ({ + register: (server: McpServer) => { + server.registerTool( + 'research_gcloud_command', + { + title: 'Research gcloud command', + inputSchema: { + args: z.array(z.string()), + }, + description: `Returns the help documentation for a gcloud command. +Use this tool when you need to understand the usage, flags, and arguments of a specific gcloud command. +This tool mimics the output of 'gcloud help [args]' with markdown formatting.`, + }, + async ({ args }) => { + const toolLogger = log.mcp('research_gcloud_command', args); + + try { + toolLogger.info('Executing research_gcloud_command'); + + // Part 1: gcloud [args] --document=style=markdown + const helpCmdArgs = [...args, '--document=style=markdown']; + const { + code: helpCode, + stdout: helpStdout, + stderr: helpStderr, + } = await gcloud.invoke(helpCmdArgs); + + if (helpCode !== 0) { + return errorTextResult( + `Failed to get help for command '${args.join(' ')}'.\nSTDERR:\n${helpStderr}`, + ); + } + + // Part 2: gcloud help --format="markdown(global_flags)" + // and filter: grep -A10 "GLOBAL FLAGS" | tail -n +2 | head -n 10 + const globalFlagsArgs = ['help', '--format=markdown(global_flags)']; + const { + code: globalCode, + stdout: globalStdout, + stderr: globalStderr, + } = await gcloud.invoke(globalFlagsArgs); + + let globalFlagsOutput = ''; + if (globalCode === 0) { + const lines = globalStdout.split('\n'); + const globalFlagsIndex = lines.findIndex((line) => line.includes('GLOBAL FLAGS')); + + if (globalFlagsIndex !== -1) { + // grep -A10 "GLOBAL FLAGS" includes the match and 10 lines after. + // tail -n +2 skips the first line (the match). + // head -n 10 takes the next 10 lines. + // So we want lines from globalFlagsIndex + 1 to globalFlagsIndex + 1 + 10 (exclusive) + globalFlagsOutput = lines + .slice(globalFlagsIndex + 1, globalFlagsIndex + 11) + .join('\n'); + } + } else { + toolLogger.warn(`Failed to get global flags help.\nSTDERR:\n${globalStderr}`); + } + + const result = ` + +Please provide relevant context for the gcloud command and flags: +${args.join(' ')}. + +Output of gcloud ${args.join(' ')} --document=style=markdown: +${helpStdout} + +Output of gcloud help --format="markdown(global_flags)" | grep -A10 "GLOBAL FLAGS" | tail -n +2 | head -n 10: +${globalFlagsOutput} +`; + + return successfulTextResult(result); + } catch (e: unknown) { + toolLogger.error( + 'research_gcloud_command failed', + e instanceof Error ? e : new Error(String(e)), + ); + const msg = e instanceof Error ? e.message : 'An unknown error occurred.'; + return errorTextResult(msg); + } + }, + ); + }, +}); + +type TextResultType = { content: [{ type: 'text'; text: string }]; isError?: boolean }; + +const successfulTextResult = (text: string): TextResultType => ({ + content: [{ type: 'text', text }], +}); + +const errorTextResult = (text: string): TextResultType => ({ + content: [{ type: 'text', text }], + isError: true, +}); From 8b5420b8b21d70acf6e661b922a4884a5709b984 Mon Sep 17 00:00:00 2001 From: Gautam Sharda Date: Tue, 9 Dec 2025 21:21:40 +0000 Subject: [PATCH 2/4] fix: research_gcloud_command.test.ts lint (npm run fix) --- packages/gcloud-mcp/src/tools/research_gcloud_command.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/gcloud-mcp/src/tools/research_gcloud_command.test.ts b/packages/gcloud-mcp/src/tools/research_gcloud_command.test.ts index d4d38674..b4843c82 100644 --- a/packages/gcloud-mcp/src/tools/research_gcloud_command.test.ts +++ b/packages/gcloud-mcp/src/tools/research_gcloud_command.test.ts @@ -5,7 +5,7 @@ * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * - * http://www.apache.org/licenses/LICENSE-2.0 + * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, From 6f92535bb32ab08c4192f6518c3a622b91a903c5 Mon Sep 17 00:00:00 2001 From: Gautam Sharda Date: Thu, 11 Dec 2025 02:14:13 +0000 Subject: [PATCH 3/4] feat: inputs / outputs are more descriptive, accurate, and fit for the overall orchestration design: describe arguments / command parts, update tool description to emphasize it's a pre-requisite, standardize tool output to json, handle output on error --- .../src/tools/research_gcloud_command.test.ts | 40 ++++++---- .../src/tools/research_gcloud_command.ts | 74 +++++++++++++++---- 2 files changed, 85 insertions(+), 29 deletions(-) diff --git a/packages/gcloud-mcp/src/tools/research_gcloud_command.test.ts b/packages/gcloud-mcp/src/tools/research_gcloud_command.test.ts index b4843c82..99d6d784 100644 --- a/packages/gcloud-mcp/src/tools/research_gcloud_command.test.ts +++ b/packages/gcloud-mcp/src/tools/research_gcloud_command.test.ts @@ -5,7 +5,7 @@ * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * - * http://www.apache.org/licenses/LICENSE-2.0 + * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, @@ -75,7 +75,7 @@ describe('createResearchGcloudCommand', () => { return { code: 1, stdout: '', stderr: 'Unknown command' }; }); - const result = await tool({ args: inputArgs }); + const result = await tool({ command_parts: inputArgs }); expect(gcloud.invoke).toHaveBeenCalledTimes(2); // Verify gcloud command for help was called with correct args @@ -88,12 +88,14 @@ describe('createResearchGcloudCommand', () => { // Verify gcloud command for global flags was called expect(gcloud.invoke).toHaveBeenCalledWith(['help', '--format=markdown(global_flags)']); - const output = result.content[0].text; - expect(output).toContain(helpStdout); - expect(output).toContain('flag1'); - expect(output).toContain('flag10'); - expect(output).not.toContain('PREAMBLE'); - expect(output).not.toContain('flag11-should-be-excluded'); + const output = JSON.parse(result.content[0].text); + expect(output.status).toBe('success'); + expect(output.documentation).toContain(helpStdout); + expect(output.documentation).toContain('flag1'); + expect(output.documentation).toContain('flag10'); + expect(output.documentation).not.toContain('PREAMBLE'); + expect(output.documentation).not.toContain('flag11-should-be-excluded'); + expect(output.instructions_for_agent.next_step).toBe('VERIFY'); }); test('returns error when help command fails', async () => { @@ -108,13 +110,16 @@ describe('createResearchGcloudCommand', () => { return { code: 0, stdout: '', stderr: '' }; }); - const result = await tool({ args: inputArgs }); + const result = await tool({ command_parts: inputArgs }); // Should stop after first failure expect(gcloud.invoke).toHaveBeenCalledTimes(1); expect(result.isError).toBe(true); - expect(result.content[0].text).toContain('Failed to get help'); - expect(result.content[0].text).toContain('Help command failed'); + const output = JSON.parse(result.content[0].text); + expect(output.status).toBe('failure'); + expect(output.reason).toBe('invalid command or group'); + expect(output.instructions_for_agent.next_step).toBe('RESEARCH'); + expect(output.error_details).toContain('Help command failed'); }); test('returns success with warning when global flags command fails', async () => { @@ -133,11 +138,13 @@ describe('createResearchGcloudCommand', () => { return { code: 0, stdout: '', stderr: '' }; }); - const result = await tool({ args: inputArgs }); + const result = await tool({ command_parts: inputArgs }); expect(gcloud.invoke).toHaveBeenCalledTimes(2); expect(result.isError).toBeUndefined(); - expect(result.content[0].text).toContain(helpStdout); + const output = JSON.parse(result.content[0].text); + expect(output.status).toBe('success'); + expect(output.documentation).toContain(helpStdout); // We expect the result to still be successful, just without global flags }); @@ -148,9 +155,12 @@ describe('createResearchGcloudCommand', () => { const mockedInvoke = vi.mocked(gcloud.invoke); mockedInvoke.mockRejectedValue(new Error('Unexpected error')); - const result = await tool({ args: inputArgs }); + const result = await tool({ command_parts: inputArgs }); expect(result.isError).toBe(true); - expect(result.content[0].text).toBe('Unexpected error'); + const output = JSON.parse(result.content[0].text); + expect(output.status).toBe('failure'); + expect(output.reason).toBe('execution error'); + expect(output.error).toBe('Unexpected error'); }); }); diff --git a/packages/gcloud-mcp/src/tools/research_gcloud_command.ts b/packages/gcloud-mcp/src/tools/research_gcloud_command.ts index a3887af2..6d6b0e67 100644 --- a/packages/gcloud-mcp/src/tools/research_gcloud_command.ts +++ b/packages/gcloud-mcp/src/tools/research_gcloud_command.ts @@ -26,13 +26,25 @@ export const createResearchGcloudCommand = () => ({ { title: 'Research gcloud command', inputSchema: { - args: z.array(z.string()), + command_parts: z + .array(z.string()) + .describe( + "The ordered list of command groups and the command itself. Example: for `gcloud compute instances list`, pass `['compute', 'instances', 'list']`. Do not include flags starting with `--`.", + ), }, - description: `Returns the help documentation for a gcloud command. -Use this tool when you need to understand the usage, flags, and arguments of a specific gcloud command. -This tool mimics the output of 'gcloud help [args]' with markdown formatting.`, + description: `Retrieves the official help text and reference documentation for a Google Cloud CLI (gcloud) command. + +**CRITICAL INSTRUCTION**: This is a MANDATORY PRECURSOR to the \`run_gcloud_command\` tool. You must use this tool to 'read the manual' before attempting to execute any command. + +**Workflow**: +1. **Research**: Call this tool with the target command path (e.g., \`['compute', 'instances', 'list']\`). Do NOT include flags (e.g., \`--project\`, \`--zone\`) in the input arguments. +2. **Verify**: The tool returns the official documentation. You must STOP and analyze this text. Check if your intended flags exist and if your argument syntax is correct. +3. **Execute**: Only after this verification step is complete, proceed to call \`run_gcloud_command\` with the validated arguments. + +Use this tool to prevent syntax errors and hallucinated flags.`, }, - async ({ args }) => { + async ({ command_parts }) => { + const args = command_parts as string[]; const toolLogger = log.mcp('research_gcloud_command', args); try { @@ -47,9 +59,24 @@ This tool mimics the output of 'gcloud help [args]' with markdown formatting.`, } = await gcloud.invoke(helpCmdArgs); if (helpCode !== 0) { - return errorTextResult( + toolLogger.error( `Failed to get help for command '${args.join(' ')}'.\nSTDERR:\n${helpStderr}`, ); + return errorTextResult( + JSON.stringify( + { + status: 'failure', + reason: 'invalid command or group', + instructions_for_agent: { + next_step: 'RESEARCH', + guidance: 'STOP making assumptions. Perform a search for the correct command.', + }, + error_details: helpStderr, + }, + null, + 2, + ), + ); } // Part 2: gcloud help --format="markdown(global_flags)" @@ -79,18 +106,27 @@ This tool mimics the output of 'gcloud help [args]' with markdown formatting.`, toolLogger.warn(`Failed to get global flags help.\nSTDERR:\n${globalStderr}`); } - const result = ` - -Please provide relevant context for the gcloud command and flags: -${args.join(' ')}. - -Output of gcloud ${args.join(' ')} --document=style=markdown: + const combinedDocumentation = ` ${helpStdout} -Output of gcloud help --format="markdown(global_flags)" | grep -A10 "GLOBAL FLAGS" | tail -n +2 | head -n 10: +## GLOBAL FLAGS (Partial) ${globalFlagsOutput} `; + const result = JSON.stringify( + { + status: 'success', + documentation: combinedDocumentation, + instructions_for_agent: { + next_step: 'VERIFY', + guidance: + "Compare your user's request against the above documentation. Identify any missing required flags. Ensure the command description aligns with the goal. Confirm the syntax for 'zone' and 'project'. Formulate the final command arguments to strictly adhere to this documentation.", + }, + }, + null, + 2, + ); + return successfulTextResult(result); } catch (e: unknown) { toolLogger.error( @@ -98,7 +134,17 @@ ${globalFlagsOutput} e instanceof Error ? e : new Error(String(e)), ); const msg = e instanceof Error ? e.message : 'An unknown error occurred.'; - return errorTextResult(msg); + return errorTextResult( + JSON.stringify( + { + status: 'failure', + reason: 'execution error', + error: msg, + }, + null, + 2, + ), + ); } }, ); From fa1637227d5e393a0bb93e92a5352091584fb16c Mon Sep 17 00:00:00 2001 From: Gautam Sharda Date: Thu, 11 Dec 2025 02:22:47 +0000 Subject: [PATCH 4/4] fix: research_gcloud_command.test.ts lint (npm run fix) --- packages/gcloud-mcp/src/tools/research_gcloud_command.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/gcloud-mcp/src/tools/research_gcloud_command.test.ts b/packages/gcloud-mcp/src/tools/research_gcloud_command.test.ts index 99d6d784..cfc009ed 100644 --- a/packages/gcloud-mcp/src/tools/research_gcloud_command.test.ts +++ b/packages/gcloud-mcp/src/tools/research_gcloud_command.test.ts @@ -5,7 +5,7 @@ * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * - * http://www.apache.org/licenses/LICENSE-2.0 + * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS,