diff --git a/packages/toolbox-adk/test/e2e/jest.globalSetup.ts b/packages/toolbox-adk/test/e2e/jest.globalSetup.ts index 72703d22..351e954e 100644 --- a/packages/toolbox-adk/test/e2e/jest.globalSetup.ts +++ b/packages/toolbox-adk/test/e2e/jest.globalSetup.ts @@ -57,102 +57,134 @@ export default async function globalSetup(): Promise { const localToolboxPath = path.resolve(__dirname, TOOLBOX_BINARY_NAME); + const bucketName = 'mcp-toolbox-for-databases'; console.log( - `Downloading toolbox binary from gs://mcp-toolbox-for-databases/${toolboxGcsPath} to ${localToolboxPath}...`, - ); - await downloadBlob( - 'mcp-toolbox-for-databases', - toolboxGcsPath, - localToolboxPath, + `Downloading toolbox binary from gs://${bucketName}/${toolboxGcsPath} to ${localToolboxPath}...`, ); + await downloadBlob(bucketName, toolboxGcsPath, localToolboxPath); console.log('Toolbox binary downloaded successfully.'); // Make toolbox executable await fs.chmod(localToolboxPath, 0o700); - // Start toolbox server - console.log('Starting toolbox server process...'); - const serverProcess = spawn( + // Start toolbox servers + console.log('Starting toolbox server processes...'); + const serverProcess1 = spawn( localToolboxPath, - ['--tools-file', toolsFilePath], + ['--port', '5000', '--tools-file', toolsFilePath], { - stdio: ['ignore', 'pipe', 'pipe'], // ignore stdin, pipe stdout/stderr + stdio: ['ignore', 'pipe', 'pipe'], + }, + ); + const serverProcess2 = spawn( + localToolboxPath, + ['--port', '5001', '--tools-file', toolsFilePath, '--enable-draft-specs'], + { + stdio: ['ignore', 'pipe', 'pipe'], }, ); - (globalThis as CustomGlobal).__TOOLBOX_SERVER_PROCESS__ = serverProcess; + (globalThis as CustomGlobal).__TOOLBOX_SERVER_PROCESS__ = serverProcess1; + (globalThis as CustomGlobal).__TOOLBOX_SERVER_PROCESS_2__ = serverProcess2; - serverProcess.stdout?.on('data', (data: Buffer) => { - console.log(`[ToolboxServer STDOUT]: ${data.toString().trim()}`); + serverProcess1.stdout?.on('data', (data: Buffer) => { + console.log(`[ToolboxServer1 STDOUT]: ${data.toString().trim()}`); }); - - serverProcess.stderr?.on('data', (data: Buffer) => { - console.error(`[ToolboxServer STDERR]: ${data.toString().trim()}`); + serverProcess1.stderr?.on('data', (data: Buffer) => { + console.error(`[ToolboxServer1 STDERR]: ${data.toString().trim()}`); }); - - serverProcess.on('error', err => { - console.error('Toolbox server process error:', err); - throw new Error('Failed to start toolbox server process.'); + serverProcess1.on('error', err => { + console.error('Toolbox server 1 process error:', err); + throw new Error('Failed to start toolbox server 1 process.'); }); - - serverProcess.on('exit', (code, signal) => { + serverProcess1.on('exit', (code, signal) => { console.log( - `Toolbox server process exited with code ${code}, signal ${signal}.`, + `Toolbox server 1 process exited with code ${code}, signal ${signal}.`, ); if ( (globalThis as CustomGlobal).__TOOLBOX_SERVER_PROCESS__ && !(globalThis as CustomGlobal).__SERVER_TEARDOWN_INITIATED__ ) { - console.error('Toolbox server exited prematurely during setup.'); + console.error('Toolbox server 1 exited prematurely during setup.'); + } + }); + + serverProcess2.stdout?.on('data', (data: Buffer) => { + console.log(`[ToolboxServer2 STDOUT]: ${data.toString().trim()}`); + }); + serverProcess2.stderr?.on('data', (data: Buffer) => { + console.error(`[ToolboxServer2 STDERR]: ${data.toString().trim()}`); + }); + serverProcess2.on('error', err => { + console.error('Toolbox server 2 process error:', err); + throw new Error('Failed to start toolbox server 2 process.'); + }); + serverProcess2.on('exit', (code, signal) => { + console.log( + `Toolbox server 2 process exited with code ${code}, signal ${signal}.`, + ); + if ( + (globalThis as CustomGlobal).__TOOLBOX_SERVER_PROCESS_2__ && + !(globalThis as CustomGlobal).__SERVER_TEARDOWN_INITIATED__ + ) { + console.error('Toolbox server 2 exited prematurely during setup.'); } }); - // Wait for server to start (basic poll check) - let started = false; + // Wait for servers to start (basic poll check) + let started1 = false; + let started2 = false; const startTime = Date.now(); while (Date.now() - startTime < SERVER_READY_TIMEOUT_MS) { if ( - serverProcess.pid && - !serverProcess.killed && - serverProcess.exitCode === null + serverProcess1.pid && + !serverProcess1.killed && + serverProcess1.exitCode === null ) { + started1 = true; + } + if ( + serverProcess2.pid && + !serverProcess2.killed && + serverProcess2.exitCode === null + ) { + started2 = true; + } + if (started1 && started2) { console.log( - 'Toolbox server process appears to be running. Polling for stability...', + 'Both Toolbox servers started successfully (processes are active).', ); - await delay(SERVER_READY_POLL_INTERVAL_MS * 2); - if (serverProcess.exitCode === null) { - console.log( - 'Toolbox server started successfully (process is active).', - ); - started = true; - break; - } else { - console.log('Toolbox server process exited after initial start.'); - break; - } + break; } await delay(SERVER_READY_POLL_INTERVAL_MS); - console.log('Checking if toolbox server is started...'); + console.log('Checking if toolbox servers are started...'); } - if (!started) { - if (serverProcess && !serverProcess.killed) { - serverProcess.kill('SIGTERM'); - } + if (!started1 || !started2) { + if (serverProcess1 && !serverProcess1.killed) + serverProcess1.kill('SIGTERM'); + if (serverProcess2 && !serverProcess2.killed) + serverProcess2.kill('SIGTERM'); throw new Error( - `Toolbox server failed to start within ${SERVER_READY_TIMEOUT_MS / 1000} seconds.`, + `Toolbox servers failed to start within ${SERVER_READY_TIMEOUT_MS / 1000} seconds.`, ); } console.log('Jest Global Setup: Completed successfully.'); } catch (error) { console.error('Jest Global Setup Failed:', error); - // Attempt to kill server if it started partially - const serverProcess = (globalThis as CustomGlobal) + // Attempt to kill servers if they started partially + const serverProcess1 = (globalThis as CustomGlobal) .__TOOLBOX_SERVER_PROCESS__; - if (serverProcess && !serverProcess.killed) { - console.log('Attempting to terminate partially started server...'); - serverProcess.kill('SIGKILL'); + const serverProcess2 = (globalThis as CustomGlobal) + .__TOOLBOX_SERVER_PROCESS_2__; + if (serverProcess1 && !serverProcess1.killed) { + console.log('Attempting to terminate partially started server 1...'); + serverProcess1.kill('SIGKILL'); + } + if (serverProcess2 && !serverProcess2.killed) { + console.log('Attempting to terminate partially started server 2...'); + serverProcess2.kill('SIGKILL'); } // Clean up temp file if created const toolsFilePath = (globalThis as CustomGlobal).__TOOLS_FILE_PATH__; diff --git a/packages/toolbox-adk/test/e2e/jest.globalTeardown.ts b/packages/toolbox-adk/test/e2e/jest.globalTeardown.ts index 0b61fccd..e0a6c0a9 100644 --- a/packages/toolbox-adk/test/e2e/jest.globalTeardown.ts +++ b/packages/toolbox-adk/test/e2e/jest.globalTeardown.ts @@ -13,6 +13,7 @@ // limitations under the License. import * as fs from 'fs-extra'; +import {ChildProcess} from 'child_process'; import {CustomGlobal} from './types.js'; const SERVER_TERMINATE_TIMEOUT_MS = 10000; // 10 seconds @@ -22,50 +23,67 @@ export default async function globalTeardown(): Promise { (globalThis as CustomGlobal).__SERVER_TEARDOWN_INITIATED__ = true; const customGlobal = globalThis as CustomGlobal; - const serverProcess = customGlobal.__TOOLBOX_SERVER_PROCESS__; + const serverProcess1 = customGlobal.__TOOLBOX_SERVER_PROCESS__; + const serverProcess2 = customGlobal.__TOOLBOX_SERVER_PROCESS_2__; const toolsFilePath = customGlobal.__TOOLS_FILE_PATH__; - if (serverProcess && !serverProcess.killed) { - console.log('Stopping toolbox server process...'); - serverProcess.kill('SIGTERM'); // Graceful termination + const killServer = async ( + serverProcess: ChildProcess | undefined, + name: string, + ) => { + if (serverProcess && !serverProcess.killed) { + console.log(`Stopping toolbox server ${name} process...`); + serverProcess.kill('SIGTERM'); // Graceful termination - // Wait for the process to exit - const stopPromise = new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - if (!serverProcess.killed) { - console.warn( - 'Toolbox server did not terminate gracefully, sending SIGKILL.', - ); - serverProcess.kill('SIGKILL'); - } - // Resolve even if SIGKILL is needed, as we want teardown to finish - resolve(); - }, SERVER_TERMINATE_TIMEOUT_MS); + // Wait for the process to exit + const stopPromise = new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + if (!serverProcess.killed) { + console.warn( + `Toolbox server ${name} did not terminate gracefully, sending SIGKILL.`, + ); + serverProcess.kill('SIGKILL'); + } + // Resolve even if SIGKILL is needed, as we want teardown to finish + resolve(); + }, SERVER_TERMINATE_TIMEOUT_MS); - serverProcess.on('exit', (code, signal) => { - clearTimeout(timeout); - console.log( - `Toolbox server process exited with code ${code}, signal ${signal} during teardown.`, + serverProcess.on( + 'exit', + (code: number | null, signal: NodeJS.Signals | null) => { + clearTimeout(timeout); + console.log( + `Toolbox server ${name} process exited with code ${code}, signal ${signal} during teardown.`, + ); + resolve(); + }, ); - resolve(); - }); - serverProcess.on('error', err => { - // Should not happen if already running - clearTimeout(timeout); - console.error('Error during server process termination:', err); - reject(err); + serverProcess.on('error', (err: Error) => { + // Should not happen if already running + clearTimeout(timeout); + console.error( + `Error during server ${name} process termination:`, + err, + ); + reject(err); + }); }); - }); - try { - await stopPromise; - } catch (error) { - console.error('Error while waiting for server to stop:', error); - if (!serverProcess.killed) serverProcess.kill('SIGKILL'); // Ensure it's killed + try { + await stopPromise; + } catch (error) { + console.error(`Error while waiting for server ${name} to stop:`, error); + if (!serverProcess.killed) serverProcess.kill('SIGKILL'); // Ensure it's killed + } + } else { + console.log( + `Toolbox server ${name} process was not running or already handled.`, + ); } - } else { - console.log('Toolbox server process was not running or already handled.'); - } + }; + + await killServer(serverProcess1, '1'); + await killServer(serverProcess2, '2'); if (toolsFilePath) { try { @@ -79,6 +97,7 @@ export default async function globalTeardown(): Promise { } } customGlobal.__TOOLBOX_SERVER_PROCESS__ = undefined; + customGlobal.__TOOLBOX_SERVER_PROCESS_2__ = undefined; customGlobal.__TOOLS_FILE_PATH__ = undefined; customGlobal.__GOOGLE_CLOUD_PROJECT__ = undefined; diff --git a/packages/toolbox-adk/test/e2e/test.e2e.ts b/packages/toolbox-adk/test/e2e/test.e2e.ts index 180478e7..caf2f5f5 100644 --- a/packages/toolbox-adk/test/e2e/test.e2e.ts +++ b/packages/toolbox-adk/test/e2e/test.e2e.ts @@ -24,356 +24,364 @@ import {Context} from '@google/adk'; import {authTokenGetter} from './utils.js'; -describe('ToolboxClient E2E Tests', () => { - let commonToolboxClient: ToolboxClient; - let getNRowsTool: ToolboxTool; - const testBaseUrl = 'http://localhost:5000'; - const projectId = (globalThis as CustomGlobal).__GOOGLE_CLOUD_PROJECT__; - - const mockContext = {} as Context; - - beforeAll(async () => { - commonToolboxClient = new ToolboxClient( - testBaseUrl, - undefined, - undefined, - Protocol.MCP, - ); - }); +const testBaseUrls = ['http://localhost:5000', 'http://localhost:5001']; - beforeEach(async () => { - getNRowsTool = await commonToolboxClient.loadTool('get-n-rows'); - expect(getNRowsTool.name).toBe('get-n-rows'); - }); +testBaseUrls.forEach(testBaseUrl => { + describe(`ToolboxClient E2E Tests against ${testBaseUrl}`, () => { + let commonToolboxClient: ToolboxClient; + let getNRowsTool: ToolboxTool; + const projectId = (globalThis as CustomGlobal).__GOOGLE_CLOUD_PROJECT__; - describe('invokeTool', () => { - it('should invoke the getNRowsTool', async () => { - const response = await getNRowsTool.runAsync({ - args: {num_rows: '2'}, - toolContext: mockContext, - }); - expect(typeof response).toBe('string'); - expect(response).toContain('row1'); - expect(response).toContain('row2'); - expect(response).not.toContain('row3'); - }); + const mockContext = {} as Context; - it('should invoke the getNRowsTool with missing params', async () => { - await expect( - getNRowsTool.runAsync({args: {}, toolContext: mockContext}), - ).rejects.toThrow( - /Argument validation failed for tool "get-n-rows":\s*- num_rows: Required/, + beforeAll(async () => { + commonToolboxClient = new ToolboxClient( + testBaseUrl, + undefined, + undefined, + Protocol.MCP, ); }); - it('should invoke the getNRowsTool with wrong param type', async () => { - await expect( - getNRowsTool.runAsync({ - args: {num_rows: 2}, - toolContext: mockContext, - }), - ).rejects.toThrow( - /Argument validation failed for tool "get-n-rows":\s*- num_rows: Expected string, received number/, - ); + beforeEach(async () => { + getNRowsTool = await commonToolboxClient.loadTool('get-n-rows'); + expect(getNRowsTool.name).toBe('get-n-rows'); }); - }); - describe('loadToolset', () => { - const specificToolsetTestCases = [ - { - name: 'my-toolset', - expectedLength: 1, - expectedTools: ['get-row-by-id'], - }, - { - name: 'my-toolset-2', - expectedLength: 2, - expectedTools: ['get-n-rows', 'get-row-by-id'], - }, - ]; - - specificToolsetTestCases.forEach(testCase => { - it(`should successfully load the specific toolset "${testCase.name}"`, async () => { - const loadedTools = await commonToolboxClient.loadToolset( - testCase.name, - ); - - expect(Array.isArray(loadedTools)).toBe(true); - expect(loadedTools.length).toBe(testCase.expectedLength); + describe('invokeTool', () => { + it('should invoke the getNRowsTool', async () => { + const response = await getNRowsTool.runAsync({ + args: {num_rows: '2'}, + toolContext: mockContext, + }); + expect(typeof response).toBe('string'); + expect(response).toContain('row1'); + expect(response).toContain('row2'); + expect(response).not.toContain('row3'); + }); - const loadedToolNames = new Set( - loadedTools.map((tool: ToolboxTool) => tool.name), + it('should invoke the getNRowsTool with missing params', async () => { + await expect( + getNRowsTool.runAsync({args: {}, toolContext: mockContext}), + ).rejects.toThrow( + /Argument validation failed for tool "get-n-rows":\s*- num_rows: Required/, ); - expect(loadedToolNames).toEqual(new Set(testCase.expectedTools)); - - for (const tool of loadedTools) { - expect(typeof tool).toBe('object'); - expect(tool).toBeInstanceOf(ToolboxTool); - - expect(tool).toBeInstanceOf(ToolboxTool); - const declaration = tool._getDeclaration(); - expect(declaration).toBeDefined(); + }); - expect(testCase.expectedTools).toContain(declaration!.name); - expect(declaration!.parameters).toBeDefined(); - } + it('should invoke the getNRowsTool with wrong param type', async () => { + await expect( + getNRowsTool.runAsync({ + args: {num_rows: 2}, + toolContext: mockContext, + }), + ).rejects.toThrow( + /Argument validation failed for tool "get-n-rows":\s*- num_rows: Expected string, received number/, + ); }); }); - it('should successfully load the default toolset (all tools)', async () => { - const loadedTools = await commonToolboxClient.loadToolset(); // Load the default toolset (no name provided) - expect(Array.isArray(loadedTools)).toBe(true); - expect(loadedTools.length).toBeGreaterThan(0); - const getNRowsToolFromSet = loadedTools.find( - (tool: ToolboxTool) => tool.name === 'get-n-rows', - ) as ToolboxTool; - - expect(getNRowsToolFromSet).toBeDefined(); - const declaration = getNRowsToolFromSet._getDeclaration(); - expect(declaration).toBeDefined(); - expect(declaration?.name).toBe('get-n-rows'); - expect(declaration?.parameters).toBeDefined(); - - const loadedToolNames = new Set( - loadedTools.map((tool: ToolboxTool) => tool.name), - ); - const expectedDefaultTools = new Set([ - 'get-row-by-content-auth', - 'get-row-by-email-auth', - 'get-row-by-id-auth', - 'get-row-by-id', - 'get-n-rows', - 'search-rows', - 'process-data', - ]); - expect(loadedToolNames).toEqual(expectedDefaultTools); - }); + describe('loadToolset', () => { + const specificToolsetTestCases = [ + { + name: 'my-toolset', + expectedLength: 1, + expectedTools: ['get-row-by-id'], + }, + { + name: 'my-toolset-2', + expectedLength: 2, + expectedTools: ['get-n-rows', 'get-row-by-id'], + }, + ]; - it('should throw an error when trying to load a non-existent toolset', async () => { - await expect( - commonToolboxClient.loadToolset('non-existent-toolset'), - ).rejects.toThrow( - 'MCP request failed with code -32600: toolset does not exist', - ); - }); - }); + specificToolsetTestCases.forEach(testCase => { + it(`should successfully load the specific toolset "${testCase.name}"`, async () => { + const loadedTools = await commonToolboxClient.loadToolset( + testCase.name, + ); - describe('bindParams', () => { - it('should successfully bind a parameter with bindParam and invoke', async () => { - const newTool = getNRowsTool.bindParam('num_rows', '3'); - const response = await newTool.runAsync({ - args: {}, - toolContext: mockContext, - }); // Invoke with no args - expect(response).toContain('row1'); - expect(response).toContain('row2'); - expect(response).toContain('row3'); - expect(response).not.toContain('row4'); - }); + expect(Array.isArray(loadedTools)).toBe(true); + expect(loadedTools.length).toBe(testCase.expectedLength); - it('should successfully bind parameters with bindParams and invoke', async () => { - const newTool = getNRowsTool.bindParams({num_rows: '3'}); - const response = await newTool.runAsync({ - args: {}, - toolContext: mockContext, - }); // Invoke with no args - expect(response).toContain('row1'); - expect(response).toContain('row2'); - expect(response).toContain('row3'); - expect(response).not.toContain('row4'); - }); + const loadedToolNames = new Set( + loadedTools.map((tool: ToolboxTool) => tool.name), + ); + expect(loadedToolNames).toEqual(new Set(testCase.expectedTools)); + + for (const tool of loadedTools) { + expect(typeof tool).toBe('object'); + expect(tool).toBeInstanceOf(ToolboxTool); - it('should successfully bind a synchronous function value', async () => { - const newTool = getNRowsTool.bindParams({num_rows: () => '1'}); - const response = await newTool.runAsync({ - args: {}, - toolContext: mockContext, + expect(tool).toBeInstanceOf(ToolboxTool); + const declaration = tool._getDeclaration(); + expect(declaration).toBeDefined(); + + expect(testCase.expectedTools).toContain(declaration!.name); + expect(declaration!.parameters).toBeDefined(); + } + }); }); - expect(response).toContain('row1'); - expect(response).not.toContain('row2'); - }); - it('should successfully bind an asynchronous function value', async () => { - const asyncNumProvider = async () => { - await new Promise(resolve => setTimeout(resolve, 10)); - return '1'; - }; + it('should successfully load the default toolset (all tools)', async () => { + const loadedTools = await commonToolboxClient.loadToolset(); // Load the default toolset (no name provided) + expect(Array.isArray(loadedTools)).toBe(true); + expect(loadedTools.length).toBeGreaterThan(0); + const getNRowsToolFromSet = loadedTools.find( + (tool: ToolboxTool) => tool.name === 'get-n-rows', + ) as ToolboxTool; - const newTool = getNRowsTool.bindParams({num_rows: asyncNumProvider}); - const response = await newTool.runAsync({ - args: {}, - toolContext: mockContext, + expect(getNRowsToolFromSet).toBeDefined(); + const declaration = getNRowsToolFromSet._getDeclaration(); + expect(declaration).toBeDefined(); + expect(declaration?.name).toBe('get-n-rows'); + expect(declaration?.parameters).toBeDefined(); + + const loadedToolNames = new Set( + loadedTools.map((tool: ToolboxTool) => tool.name), + ); + const expectedDefaultTools = new Set([ + 'get-row-by-content-auth', + 'get-row-by-email-auth', + 'get-row-by-id-auth', + 'get-row-by-id', + 'get-n-rows', + 'search-rows', + 'process-data', + ]); + expect(loadedToolNames).toEqual(expectedDefaultTools); + }); + + it('should throw an error when trying to load a non-existent toolset', async () => { + await expect( + commonToolboxClient.loadToolset('non-existent-toolset'), + ).rejects.toThrow( + 'MCP request failed with code -32600: toolset does not exist', + ); }); - expect(response).toContain('row1'); - expect(response).not.toContain('row2'); }); - it('should successfully bind parameters at load time', async () => { - const tool = await commonToolboxClient.loadTool('get-n-rows', null, { - num_rows: '3', + describe('bindParams', () => { + it('should successfully bind a parameter with bindParam and invoke', async () => { + const newTool = getNRowsTool.bindParam('num_rows', '3'); + const response = await newTool.runAsync({ + args: {}, + toolContext: mockContext, + }); // Invoke with no args + expect(response).toContain('row1'); + expect(response).toContain('row2'); + expect(response).toContain('row3'); + expect(response).not.toContain('row4'); }); - const response = await tool.runAsync({ - args: {}, - toolContext: mockContext, + + it('should successfully bind parameters with bindParams and invoke', async () => { + const newTool = getNRowsTool.bindParams({num_rows: '3'}); + const response = await newTool.runAsync({ + args: {}, + toolContext: mockContext, + }); // Invoke with no args + expect(response).toContain('row1'); + expect(response).toContain('row2'); + expect(response).toContain('row3'); + expect(response).not.toContain('row4'); }); - expect(response).toContain('row1'); - expect(response).toContain('row2'); - expect(response).toContain('row3'); - expect(response).not.toContain('row4'); - }); - it('should throw an error when re-binding an existing parameter', () => { - const newTool = getNRowsTool.bindParam('num_rows', '1'); - expect(() => { - newTool.bindParam('num_rows', '2'); - }).toThrow( - "Cannot re-bind parameter: parameter 'num_rows' is already bound in tool 'get-n-rows'.", - ); - }); + it('should successfully bind a synchronous function value', async () => { + const newTool = getNRowsTool.bindParams({num_rows: () => '1'}); + const response = await newTool.runAsync({ + args: {}, + toolContext: mockContext, + }); + expect(response).toContain('row1'); + expect(response).not.toContain('row2'); + }); - it('should throw an error when binding a non-existent parameter', () => { - expect(() => { - getNRowsTool.bindParam('non_existent_param', '2'); - }).toThrow( - "Unable to bind parameter: no parameter named 'non_existent_param' in tool 'get-n-rows'.", - ); - }); - }); + it('should successfully bind an asynchronous function value', async () => { + const asyncNumProvider = async () => { + await new Promise(resolve => setTimeout(resolve, 10)); + return '1'; + }; + + const newTool = getNRowsTool.bindParams({num_rows: asyncNumProvider}); + const response = await newTool.runAsync({ + args: {}, + toolContext: mockContext, + }); + expect(response).toContain('row1'); + expect(response).not.toContain('row2'); + }); - describe('Auth E2E Tests', () => { - let authToken1: string; - let authToken2: string; - let authToken1Getter: () => string; - let authToken2Getter: () => string; + it('should successfully bind parameters at load time', async () => { + const tool = await commonToolboxClient.loadTool('get-n-rows', null, { + num_rows: '3', + }); + const response = await tool.runAsync({ + args: {}, + toolContext: mockContext, + }); + expect(response).toContain('row1'); + expect(response).toContain('row2'); + expect(response).toContain('row3'); + expect(response).not.toContain('row4'); + }); - beforeAll(async () => { - if (!projectId) { - throw new Error( - 'GOOGLE_CLOUD_PROJECT is not defined. Cannot run Auth E2E tests.', + it('should throw an error when re-binding an existing parameter', () => { + const newTool = getNRowsTool.bindParam('num_rows', '1'); + expect(() => { + newTool.bindParam('num_rows', '2'); + }).toThrow( + "Cannot re-bind parameter: parameter 'num_rows' is already bound in tool 'get-n-rows'.", ); - } - authToken1 = await authTokenGetter(projectId, 'sdk_testing_client1'); - authToken2 = await authTokenGetter(projectId, 'sdk_testing_client2'); - - authToken1Getter = () => authToken1; - authToken2Getter = () => authToken2; - }); + }); - it('should fail when running a tool that does not require auth with auth provided', async () => { - await expect( - commonToolboxClient.loadTool('get-row-by-id', { - 'my-test-auth': authToken2Getter, - }), - ).rejects.toThrow( - "Validation failed for tool 'get-row-by-id': unused auth tokens: my-test-auth", - ); + it('should throw an error when binding a non-existent parameter', () => { + expect(() => { + getNRowsTool.bindParam('non_existent_param', '2'); + }).toThrow( + "Unable to bind parameter: no parameter named 'non_existent_param' in tool 'get-n-rows'.", + ); + }); }); - it('should fail when running a tool requiring auth without providing auth', async () => { - const tool = await commonToolboxClient.loadTool('get-row-by-id-auth'); - await expect( - tool.runAsync({args: {id: '2'}, toolContext: mockContext}), - ).rejects.toThrow( - 'One or more of the following authn services are required to invoke this tool: my-test-auth', - ); - }); + describe('Auth E2E Tests', () => { + let authToken1: string; + let authToken2: string; + let authToken1Getter: () => string; + let authToken2Getter: () => string; + + beforeAll(async () => { + if (!projectId) { + throw new Error( + 'GOOGLE_CLOUD_PROJECT is not defined. Cannot run Auth E2E tests.', + ); + } + authToken1 = await authTokenGetter(projectId, 'sdk_testing_client1'); + authToken2 = await authTokenGetter(projectId, 'sdk_testing_client2'); - it('should fail when running a tool with incorrect auth', async () => { - const tool = await commonToolboxClient.loadTool('get-row-by-id-auth'); - const authTool = tool.addAuthTokenGetters({ - 'my-test-auth': authToken2Getter, + authToken1Getter = () => authToken1; + authToken2Getter = () => authToken2; }); - try { - await authTool.runAsync({ - args: {id: '2'}, - toolContext: mockContext, - }); - } catch (error) { - expect(error).toBeInstanceOf(AxiosError); - const axiosError = error as AxiosError; - expect(axiosError.response?.data).toEqual( - expect.objectContaining({ - error: expect.objectContaining({ - message: expect.stringContaining('unauthorized Tool call'), - }), + + it('should fail when running a tool that does not require auth with auth provided', async () => { + await expect( + commonToolboxClient.loadTool('get-row-by-id', { + 'my-test-auth': authToken2Getter, }), + ).rejects.toThrow( + "Validation failed for tool 'get-row-by-id': unused auth tokens: my-test-auth", ); - } - }); - - it('should succeed when running a tool with correct auth', async () => { - const tool = await commonToolboxClient.loadTool('get-row-by-id-auth'); - const authTool = tool.addAuthTokenGetters({ - 'my-test-auth': authToken1Getter, }); - const response = await authTool.runAsync({ - args: {id: '2'}, - toolContext: mockContext, + + it('should fail when running a tool requiring auth without providing auth', async () => { + const tool = await commonToolboxClient.loadTool('get-row-by-id-auth'); + await expect( + tool.runAsync({args: {id: '2'}, toolContext: mockContext}), + ).rejects.toThrow( + 'One or more of the following authn services are required to invoke this tool: my-test-auth', + ); }); - expect(response).toContain('row2'); - }); - it('should succeed when running a tool with correct async auth', async () => { - const tool = await commonToolboxClient.loadTool('get-row-by-id-auth'); - const getAsyncToken = async () => { - return authToken1Getter(); - }; - const authTool = tool.addAuthTokenGetters({ - 'my-test-auth': getAsyncToken, + it('should fail when running a tool with incorrect auth', async () => { + const tool = await commonToolboxClient.loadTool('get-row-by-id-auth'); + const authTool = tool.addAuthTokenGetters({ + 'my-test-auth': authToken2Getter, + }); + try { + await authTool.runAsync({ + args: {id: '2'}, + toolContext: mockContext, + }); + } catch (error) { + expect(error).toBeInstanceOf(AxiosError); + const axiosError = error as AxiosError; + expect(axiosError.response?.data).toEqual( + expect.objectContaining({ + error: expect.objectContaining({ + message: expect.stringContaining('unauthorized Tool call'), + }), + }), + ); + } }); - const response = await authTool.runAsync({ - args: {id: '2'}, - toolContext: mockContext, + + it('should succeed when running a tool with correct auth', async () => { + const tool = await commonToolboxClient.loadTool('get-row-by-id-auth'); + const authTool = tool.addAuthTokenGetters({ + 'my-test-auth': authToken1Getter, + }); + const response = await authTool.runAsync({ + args: {id: '2'}, + toolContext: mockContext, + }); + expect(response).toContain('row2'); }); - expect(response).toContain('row2'); - }); - it('should fail when a tool with a param requiring auth is run without auth', async () => { - const tool = await commonToolboxClient.loadTool('get-row-by-email-auth'); - await expect( - tool.runAsync({args: {}, toolContext: mockContext}), - ).rejects.toThrow( - 'One or more of the following authn services are required to invoke this tool: my-test-auth', - ); - }); + it('should succeed when running a tool with correct async auth', async () => { + const tool = await commonToolboxClient.loadTool('get-row-by-id-auth'); + const getAsyncToken = async () => { + return authToken1Getter(); + }; + const authTool = tool.addAuthTokenGetters({ + 'my-test-auth': getAsyncToken, + }); + const response = await authTool.runAsync({ + args: {id: '2'}, + toolContext: mockContext, + }); + expect(response).toContain('row2'); + }); - it('should succeed when a tool with a param requiring auth is run with correct auth', async () => { - const tool = await commonToolboxClient.loadTool('get-row-by-email-auth', { - 'my-test-auth': authToken1Getter, + it('should fail when a tool with a param requiring auth is run without auth', async () => { + const tool = await commonToolboxClient.loadTool( + 'get-row-by-email-auth', + ); + await expect( + tool.runAsync({args: {}, toolContext: mockContext}), + ).rejects.toThrow( + 'One or more of the following authn services are required to invoke this tool: my-test-auth', + ); }); - const response = await tool.runAsync({ - args: {}, - toolContext: mockContext, + + it('should succeed when a tool with a param requiring auth is run with correct auth', async () => { + const tool = await commonToolboxClient.loadTool( + 'get-row-by-email-auth', + { + 'my-test-auth': authToken1Getter, + }, + ); + const response = await tool.runAsync({ + args: {}, + toolContext: mockContext, + }); + expect(response).toContain('row4'); + expect(response).toContain('row5'); + expect(response).toContain('row6'); }); - expect(response).toContain('row4'); - expect(response).toContain('row5'); - expect(response).toContain('row6'); - }); - it('should fail when a tool with a param requiring auth is run with insufficient auth claims', async () => { - expect.assertions(3); + it('should fail when a tool with a param requiring auth is run with insufficient auth claims', async () => { + expect.assertions(3); - const tool = await commonToolboxClient.loadTool( - 'get-row-by-content-auth', - { - 'my-test-auth': authToken1Getter, - }, - ); - try { - await tool.runAsync({args: {}, toolContext: mockContext}); - } catch (error) { - expect(error).toBeInstanceOf(AxiosError); - const axiosError = error as AxiosError; - expect(axiosError.response?.data).toEqual( - expect.objectContaining({ - error: expect.objectContaining({ - message: expect.stringContaining( - 'provided parameters were invalid', - ), - }), - }), + const tool = await commonToolboxClient.loadTool( + 'get-row-by-content-auth', + { + 'my-test-auth': authToken1Getter, + }, ); - } + try { + await tool.runAsync({args: {}, toolContext: mockContext}); + } catch (error) { + expect(error).toBeInstanceOf(AxiosError); + const axiosError = error as AxiosError; + expect(axiosError.response?.data).toEqual( + expect.objectContaining({ + error: expect.objectContaining({ + message: expect.stringContaining( + 'provided parameters were invalid', + ), + }), + }), + ); + } + }); }); }); }); diff --git a/packages/toolbox-adk/test/e2e/types.ts b/packages/toolbox-adk/test/e2e/types.ts index c207831d..efffdbc2 100644 --- a/packages/toolbox-adk/test/e2e/types.ts +++ b/packages/toolbox-adk/test/e2e/types.ts @@ -18,6 +18,7 @@ import {ChildProcess} from 'child_process'; export type CustomGlobal = typeof globalThis & { __TOOLS_FILE_PATH__?: string; __TOOLBOX_SERVER_PROCESS__?: ChildProcess; + __TOOLBOX_SERVER_PROCESS_2__?: ChildProcess; __SERVER_TEARDOWN_INITIATED__?: boolean; __GOOGLE_CLOUD_PROJECT__?: string; }; diff --git a/packages/toolbox-core/src/toolbox_core/client.ts b/packages/toolbox-core/src/toolbox_core/client.ts index 294cfc36..281a3cbf 100644 --- a/packages/toolbox-core/src/toolbox_core/client.ts +++ b/packages/toolbox-core/src/toolbox_core/client.ts @@ -21,7 +21,6 @@ import { ZodManifestSchema, Protocol, getSupportedMcpVersions, - MCP_LATEST, } from './protocol.js'; import {McpHttpTransportV20241105} from './mcp/v20241105/mcp.js'; import {McpHttpTransportV20250618} from './mcp/v20250618/mcp.js'; @@ -80,12 +79,6 @@ class ToolboxClient { throw new Error(`Unsupported protocol version: ${protocol}`); } - if (protocol !== MCP_LATEST) { - console.warn( - `A newer version of MCP: ${MCP_LATEST} is available. Please use the latest version ${MCP_LATEST} to use the latest features.`, - ); - } - this.#transport = this.#createTransport( url, session || undefined, diff --git a/packages/toolbox-core/src/toolbox_core/mcp/v20260618/mcp.ts b/packages/toolbox-core/src/toolbox_core/mcp/v20260618/mcp.ts index f3fa7d84..935669e3 100644 --- a/packages/toolbox-core/src/toolbox_core/mcp/v20260618/mcp.ts +++ b/packages/toolbox-core/src/toolbox_core/mcp/v20260618/mcp.ts @@ -32,12 +32,12 @@ import {ProtocolNegotiationError} from '../../errorUtils.js'; export class McpHttpTransportV20260618 extends McpHttpTransportBase { #getMeta() { return { - protocolVersion: this._protocolVersion, - clientInfo: { + 'io.modelcontextprotocol/protocolVersion': this._protocolVersion, + 'io.modelcontextprotocol/clientInfo': { name: this._clientName || 'toolbox-core-js', version: this._clientVersion || VERSION, }, - clientCapabilities: {}, + 'io.modelcontextprotocol/clientCapabilities': {}, }; } diff --git a/packages/toolbox-core/src/toolbox_core/protocol.ts b/packages/toolbox-core/src/toolbox_core/protocol.ts index be29fad4..5c4d45e4 100644 --- a/packages/toolbox-core/src/toolbox_core/protocol.ts +++ b/packages/toolbox-core/src/toolbox_core/protocol.ts @@ -21,10 +21,10 @@ export enum Protocol { MCP_v20250618 = '2025-06-18', MCP_v20251125 = '2025-11-25', MCP = MCP_v20250618, // Default MCP + MCP_LATEST = MCP_DRAFT_2026_v1, } -export const MCP_LATEST = Protocol.MCP_v20251125; - +export const MCP_LATEST = Protocol.MCP_LATEST; export function getSupportedMcpVersions(): Protocol[] { return [ Protocol.MCP_DRAFT_2026_v1, diff --git a/packages/toolbox-core/test/e2e/jest.globalSetup.ts b/packages/toolbox-core/test/e2e/jest.globalSetup.ts index 2a1396b1..ae99d26c 100644 --- a/packages/toolbox-core/test/e2e/jest.globalSetup.ts +++ b/packages/toolbox-core/test/e2e/jest.globalSetup.ts @@ -51,102 +51,134 @@ export default async function globalSetup(): Promise { const toolboxGcsPath = getToolboxBinaryGcsPath(toolboxVersion); const localToolboxPath = path.resolve(__dirname, TOOLBOX_BINARY_NAME); + const bucketName = 'mcp-toolbox-for-databases'; console.log( - `Downloading toolbox binary from gs://mcp-toolbox-for-databases/${toolboxGcsPath} to ${localToolboxPath}...`, - ); - await downloadBlob( - 'mcp-toolbox-for-databases', - toolboxGcsPath, - localToolboxPath, + `Downloading toolbox binary from gs://${bucketName}/${toolboxGcsPath} to ${localToolboxPath}...`, ); + await downloadBlob(bucketName, toolboxGcsPath, localToolboxPath); console.log('Toolbox binary downloaded successfully.'); // Make toolbox executable await fs.chmod(localToolboxPath, 0o700); - // Start toolbox server - console.log('Starting toolbox server process...'); - const serverProcess = spawn( + // Start toolbox servers + console.log('Starting toolbox server processes...'); + const serverProcess1 = spawn( localToolboxPath, - ['--tools-file', toolsFilePath], + ['--port', '5000', '--tools-file', toolsFilePath], { - stdio: ['ignore', 'pipe', 'pipe'], // ignore stdin, pipe stdout/stderr + stdio: ['ignore', 'pipe', 'pipe'], + }, + ); + const serverProcess2 = spawn( + localToolboxPath, + ['--port', '5001', '--tools-file', toolsFilePath, '--enable-draft-specs'], + { + stdio: ['ignore', 'pipe', 'pipe'], }, ); - (globalThis as CustomGlobal).__TOOLBOX_SERVER_PROCESS__ = serverProcess; + (globalThis as CustomGlobal).__TOOLBOX_SERVER_PROCESS__ = serverProcess1; + (globalThis as CustomGlobal).__TOOLBOX_SERVER_PROCESS_2__ = serverProcess2; - serverProcess.stdout?.on('data', (data: Buffer) => { - console.log(`[ToolboxServer STDOUT]: ${data.toString().trim()}`); + serverProcess1.stdout?.on('data', (data: Buffer) => { + console.log(`[ToolboxServer1 STDOUT]: ${data.toString().trim()}`); }); - - serverProcess.stderr?.on('data', (data: Buffer) => { - console.error(`[ToolboxServer STDERR]: ${data.toString().trim()}`); + serverProcess1.stderr?.on('data', (data: Buffer) => { + console.error(`[ToolboxServer1 STDERR]: ${data.toString().trim()}`); }); - - serverProcess.on('error', err => { - console.error('Toolbox server process error:', err); - throw new Error('Failed to start toolbox server process.'); + serverProcess1.on('error', err => { + console.error('Toolbox server 1 process error:', err); + throw new Error('Failed to start toolbox server 1 process.'); }); - - serverProcess.on('exit', (code, signal) => { + serverProcess1.on('exit', (code, signal) => { console.log( - `Toolbox server process exited with code ${code}, signal ${signal}.`, + `Toolbox server 1 process exited with code ${code}, signal ${signal}.`, ); if ( (globalThis as CustomGlobal).__TOOLBOX_SERVER_PROCESS__ && !(globalThis as CustomGlobal).__SERVER_TEARDOWN_INITIATED__ ) { - console.error('Toolbox server exited prematurely during setup.'); + console.error('Toolbox server 1 exited prematurely during setup.'); + } + }); + + serverProcess2.stdout?.on('data', (data: Buffer) => { + console.log(`[ToolboxServer2 STDOUT]: ${data.toString().trim()}`); + }); + serverProcess2.stderr?.on('data', (data: Buffer) => { + console.error(`[ToolboxServer2 STDERR]: ${data.toString().trim()}`); + }); + serverProcess2.on('error', err => { + console.error('Toolbox server 2 process error:', err); + throw new Error('Failed to start toolbox server 2 process.'); + }); + serverProcess2.on('exit', (code, signal) => { + console.log( + `Toolbox server 2 process exited with code ${code}, signal ${signal}.`, + ); + if ( + (globalThis as CustomGlobal).__TOOLBOX_SERVER_PROCESS_2__ && + !(globalThis as CustomGlobal).__SERVER_TEARDOWN_INITIATED__ + ) { + console.error('Toolbox server 2 exited prematurely during setup.'); } }); - // Wait for server to start (basic poll check) - let started = false; + // Wait for servers to start (basic poll check) + let started1 = false; + let started2 = false; const startTime = Date.now(); while (Date.now() - startTime < SERVER_READY_TIMEOUT_MS) { if ( - serverProcess.pid && - !serverProcess.killed && - serverProcess.exitCode === null + serverProcess1.pid && + !serverProcess1.killed && + serverProcess1.exitCode === null ) { + started1 = true; + } + if ( + serverProcess2.pid && + !serverProcess2.killed && + serverProcess2.exitCode === null + ) { + started2 = true; + } + if (started1 && started2) { console.log( - 'Toolbox server process appears to be running. Polling for stability...', + 'Both Toolbox servers started successfully (processes are active).', ); - await delay(SERVER_READY_POLL_INTERVAL_MS * 2); - if (serverProcess.exitCode === null) { - console.log( - 'Toolbox server started successfully (process is active).', - ); - started = true; - break; - } else { - console.log('Toolbox server process exited after initial start.'); - break; - } + break; } await delay(SERVER_READY_POLL_INTERVAL_MS); - console.log('Checking if toolbox server is started...'); + console.log('Checking if toolbox servers are started...'); } - if (!started) { - if (serverProcess && !serverProcess.killed) { - serverProcess.kill('SIGTERM'); - } + if (!started1 || !started2) { + if (serverProcess1 && !serverProcess1.killed) + serverProcess1.kill('SIGTERM'); + if (serverProcess2 && !serverProcess2.killed) + serverProcess2.kill('SIGTERM'); throw new Error( - `Toolbox server failed to start within ${SERVER_READY_TIMEOUT_MS / 1000} seconds.`, + `Toolbox servers failed to start within ${SERVER_READY_TIMEOUT_MS / 1000} seconds.`, ); } console.log('Jest Global Setup: Completed successfully.'); } catch (error) { console.error('Jest Global Setup Failed:', error); - // Attempt to kill server if it started partially - const serverProcess = (globalThis as CustomGlobal) + // Attempt to kill servers if they started partially + const serverProcess1 = (globalThis as CustomGlobal) .__TOOLBOX_SERVER_PROCESS__; - if (serverProcess && !serverProcess.killed) { - console.log('Attempting to terminate partially started server...'); - serverProcess.kill('SIGKILL'); + const serverProcess2 = (globalThis as CustomGlobal) + .__TOOLBOX_SERVER_PROCESS_2__; + if (serverProcess1 && !serverProcess1.killed) { + console.log('Attempting to terminate partially started server 1...'); + serverProcess1.kill('SIGKILL'); + } + if (serverProcess2 && !serverProcess2.killed) { + console.log('Attempting to terminate partially started server 2...'); + serverProcess2.kill('SIGKILL'); } // Clean up temp file if created const toolsFilePath = (globalThis as CustomGlobal).__TOOLS_FILE_PATH__; diff --git a/packages/toolbox-core/test/e2e/jest.globalTeardown.ts b/packages/toolbox-core/test/e2e/jest.globalTeardown.ts index caf6ff41..3fc32d7a 100644 --- a/packages/toolbox-core/test/e2e/jest.globalTeardown.ts +++ b/packages/toolbox-core/test/e2e/jest.globalTeardown.ts @@ -13,6 +13,7 @@ // limitations under the License. import * as fs from 'fs-extra'; +import {ChildProcess} from 'child_process'; import {CustomGlobal} from './types'; const SERVER_TERMINATE_TIMEOUT_MS = 10000; // 10 seconds @@ -22,50 +23,67 @@ export default async function globalTeardown(): Promise { (globalThis as CustomGlobal).__SERVER_TEARDOWN_INITIATED__ = true; const customGlobal = globalThis as CustomGlobal; - const serverProcess = customGlobal.__TOOLBOX_SERVER_PROCESS__; + const serverProcess1 = customGlobal.__TOOLBOX_SERVER_PROCESS__; + const serverProcess2 = customGlobal.__TOOLBOX_SERVER_PROCESS_2__; const toolsFilePath = customGlobal.__TOOLS_FILE_PATH__; - if (serverProcess && !serverProcess.killed) { - console.log('Stopping toolbox server process...'); - serverProcess.kill('SIGTERM'); // Graceful termination + const killServer = async ( + serverProcess: ChildProcess | undefined, + name: string, + ) => { + if (serverProcess && !serverProcess.killed) { + console.log(`Stopping toolbox server ${name} process...`); + serverProcess.kill('SIGTERM'); // Graceful termination - // Wait for the process to exit - const stopPromise = new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - if (!serverProcess.killed) { - console.warn( - 'Toolbox server did not terminate gracefully, sending SIGKILL.', - ); - serverProcess.kill('SIGKILL'); - } - // Resolve even if SIGKILL is needed, as we want teardown to finish - resolve(); - }, SERVER_TERMINATE_TIMEOUT_MS); + // Wait for the process to exit + const stopPromise = new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + if (!serverProcess.killed) { + console.warn( + `Toolbox server ${name} did not terminate gracefully, sending SIGKILL.`, + ); + serverProcess.kill('SIGKILL'); + } + // Resolve even if SIGKILL is needed, as we want teardown to finish + resolve(); + }, SERVER_TERMINATE_TIMEOUT_MS); - serverProcess.on('exit', (code, signal) => { - clearTimeout(timeout); - console.log( - `Toolbox server process exited with code ${code}, signal ${signal} during teardown.`, + serverProcess.on( + 'exit', + (code: number | null, signal: NodeJS.Signals | null) => { + clearTimeout(timeout); + console.log( + `Toolbox server ${name} process exited with code ${code}, signal ${signal} during teardown.`, + ); + resolve(); + }, ); - resolve(); - }); - serverProcess.on('error', err => { - // Should not happen if already running - clearTimeout(timeout); - console.error('Error during server process termination:', err); - reject(err); + serverProcess.on('error', (err: Error) => { + // Should not happen if already running + clearTimeout(timeout); + console.error( + `Error during server ${name} process termination:`, + err, + ); + reject(err); + }); }); - }); - try { - await stopPromise; - } catch (error) { - console.error('Error while waiting for server to stop:', error); - if (!serverProcess.killed) serverProcess.kill('SIGKILL'); // Ensure it's killed + try { + await stopPromise; + } catch (error) { + console.error(`Error while waiting for server ${name} to stop:`, error); + if (!serverProcess.killed) serverProcess.kill('SIGKILL'); // Ensure it's killed + } + } else { + console.log( + `Toolbox server ${name} process was not running or already handled.`, + ); } - } else { - console.log('Toolbox server process was not running or already handled.'); - } + }; + + await killServer(serverProcess1, '1'); + await killServer(serverProcess2, '2'); if (toolsFilePath) { try { @@ -79,6 +97,7 @@ export default async function globalTeardown(): Promise { } } customGlobal.__TOOLBOX_SERVER_PROCESS__ = undefined; + customGlobal.__TOOLBOX_SERVER_PROCESS_2__ = undefined; customGlobal.__TOOLS_FILE_PATH__ = undefined; customGlobal.__GOOGLE_CLOUD_PROJECT__ = undefined; diff --git a/packages/toolbox-core/test/e2e/test.e2e.ts b/packages/toolbox-core/test/e2e/test.e2e.ts index 4ea26b8c..c43dd6e1 100644 --- a/packages/toolbox-core/test/e2e/test.e2e.ts +++ b/packages/toolbox-core/test/e2e/test.e2e.ts @@ -19,82 +19,124 @@ import { getSupportedMcpVersions, } from '../../src/toolbox_core/protocol.js'; -import {AxiosError} from 'axios'; +import {jest} from '@jest/globals'; +import axios, {AxiosError} from 'axios'; import {CustomGlobal} from './types.js'; import {authTokenGetter} from './utils.js'; import {ZodTypeAny} from 'zod'; -describe.each(getSupportedMcpVersions())( - 'ToolboxClient E2E MCP Tests (%s)', - protocolVersion => { - let commonToolboxClient: ToolboxClient; - let getNRowsTool: ReturnType; - const testBaseUrl = 'http://localhost:5000'; - const projectId = (globalThis as CustomGlobal).__GOOGLE_CLOUD_PROJECT__; - - beforeAll(async () => { - commonToolboxClient = new ToolboxClient( - testBaseUrl, - undefined, - undefined, - protocolVersion, - ); - }); - - beforeEach(async () => { - getNRowsTool = await commonToolboxClient.loadTool('get-n-rows'); - expect(getNRowsTool.getName()).toBe('get-n-rows'); - }); +const LEGACY_SERVER_URL = 'http://localhost:5000'; +const DRAFT_SERVER_URL = 'http://localhost:5001'; +const testBaseUrls = [LEGACY_SERVER_URL, DRAFT_SERVER_URL]; - describe('invokeTool', () => { - it('should invoke the getNRowsTool', async () => { - const response = await getNRowsTool({num_rows: '2'}); - expect(typeof response).toBe('string'); - expect(response).toContain('row1'); - expect(response).toContain('row2'); - expect(response).not.toContain('row3'); - }); +testBaseUrls.forEach(testBaseUrl => { + describe.each(getSupportedMcpVersions())( + `ToolboxClient E2E MCP Tests against ${testBaseUrl} (%s)`, + protocolVersion => { + let commonToolboxClient: ToolboxClient; + let getNRowsTool: ReturnType; + const projectId = (globalThis as CustomGlobal).__GOOGLE_CLOUD_PROJECT__; - it('should invoke the getNRowsTool with missing params', async () => { - await expect(getNRowsTool()).rejects.toThrow( - /Argument validation failed for tool "get-n-rows":\s*- num_rows: Required/, + beforeAll(async () => { + commonToolboxClient = new ToolboxClient( + testBaseUrl, + undefined, + undefined, + protocolVersion, ); }); - it('should invoke the getNRowsTool with wrong param type', async () => { - await expect(getNRowsTool({num_rows: 2})).rejects.toThrow( - /Argument validation failed for tool "get-n-rows":\s*- num_rows: Expected string, received number/, - ); + beforeEach(async () => { + getNRowsTool = await commonToolboxClient.loadTool('get-n-rows'); + expect(getNRowsTool.getName()).toBe('get-n-rows'); }); - }); - describe('loadToolset', () => { - const specificToolsetTestCases = [ - { - name: 'my-toolset', - expectedLength: 1, - expectedTools: ['get-row-by-id'], - }, - { - name: 'my-toolset-2', - expectedLength: 2, - expectedTools: ['get-n-rows', 'get-row-by-id'], - }, - ]; - - specificToolsetTestCases.forEach(testCase => { - it(`should successfully load the specific toolset "${testCase.name}"`, async () => { - const loadedTools = await commonToolboxClient.loadToolset( - testCase.name, + describe('invokeTool', () => { + it('should invoke the getNRowsTool', async () => { + const response = await getNRowsTool({num_rows: '2'}); + expect(typeof response).toBe('string'); + expect(response).toContain('row1'); + expect(response).toContain('row2'); + expect(response).not.toContain('row3'); + }); + + it('should invoke the getNRowsTool with missing params', async () => { + await expect(getNRowsTool()).rejects.toThrow( + /Argument validation failed for tool "get-n-rows":\s*- num_rows: Required/, + ); + }); + + it('should invoke the getNRowsTool with wrong param type', async () => { + await expect(getNRowsTool({num_rows: 2})).rejects.toThrow( + /Argument validation failed for tool "get-n-rows":\s*- num_rows: Expected string, received number/, ); + }); + }); + + describe('loadToolset', () => { + const specificToolsetTestCases = [ + { + name: 'my-toolset', + expectedLength: 1, + expectedTools: ['get-row-by-id'], + }, + { + name: 'my-toolset-2', + expectedLength: 2, + expectedTools: ['get-n-rows', 'get-row-by-id'], + }, + ]; + + specificToolsetTestCases.forEach(testCase => { + it(`should successfully load the specific toolset "${testCase.name}"`, async () => { + const loadedTools = await commonToolboxClient.loadToolset( + testCase.name, + ); + + expect(Array.isArray(loadedTools)).toBe(true); + expect(loadedTools.length).toBe(testCase.expectedLength); + + const loadedToolNames = new Set( + loadedTools.map(tool => tool.getName()), + ); + expect(loadedToolNames).toEqual(new Set(testCase.expectedTools)); + + for (const tool of loadedTools) { + expect(typeof tool).toBe('function'); + expect(tool.getName).toBeInstanceOf(Function); + expect(tool.getDescription).toBeInstanceOf(Function); + expect(tool.getParamSchema).toBeInstanceOf(Function); + } + }); + }); + it('should successfully load the default toolset (all tools)', async () => { + const loadedTools = await commonToolboxClient.loadToolset(); // Load the default toolset (no name provided) expect(Array.isArray(loadedTools)).toBe(true); - expect(loadedTools.length).toBe(testCase.expectedLength); + expect(loadedTools.length).toBeGreaterThan(0); + const getNRowsToolFromSet = loadedTools.find( + tool => tool.getName() === 'get-n-rows', + ); + + expect(getNRowsToolFromSet).toBeDefined(); + expect(typeof getNRowsToolFromSet).toBe('function'); + expect(getNRowsToolFromSet?.getName()).toBe('get-n-rows'); + expect(getNRowsToolFromSet?.getDescription()).toBeDefined(); + expect(getNRowsToolFromSet?.getParamSchema()).toBeDefined(); const loadedToolNames = new Set( loadedTools.map(tool => tool.getName()), ); - expect(loadedToolNames).toEqual(new Set(testCase.expectedTools)); + const expectedDefaultTools = new Set([ + 'get-row-by-content-auth', + 'get-row-by-email-auth', + 'get-row-by-id-auth', + 'get-row-by-id', + 'get-n-rows', + 'search-rows', + 'process-data', + ]); + expect(loadedToolNames).toEqual(expectedDefaultTools); for (const tool of loadedTools) { expect(typeof tool).toBe('function'); @@ -103,536 +145,608 @@ describe.each(getSupportedMcpVersions())( expect(tool.getParamSchema).toBeInstanceOf(Function); } }); - }); - - it('should successfully load the default toolset (all tools)', async () => { - const loadedTools = await commonToolboxClient.loadToolset(); // Load the default toolset (no name provided) - expect(Array.isArray(loadedTools)).toBe(true); - expect(loadedTools.length).toBeGreaterThan(0); - const getNRowsToolFromSet = loadedTools.find( - tool => tool.getName() === 'get-n-rows', - ); - expect(getNRowsToolFromSet).toBeDefined(); - expect(typeof getNRowsToolFromSet).toBe('function'); - expect(getNRowsToolFromSet?.getName()).toBe('get-n-rows'); - expect(getNRowsToolFromSet?.getDescription()).toBeDefined(); - expect(getNRowsToolFromSet?.getParamSchema()).toBeDefined(); - - const loadedToolNames = new Set( - loadedTools.map(tool => tool.getName()), - ); - const expectedDefaultTools = new Set([ - 'get-row-by-content-auth', - 'get-row-by-email-auth', - 'get-row-by-id-auth', - 'get-row-by-id', - 'get-n-rows', - 'search-rows', - 'process-data', - ]); - expect(loadedToolNames).toEqual(expectedDefaultTools); - - for (const tool of loadedTools) { - expect(typeof tool).toBe('function'); - expect(tool.getName).toBeInstanceOf(Function); - expect(tool.getDescription).toBeInstanceOf(Function); - expect(tool.getParamSchema).toBeInstanceOf(Function); - } + it('should throw an error when trying to load a non-existent toolset', async () => { + await expect( + commonToolboxClient.loadToolset('non-existent-toolset'), + ).rejects.toThrow( + /MCP request failed with code -32600: toolset does not exist/, + ); + }); }); + describe('bindParams', () => { + it('should throw an error when attempting to provide user arguments for bound params', async () => { + const newTool = getNRowsTool.bindParam('num_rows', '3'); + await expect(newTool({num_rows: '4'})).rejects.toThrow( + "unexpected parameter 'num_rows' provided", + ); + }); - it('should throw an error when trying to load a non-existent toolset', async () => { - await expect( - commonToolboxClient.loadToolset('non-existent-toolset'), - ).rejects.toThrow( - /MCP request failed with code -32600: toolset does not exist/, - ); - }); - }); - describe('bindParams', () => { - it('should throw an error when attempting to provide user arguments for bound params', async () => { - const newTool = getNRowsTool.bindParam('num_rows', '3'); - await expect(newTool({num_rows: '4'})).rejects.toThrow( - "unexpected parameter 'num_rows' provided", - ); - }); + it('should successfully bind a parameter with bindParam and invoke', async () => { + const newTool = getNRowsTool.bindParam('num_rows', '3'); + const response = await newTool(); // Invoke with no args + expect(response).toContain('row1'); + expect(response).toContain('row2'); + expect(response).toContain('row3'); + expect(response).not.toContain('row4'); + }); - it('should successfully bind a parameter with bindParam and invoke', async () => { - const newTool = getNRowsTool.bindParam('num_rows', '3'); - const response = await newTool(); // Invoke with no args - expect(response).toContain('row1'); - expect(response).toContain('row2'); - expect(response).toContain('row3'); - expect(response).not.toContain('row4'); - }); + it('should successfully bind parameters with bindParams and invoke', async () => { + const newTool = getNRowsTool.bindParams({num_rows: '3'}); + const response = await newTool(); // Invoke with no args + expect(response).toContain('row1'); + expect(response).toContain('row2'); + expect(response).toContain('row3'); + expect(response).not.toContain('row4'); + }); - it('should successfully bind parameters with bindParams and invoke', async () => { - const newTool = getNRowsTool.bindParams({num_rows: '3'}); - const response = await newTool(); // Invoke with no args - expect(response).toContain('row1'); - expect(response).toContain('row2'); - expect(response).toContain('row3'); - expect(response).not.toContain('row4'); - }); + it('should successfully bind a synchronous function value', async () => { + const newTool = getNRowsTool.bindParams({num_rows: () => '1'}); + const response = await newTool(); + expect(response).toContain('row1'); + expect(response).not.toContain('row2'); + }); - it('should successfully bind a synchronous function value', async () => { - const newTool = getNRowsTool.bindParams({num_rows: () => '1'}); - const response = await newTool(); - expect(response).toContain('row1'); - expect(response).not.toContain('row2'); - }); + it('should successfully bind an asynchronous function value', async () => { + const asyncNumProvider = async () => { + await new Promise(resolve => setTimeout(resolve, 10)); + return '1'; + }; - it('should successfully bind an asynchronous function value', async () => { - const asyncNumProvider = async () => { - await new Promise(resolve => setTimeout(resolve, 10)); - return '1'; - }; + const newTool = getNRowsTool.bindParams({num_rows: asyncNumProvider}); + const response = await newTool(); + expect(response).toContain('row1'); + expect(response).not.toContain('row2'); + }); - const newTool = getNRowsTool.bindParams({num_rows: asyncNumProvider}); - const response = await newTool(); - expect(response).toContain('row1'); - expect(response).not.toContain('row2'); - }); + it('should successfully bind parameters at load time', async () => { + const tool = await commonToolboxClient.loadTool('get-n-rows', null, { + num_rows: '3', + }); + const response = await tool(); + expect(response).toContain('row1'); + expect(response).toContain('row2'); + expect(response).toContain('row3'); + expect(response).not.toContain('row4'); + }); - it('should successfully bind parameters at load time', async () => { - const tool = await commonToolboxClient.loadTool('get-n-rows', null, { - num_rows: '3', + it('should throw an error when re-binding an existing parameter', () => { + const newTool = getNRowsTool.bindParam('num_rows', '1'); + expect(() => { + newTool.bindParam('num_rows', '2'); + }).toThrow( + "Cannot re-bind parameter: parameter 'num_rows' is already bound in tool 'get-n-rows'.", + ); }); - const response = await tool(); - expect(response).toContain('row1'); - expect(response).toContain('row2'); - expect(response).toContain('row3'); - expect(response).not.toContain('row4'); - }); - it('should throw an error when re-binding an existing parameter', () => { - const newTool = getNRowsTool.bindParam('num_rows', '1'); - expect(() => { - newTool.bindParam('num_rows', '2'); - }).toThrow( - "Cannot re-bind parameter: parameter 'num_rows' is already bound in tool 'get-n-rows'.", - ); + it('should throw an error when binding a non-existent parameter', () => { + expect(() => { + getNRowsTool.bindParam('non_existent_param', '2'); + }).toThrow( + "Unable to bind parameter: no parameter named 'non_existent_param' in tool 'get-n-rows'.", + ); + }); }); - it('should throw an error when binding a non-existent parameter', () => { - expect(() => { - getNRowsTool.bindParam('non_existent_param', '2'); - }).toThrow( - "Unable to bind parameter: no parameter named 'non_existent_param' in tool 'get-n-rows'.", - ); - }); - }); + describe('Auth E2E Tests', () => { + // We isolate authToolboxClient to this block so that the global commonToolboxClient + // remains completely unauthenticated. If we mutate commonToolboxClient with auth + // claims here, subsequent test suites (like pagination) would inherit the auth state + // and mask bugs where unauthenticated requests should rightfully fail. + let authToolboxClient: ToolboxClient; + let authToken1: string; + let authToken2: string; + let authToken1Getter: () => string; + let authToken2Getter: () => string; + + beforeEach(async () => { + authToolboxClient = new ToolboxClient( + testBaseUrl, + undefined, + undefined, + protocolVersion, + ); + }); - describe('Auth E2E Tests', () => { - let authToken1: string; - let authToken2: string; - let authToken1Getter: () => string; - let authToken2Getter: () => string; + beforeAll(async () => { + if (!projectId) { + throw new Error( + 'GOOGLE_CLOUD_PROJECT is not defined. Cannot run Auth E2E tests.', + ); + } + authToken1 = await authTokenGetter(projectId, 'sdk_testing_client1'); + authToken2 = await authTokenGetter(projectId, 'sdk_testing_client2'); - beforeAll(async () => { - if (!projectId) { - throw new Error( - 'GOOGLE_CLOUD_PROJECT is not defined. Cannot run Auth E2E tests.', + authToken1Getter = () => authToken1; + authToken2Getter = () => authToken2; + }); + + it('should fail when running a tool that does not require auth with auth provided', async () => { + await expect( + authToolboxClient.loadTool('get-row-by-id', { + 'my-test-auth': authToken2Getter, + }), + ).rejects.toThrow( + "Validation failed for tool 'get-row-by-id': unused auth tokens: my-test-auth", ); - } - authToken1 = await authTokenGetter(projectId, 'sdk_testing_client1'); - authToken2 = await authTokenGetter(projectId, 'sdk_testing_client2'); + }); - authToken1Getter = () => authToken1; - authToken2Getter = () => authToken2; - }); + it('should fail when running a tool requiring auth without providing auth', async () => { + const tool = await authToolboxClient.loadTool('get-row-by-id-auth'); + await expect(tool({id: '2'})).rejects.toThrow( + 'One or more of the following authn services are required to invoke this tool: my-test-auth', + ); + }); - it('should fail when running a tool that does not require auth with auth provided', async () => { - await expect( - commonToolboxClient.loadTool('get-row-by-id', { + it('should fail when running a tool with incorrect auth', async () => { + const tool = await authToolboxClient.loadTool('get-row-by-id-auth'); + const authTool = tool.addAuthTokenGetters({ 'my-test-auth': authToken2Getter, - }), - ).rejects.toThrow( - "Validation failed for tool 'get-row-by-id': unused auth tokens: my-test-auth", - ); - }); + }); + try { + await authTool({id: '2'}); + } catch (error) { + expect(error).toBeInstanceOf(AxiosError); + const axiosError = error as AxiosError; + expect(axiosError.response?.data).toEqual( + expect.objectContaining({ + error: expect.objectContaining({ + message: expect.stringMatching( + /unauthorized|missing or invalid authentication header|unauthorized Tool call/, + ), + }), + }), + ); + } + }); - it('should fail when running a tool requiring auth without providing auth', async () => { - const tool = await commonToolboxClient.loadTool('get-row-by-id-auth'); - await expect(tool({id: '2'})).rejects.toThrow( - 'One or more of the following authn services are required to invoke this tool: my-test-auth', - ); - }); + it('should succeed when running a tool with correct auth', async () => { + const tool = await authToolboxClient.loadTool('get-row-by-id-auth'); + const authTool = tool.addAuthTokenGetters({ + 'my-test-auth': authToken1Getter, + }); + const response = await authTool({id: '2'}); + expect(response).toContain('row2'); + }); - it('should fail when running a tool with incorrect auth', async () => { - const tool = await commonToolboxClient.loadTool('get-row-by-id-auth'); - const authTool = tool.addAuthTokenGetters({ - 'my-test-auth': authToken2Getter, - }); - try { - await authTool({id: '2'}); - } catch (error) { - expect(error).toBeInstanceOf(AxiosError); - const axiosError = error as AxiosError; - expect(axiosError.response?.data).toEqual( - expect.objectContaining({ - error: expect.objectContaining({ - message: expect.stringMatching( - /unauthorized|missing or invalid authentication header|unauthorized Tool call/, - ), - }), - }), + it('should succeed when running a tool with correct async auth', async () => { + const tool = await authToolboxClient.loadTool('get-row-by-id-auth'); + const getAsyncToken = async () => { + return authToken1Getter(); + }; + const authTool = tool.addAuthTokenGetters({ + 'my-test-auth': getAsyncToken, + }); + const response = await authTool({id: '2'}); + expect(response).toContain('row2'); + }); + + it('should fail when a tool with a param requiring auth is run without auth', async () => { + const tool = await authToolboxClient.loadTool( + 'get-row-by-email-auth', ); - } - }); + await expect(tool()).rejects.toThrow( + 'One or more of the following authn services are required to invoke this tool: my-test-auth', + ); + }); - it('should succeed when running a tool with correct auth', async () => { - const tool = await commonToolboxClient.loadTool('get-row-by-id-auth'); - const authTool = tool.addAuthTokenGetters({ - 'my-test-auth': authToken1Getter, + it('should succeed when a tool with a param requiring auth is run with correct auth', async () => { + const tool = await authToolboxClient.loadTool( + 'get-row-by-email-auth', + { + 'my-test-auth': authToken1Getter, + }, + ); + const response = await tool(); + expect(response).toContain('row4'); + expect(response).toContain('row5'); + expect(response).toContain('row6'); }); - const response = await authTool({id: '2'}); - expect(response).toContain('row2'); - }); - it('should succeed when running a tool with correct async auth', async () => { - const tool = await commonToolboxClient.loadTool('get-row-by-id-auth'); - const getAsyncToken = async () => { - return authToken1Getter(); - }; - const authTool = tool.addAuthTokenGetters({ - 'my-test-auth': getAsyncToken, + it('should fail when a tool with a param requiring auth is run with insufficient auth claims', async () => { + const tool = await authToolboxClient.loadTool( + 'get-row-by-content-auth', + { + 'my-test-auth': authToken1Getter, + }, + ); + try { + await tool(); + throw new Error('Expected tool invocation to fail'); + } catch (error) { + expect(error).toBeInstanceOf(AxiosError); + const axiosError = error as AxiosError; + expect(axiosError.response?.data).toEqual( + expect.objectContaining({ + error: expect.objectContaining({ + message: expect.stringMatching( + /provided parameters were invalid/, + ), + }), + }), + ); + } }); - const response = await authTool({id: '2'}); - expect(response).toContain('row2'); }); - it('should fail when a tool with a param requiring auth is run without auth', async () => { - const tool = await commonToolboxClient.loadTool( - 'get-row-by-email-auth', - ); - await expect(tool()).rejects.toThrow( - 'One or more of the following authn services are required to invoke this tool: my-test-auth', - ); - }); + describe('Optional Params E2E Tests', () => { + let searchRowsTool: ReturnType; + + beforeAll(async () => { + searchRowsTool = await commonToolboxClient.loadTool('search-rows'); + }); + + it('should correctly identify required and optional parameters in the schema', () => { + const paramSchema = searchRowsTool.getParamSchema(); + const {shape} = paramSchema; + + // Required param 'email' + expect(shape.email.isOptional()).toBe(false); + expect(shape.email.isNullable()).toBe(false); + expect(shape.email._def.typeName).toBe('ZodString'); - it('should succeed when a tool with a param requiring auth is run with correct auth', async () => { - const tool = await commonToolboxClient.loadTool( - 'get-row-by-email-auth', + // Optional param 'data' + expect(shape.data.isOptional()).toBe(true); + expect(shape.data.isNullable()).toBe(true); { - 'my-test-auth': authToken1Getter, - }, - ); - const response = await tool(); - expect(response).toContain('row4'); - expect(response).toContain('row5'); - expect(response).toContain('row6'); - }); + let inner: ZodTypeAny = shape.data as unknown as ZodTypeAny; + while ( + inner?._def?.typeName === 'ZodOptional' || + inner?._def?.typeName === 'ZodNullable' || + inner?._def?.typeName === 'ZodDefault' + ) { + inner = inner._def.innerType; + } + expect(inner._def.typeName).toBe('ZodString'); + } - it('should fail when a tool with a param requiring auth is run with insufficient auth claims', async () => { - const tool = await commonToolboxClient.loadTool( - 'get-row-by-content-auth', + // Optional param 'id' + expect(shape.id.isOptional()).toBe(true); + expect(shape.id.isNullable()).toBe(true); { - 'my-test-auth': authToken1Getter, - }, - ); - try { - await tool(); - throw new Error('Expected tool invocation to fail'); - } catch (error) { - expect(error).toBeInstanceOf(AxiosError); - const axiosError = error as AxiosError; - expect(axiosError.response?.data).toEqual( - expect.objectContaining({ - error: expect.objectContaining({ - message: expect.stringMatching( - /provided parameters were invalid/, - ), - }), - }), - ); - } - }); - }); + let inner: ZodTypeAny = shape.id as unknown as ZodTypeAny; + while ( + inner?._def?.typeName === 'ZodOptional' || + inner?._def?.typeName === 'ZodNullable' || + inner?._def?.typeName === 'ZodDefault' + ) { + inner = inner._def.innerType; + } + expect(inner._def.typeName).toBe('ZodNumber'); + } + }); - describe('Optional Params E2E Tests', () => { - let searchRowsTool: ReturnType; + it('should run tool with optional params omitted', async () => { + const response = await searchRowsTool({ + email: 'twishabansal@google.com', + }); + expect(typeof response).toBe('string'); + expect(response).toContain('"email":"twishabansal@google.com"'); + expect(response).not.toContain('row1'); + expect(response).toContain('row2'); + expect(response).not.toContain('row3'); + expect(response).not.toContain('row4'); + expect(response).not.toContain('row5'); + expect(response).not.toContain('row6'); + }); - beforeAll(async () => { - searchRowsTool = await commonToolboxClient.loadTool('search-rows'); - }); + it('should run tool with optional data provided', async () => { + const response = await searchRowsTool({ + email: 'twishabansal@google.com', + data: 'row3', + }); + expect(typeof response).toBe('string'); + expect(response).toContain('"email":"twishabansal@google.com"'); + expect(response).not.toContain('row1'); + expect(response).not.toContain('row2'); + expect(response).toContain('row3'); + expect(response).not.toContain('row4'); + expect(response).not.toContain('row5'); + expect(response).not.toContain('row6'); + }); - it('should correctly identify required and optional parameters in the schema', () => { - const paramSchema = searchRowsTool.getParamSchema(); - const {shape} = paramSchema; - - // Required param 'email' - expect(shape.email.isOptional()).toBe(false); - expect(shape.email.isNullable()).toBe(false); - expect(shape.email._def.typeName).toBe('ZodString'); - - // Optional param 'data' - expect(shape.data.isOptional()).toBe(true); - expect(shape.data.isNullable()).toBe(true); - { - let inner: ZodTypeAny = shape.data as unknown as ZodTypeAny; - while ( - inner?._def?.typeName === 'ZodOptional' || - inner?._def?.typeName === 'ZodNullable' || - inner?._def?.typeName === 'ZodDefault' - ) { - inner = inner._def.innerType; - } - expect(inner._def.typeName).toBe('ZodString'); - } - - // Optional param 'id' - expect(shape.id.isOptional()).toBe(true); - expect(shape.id.isNullable()).toBe(true); - { - let inner: ZodTypeAny = shape.id as unknown as ZodTypeAny; - while ( - inner?._def?.typeName === 'ZodOptional' || - inner?._def?.typeName === 'ZodNullable' || - inner?._def?.typeName === 'ZodDefault' - ) { - inner = inner._def.innerType; - } - expect(inner._def.typeName).toBe('ZodNumber'); - } - }); + it('should run tool with optional data as null', async () => { + const response = await searchRowsTool({ + email: 'twishabansal@google.com', + data: null, + }); + expect(typeof response).toBe('string'); + expect(response).toContain('"email":"twishabansal@google.com"'); + expect(response).not.toContain('row1'); + expect(response).toContain('row2'); + expect(response).not.toContain('row3'); + expect(response).not.toContain('row4'); + expect(response).not.toContain('row5'); + expect(response).not.toContain('row6'); + }); - it('should run tool with optional params omitted', async () => { - const response = await searchRowsTool({ - email: 'twishabansal@google.com', + it('should run tool with optional id provided', async () => { + const response = await searchRowsTool({ + email: 'twishabansal@google.com', + id: 1, + }); + expect(typeof response).toBe('string'); + expect(response).toBe('null'); }); - expect(typeof response).toBe('string'); - expect(response).toContain('"email":"twishabansal@google.com"'); - expect(response).not.toContain('row1'); - expect(response).toContain('row2'); - expect(response).not.toContain('row3'); - expect(response).not.toContain('row4'); - expect(response).not.toContain('row5'); - expect(response).not.toContain('row6'); - }); - it('should run tool with optional data provided', async () => { - const response = await searchRowsTool({ - email: 'twishabansal@google.com', - data: 'row3', + it('should run tool with optional id as null', async () => { + const response = await searchRowsTool({ + email: 'twishabansal@google.com', + id: null, + }); + expect(typeof response).toBe('string'); + expect(response).toContain('"email":"twishabansal@google.com"'); + expect(response).not.toContain('row1'); + expect(response).toContain('row2'); + expect(response).not.toContain('row3'); + expect(response).not.toContain('row4'); + expect(response).not.toContain('row5'); + expect(response).not.toContain('row6'); }); - expect(typeof response).toBe('string'); - expect(response).toContain('"email":"twishabansal@google.com"'); - expect(response).not.toContain('row1'); - expect(response).not.toContain('row2'); - expect(response).toContain('row3'); - expect(response).not.toContain('row4'); - expect(response).not.toContain('row5'); - expect(response).not.toContain('row6'); - }); - it('should run tool with optional data as null', async () => { - const response = await searchRowsTool({ - email: 'twishabansal@google.com', - data: null, + it('should fail when a required param is missing', async () => { + await expect(searchRowsTool({id: 5, data: 'row5'})).rejects.toThrow( + /Argument validation failed for tool "search-rows":\s*- email: Required/, + ); }); - expect(typeof response).toBe('string'); - expect(response).toContain('"email":"twishabansal@google.com"'); - expect(response).not.toContain('row1'); - expect(response).toContain('row2'); - expect(response).not.toContain('row3'); - expect(response).not.toContain('row4'); - expect(response).not.toContain('row5'); - expect(response).not.toContain('row6'); - }); - it('should run tool with optional id provided', async () => { - const response = await searchRowsTool({ - email: 'twishabansal@google.com', - id: 1, + it('should fail when a required param is null', async () => { + await expect( + searchRowsTool({email: null, id: 5, data: 'row5'}), + ).rejects.toThrow( + /Argument validation failed for tool "search-rows":\s*- email: Expected string, received null/, + ); }); - expect(typeof response).toBe('string'); - expect(response).toBe('null'); - }); - it('should run tool with optional id as null', async () => { - const response = await searchRowsTool({ - email: 'twishabansal@google.com', - id: null, + it('should run tool with all default params', async () => { + const response = await searchRowsTool({ + email: 'twishabansal@google.com', + id: 0, + data: 'row2', + }); + expect(typeof response).toBe('string'); + expect(response).toContain('"email":"twishabansal@google.com"'); + expect(response).not.toContain('row1'); + expect(response).toContain('row2'); + expect(response).not.toContain('row3'); + expect(response).not.toContain('row4'); + expect(response).not.toContain('row5'); + expect(response).not.toContain('row6'); }); - expect(typeof response).toBe('string'); - expect(response).toContain('"email":"twishabansal@google.com"'); - expect(response).not.toContain('row1'); - expect(response).toContain('row2'); - expect(response).not.toContain('row3'); - expect(response).not.toContain('row4'); - expect(response).not.toContain('row5'); - expect(response).not.toContain('row6'); - }); - it('should fail when a required param is missing', async () => { - await expect(searchRowsTool({id: 5, data: 'row5'})).rejects.toThrow( - /Argument validation failed for tool "search-rows":\s*- email: Required/, - ); - }); + it('should run tool with all valid params', async () => { + const response = await searchRowsTool({ + email: 'twishabansal@google.com', + id: 3, + data: 'row3', + }); + expect(typeof response).toBe('string'); + expect(response).toContain('"email":"twishabansal@google.com"'); + expect(response).not.toContain('row1'); + expect(response).not.toContain('row2'); + expect(response).toContain('row3'); + expect(response).not.toContain('row4'); + expect(response).not.toContain('row5'); + expect(response).not.toContain('row6'); + }); - it('should fail when a required param is null', async () => { - await expect( - searchRowsTool({email: null, id: 5, data: 'row5'}), - ).rejects.toThrow( - /Argument validation failed for tool "search-rows":\s*- email: Expected string, received null/, - ); - }); + it('should return null when called with a different email', async () => { + const response = await searchRowsTool({ + email: 'anubhavdhawan@google.com', + id: 3, + data: 'row3', + }); + expect(typeof response).toBe('string'); + expect(response).toBe('null'); + }); - it('should run tool with all default params', async () => { - const response = await searchRowsTool({ - email: 'twishabansal@google.com', - id: 0, - data: 'row2', + it('should return null when called with different data', async () => { + const response = await searchRowsTool({ + email: 'twishabansal@google.com', + id: 3, + data: 'row4', + }); + expect(typeof response).toBe('string'); + expect(response).toBe('null'); }); - expect(typeof response).toBe('string'); - expect(response).toContain('"email":"twishabansal@google.com"'); - expect(response).not.toContain('row1'); - expect(response).toContain('row2'); - expect(response).not.toContain('row3'); - expect(response).not.toContain('row4'); - expect(response).not.toContain('row5'); - expect(response).not.toContain('row6'); - }); - it('should run tool with all valid params', async () => { - const response = await searchRowsTool({ - email: 'twishabansal@google.com', - id: 3, - data: 'row3', + it('should return null when called with a different id', async () => { + const response = await searchRowsTool({ + email: 'twishabansal@google.com', + id: 4, + data: 'row3', + }); + expect(typeof response).toBe('string'); + expect(response).toBe('null'); }); - expect(typeof response).toBe('string'); - expect(response).toContain('"email":"twishabansal@google.com"'); - expect(response).not.toContain('row1'); - expect(response).not.toContain('row2'); - expect(response).toContain('row3'); - expect(response).not.toContain('row4'); - expect(response).not.toContain('row5'); - expect(response).not.toContain('row6'); }); + describe('Map/Object Params E2E Tests', () => { + let processDataTool: ReturnType; - it('should return null when called with a different email', async () => { - const response = await searchRowsTool({ - email: 'anubhavdhawan@google.com', - id: 3, - data: 'row3', + beforeAll(async () => { + processDataTool = await commonToolboxClient.loadTool('process-data'); }); - expect(typeof response).toBe('string'); - expect(response).toBe('null'); - }); - it('should return null when called with different data', async () => { - const response = await searchRowsTool({ - email: 'twishabansal@google.com', - id: 3, - data: 'row4', + it('should correctly identify map/object parameters in the schema', () => { + const paramSchema = processDataTool.getParamSchema(); + const baseArgs = { + execution_context: {env: 'prod'}, + user_scores: {user1: 100}, + }; + + // Test required untyped map (dict[str, Any]) + expect(paramSchema.safeParse(baseArgs).success).toBe(true); + const argsWithoutExec = {...baseArgs}; + delete (argsWithoutExec as Partial) + .execution_context; + expect(paramSchema.safeParse(argsWithoutExec).success).toBe(false); + + // Test required typed map (dict[str, int]) + expect( + paramSchema.safeParse({ + ...baseArgs, + user_scores: {user1: 'not-a-number'}, + }).success, + ).toBe(false); + + // Test optional typed map (dict[str, bool]) + expect( + paramSchema.safeParse({ + ...baseArgs, + feature_flags: {new_feature: true}, + }).success, + ).toBe(true); + expect( + paramSchema.safeParse({...baseArgs, feature_flags: null}).success, + ).toBe(true); + expect(paramSchema.safeParse(baseArgs).success).toBe(true); // Omitted }); - expect(typeof response).toBe('string'); - expect(response).toBe('null'); - }); - it('should return null when called with a different id', async () => { - const response = await searchRowsTool({ - email: 'twishabansal@google.com', - id: 4, - data: 'row3', + it('should run tool with valid map parameters', async () => { + const response = await processDataTool({ + execution_context: {env: 'prod', id: 1234, user: 1234.5}, + user_scores: {user1: 100, user2: 200}, + feature_flags: {new_feature: true}, + }); + expect(typeof response).toBe('string'); + expect(response).toContain( + '"execution_context":{"env":"prod","id":1234,"user":1234.5}', + ); + expect(response).toContain('"user_scores":{"user1":100,"user2":200}'); + expect(response).toContain('"feature_flags":{"new_feature":true}'); + }); + + it('should run tool with optional map param omitted', async () => { + const response = await processDataTool({ + execution_context: {env: 'dev'}, + user_scores: {user3: 300}, + }); + expect(typeof response).toBe('string'); + expect(response).toContain('"execution_context":{"env":"dev"}'); + expect(response).toContain('"user_scores":{"user3":300}'); + expect(response).toContain('"feature_flags":null'); + }); + + it('should fail when a map parameter has the wrong value type', async () => { + await expect( + processDataTool({ + execution_context: {env: 'staging'}, + user_scores: {user4: 'not-an-integer'}, + }), + ).rejects.toThrow( + /user_scores\.user4: Expected number, received string/, + ); }); - expect(typeof response).toBe('string'); - expect(response).toBe('null'); }); + }, + ); + + describe(`Protocol Configuration E2E Tests against ${testBaseUrl}`, () => { + it('should default to the correct protocol when none is specified', async () => { + const client = new ToolboxClient(testBaseUrl); + + const tool = await client.loadTool('get-n-rows'); + const response = await tool({num_rows: '1'}); + expect(typeof response).toBe('string'); + expect(response).toContain('row1'); }); - describe('Map/Object Params E2E Tests', () => { - let processDataTool: ReturnType; - beforeAll(async () => { - processDataTool = await commonToolboxClient.loadTool('process-data'); - }); + if (testBaseUrl === LEGACY_SERVER_URL) { + it('should fallback to an older protocol against a server that does not support the draft version', async () => { + const session = axios.create(); + const postSpy = jest.spyOn(session, 'post'); - it('should correctly identify map/object parameters in the schema', () => { - const paramSchema = processDataTool.getParamSchema(); - const baseArgs = { - execution_context: {env: 'prod'}, - user_scores: {user1: 100}, - }; - - // Test required untyped map (dict[str, Any]) - expect(paramSchema.safeParse(baseArgs).success).toBe(true); - const argsWithoutExec = {...baseArgs}; - delete (argsWithoutExec as Partial) - .execution_context; - expect(paramSchema.safeParse(argsWithoutExec).success).toBe(false); - - // Test required typed map (dict[str, int]) - expect( - paramSchema.safeParse({ - ...baseArgs, - user_scores: {user1: 'not-a-number'}, - }).success, - ).toBe(false); - - // Test optional typed map (dict[str, bool]) - expect( - paramSchema.safeParse({ - ...baseArgs, - feature_flags: {new_feature: true}, - }).success, - ).toBe(true); - expect( - paramSchema.safeParse({...baseArgs, feature_flags: null}).success, - ).toBe(true); - expect(paramSchema.safeParse(baseArgs).success).toBe(true); // Omitted - }); + const client = new ToolboxClient( + testBaseUrl, + session, + undefined, + Protocol.MCP_DRAFT_2026_v1, + ); - it('should run tool with valid map parameters', async () => { - const response = await processDataTool({ - execution_context: {env: 'prod', id: 1234, user: 1234.5}, - user_scores: {user1: 100, user2: 200}, - feature_flags: {new_feature: true}, - }); + const tool = await client.loadTool('get-n-rows'); + const response = await tool({num_rows: '1'}); expect(typeof response).toBe('string'); - expect(response).toContain( - '"execution_context":{"env":"prod","id":1234,"user":1234.5}', + expect(response).toContain('row1'); + + expect(postSpy).toHaveBeenCalledTimes(5); + + // Call 1: Draft tools/list (fails) + const call1 = postSpy.mock.calls[0]; + expect(call1[0]).toBe(`${LEGACY_SERVER_URL}/mcp/`); + expect((call1[1] as Record).method).toBe('tools/list'); + expect(call1[2]?.headers?.['MCP-Protocol-Version']).toBe( + Protocol.MCP_DRAFT_2026_v1, + ); + + // Call 2: Stateful Initialize (succeeds) + const call2 = postSpy.mock.calls[1]; + expect(call2[0]).toBe(`${LEGACY_SERVER_URL}/mcp/`); + expect((call2[1] as Record).method).toBe('initialize'); + expect(call2[2]?.headers?.['MCP-Protocol-Version']).toBe( + Protocol.MCP_v20251125, + ); + + // Call 3: Stateful Initialized Notification (succeeds) + const call3 = postSpy.mock.calls[2]; + expect(call3[0]).toBe(`${LEGACY_SERVER_URL}/mcp/`); + expect((call3[1] as Record).method).toBe( + 'notifications/initialized', + ); + expect(call3[2]?.headers?.['MCP-Protocol-Version']).toBe( + Protocol.MCP_v20251125, + ); + + // Call 4: Stateful tools/list (succeeds) + const call4 = postSpy.mock.calls[3]; + expect(call4[0]).toBe(`${LEGACY_SERVER_URL}/mcp/`); + expect((call4[1] as Record).method).toBe('tools/list'); + expect(call4[2]?.headers?.['MCP-Protocol-Version']).toBe( + Protocol.MCP_v20251125, + ); + + // Call 5: Stateful tools/call (succeeds) + const call5 = postSpy.mock.calls[4]; + expect(call5[0]).toBe(`${LEGACY_SERVER_URL}/mcp/`); + expect((call5[1] as Record).method).toBe('tools/call'); + expect(call5[2]?.headers?.['MCP-Protocol-Version']).toBe( + Protocol.MCP_v20251125, ); - expect(response).toContain('"user_scores":{"user1":100,"user2":200}'); - expect(response).toContain('"feature_flags":{"new_feature":true}'); }); + } else if (testBaseUrl === DRAFT_SERVER_URL) { + it('should successfully connect using the draft version without fallback', async () => { + const session = axios.create(); + const postSpy = jest.spyOn(session, 'post'); - it('should run tool with optional map param omitted', async () => { - const response = await processDataTool({ - execution_context: {env: 'dev'}, - user_scores: {user3: 300}, - }); + const client = new ToolboxClient( + testBaseUrl, + session, + undefined, + Protocol.MCP_DRAFT_2026_v1, + ); + + const tool = await client.loadTool('get-n-rows'); + const response = await tool({num_rows: '1'}); expect(typeof response).toBe('string'); - expect(response).toContain('"execution_context":{"env":"dev"}'); - expect(response).toContain('"user_scores":{"user3":300}'); - expect(response).toContain('"feature_flags":null'); - }); + expect(response).toContain('row1'); + + expect(postSpy).toHaveBeenCalledTimes(2); - it('should fail when a map parameter has the wrong value type', async () => { - await expect( - processDataTool({ - execution_context: {env: 'staging'}, - user_scores: {user4: 'not-an-integer'}, - }), - ).rejects.toThrow( - /user_scores\.user4: Expected number, received string/, + // Call 1: Draft tools/list (succeeds) + const call1 = postSpy.mock.calls[0]; + expect(call1[0]).toBe(`${DRAFT_SERVER_URL}/mcp/`); + expect((call1[1] as Record).method).toBe('tools/list'); + expect(call1[2]?.headers?.['MCP-Protocol-Version']).toBe( + Protocol.MCP_DRAFT_2026_v1, + ); + + // Call 2: Draft tools/call (succeeds) + const call2 = postSpy.mock.calls[1]; + expect(call2[0]).toBe(`${DRAFT_SERVER_URL}/mcp/`); + expect((call2[1] as Record).method).toBe('tools/call'); + expect(call2[2]?.headers?.['MCP-Protocol-Version']).toBe( + Protocol.MCP_DRAFT_2026_v1, ); }); - }); - }, -); - -describe('ToolboxClient E2E Protocol Negotiation Fallback', () => { - it('should successfully fallback to a supported version when draft specs are disabled on the server', async () => { - const testBaseUrl = 'http://localhost:5000'; - const client = new ToolboxClient( - testBaseUrl, - undefined, - undefined, - Protocol.MCP_DRAFT_2026_v1, - ); - - const tool = await client.loadTool('get-n-rows'); - expect(tool.getName()).toBe('get-n-rows'); + } }); }); diff --git a/packages/toolbox-core/test/e2e/types.ts b/packages/toolbox-core/test/e2e/types.ts index c207831d..efffdbc2 100644 --- a/packages/toolbox-core/test/e2e/types.ts +++ b/packages/toolbox-core/test/e2e/types.ts @@ -18,6 +18,7 @@ import {ChildProcess} from 'child_process'; export type CustomGlobal = typeof globalThis & { __TOOLS_FILE_PATH__?: string; __TOOLBOX_SERVER_PROCESS__?: ChildProcess; + __TOOLBOX_SERVER_PROCESS_2__?: ChildProcess; __SERVER_TEARDOWN_INITIATED__?: boolean; __GOOGLE_CLOUD_PROJECT__?: string; }; diff --git a/packages/toolbox-core/test/mcp/test.v20260618.ts b/packages/toolbox-core/test/mcp/test.v20260618.ts index eee91bb7..35b09700 100644 --- a/packages/toolbox-core/test/mcp/test.v20260618.ts +++ b/packages/toolbox-core/test/mcp/test.v20260618.ts @@ -90,7 +90,7 @@ describe('McpHttpTransportV20260618', () => { method: 'tools/list', params: { _meta: expect.objectContaining({ - protocolVersion: 'DRAFT-2026-v1', + 'io.modelcontextprotocol/protocolVersion': 'DRAFT-2026-v1', }), }, }), @@ -245,7 +245,7 @@ describe('McpHttpTransportV20260618', () => { name: 'testTool', arguments: {arg: 'val'}, _meta: expect.objectContaining({ - protocolVersion: 'DRAFT-2026-v1', + 'io.modelcontextprotocol/protocolVersion': 'DRAFT-2026-v1', }), }, }), diff --git a/packages/toolbox-core/test/test.client.ts b/packages/toolbox-core/test/test.client.ts index 2db3b618..5b2bb835 100644 --- a/packages/toolbox-core/test/test.client.ts +++ b/packages/toolbox-core/test/test.client.ts @@ -14,11 +14,7 @@ import {jest} from '@jest/globals'; import {ITransport} from '../src/toolbox_core/transport.types.js'; -import { - ZodManifest, - Protocol, - MCP_LATEST, -} from '../src/toolbox_core/protocol.js'; +import {ZodManifest, Protocol} from '../src/toolbox_core/protocol.js'; import type {ToolboxClient as ToolboxClientType} from '../src/toolbox_core/client.js'; import {ToolboxClient} from '../src/toolbox_core/client.js'; import {McpHttpTransportV20241105} from '../src/toolbox_core/mcp/v20241105/mcp.js'; @@ -31,6 +27,7 @@ import {ProtocolNegotiationError} from '../src/toolbox_core/errorUtils.js'; // --- Mock Transport Implementation --- class MockTransport implements ITransport { readonly baseUrl: string; + protocolVersion = Protocol.MCP; toolGet: jest.MockedFunction; toolsList: jest.MockedFunction; toolInvoke: jest.MockedFunction; @@ -141,9 +138,6 @@ describe('ToolboxClient', () => { }); it('should initialize with MCP transport (explicit) when specified', () => { - const consoleSpy = jest - .spyOn(console, 'warn') - .mockImplementation(() => {}); client = new ToolboxClient( testBaseUrl, undefined, @@ -157,12 +151,6 @@ describe('ToolboxClient', () => { undefined, undefined, ); - expect(consoleSpy).toHaveBeenCalledWith( - expect.stringContaining( - `A newer version of MCP: ${MCP_LATEST} is available`, - ), - ); - consoleSpy.mockRestore(); }); it('should initialize with MCP v20241105 transport when specified', () => { @@ -519,14 +507,35 @@ describe('ToolboxClient', () => { describe('Insecure Protocol Warnings', () => { let consoleSpy: jest.SpiedFunction; const httpUrl = 'http://api.example.com'; + let httpTransport: MockTransport; + let httpsTransport: MockTransport; beforeEach(() => { consoleSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); - // We need to ensure the transport has the HTTP URL for these tests - const httpTransport = new MockTransport(httpUrl); - // We mock the implementation for the default MCP version used by ToolboxClient + httpTransport = new MockTransport(httpUrl); + httpsTransport = new MockTransport(testBaseUrl); + + const mockImpl = (url: unknown) => { + if (typeof url !== 'string') { + throw new Error('Expected url to be string'); + } + return url.startsWith('https:') ? httpsTransport : httpTransport; + }; + + (McpHttpTransportV20241105 as unknown as jest.Mock).mockImplementation( + mockImpl, + ); + (McpHttpTransportV20250326 as unknown as jest.Mock).mockImplementation( + mockImpl, + ); (McpHttpTransportV20250618 as unknown as jest.Mock).mockImplementation( - () => httpTransport, + mockImpl, + ); + (McpHttpTransportV20251125 as unknown as jest.Mock).mockImplementation( + mockImpl, + ); + (McpHttpTransportV20260618 as unknown as jest.Mock).mockImplementation( + mockImpl, ); }); @@ -540,7 +549,6 @@ describe('ToolboxClient', () => { expect.stringContaining('This connection is using HTTP'), ); }); - it('should NOT warn when initializing with HTTPS and client headers', () => { new ToolboxClient(testBaseUrl, undefined, {'X-Test': 'val'}); expect(consoleSpy).not.toHaveBeenCalledWith( @@ -549,7 +557,6 @@ describe('ToolboxClient', () => { }); it('should warn in loadTool with HTTP and auth tokens', async () => { - const httpTransport = new MockTransport(httpUrl); httpTransport.toolGet.mockResolvedValue({ serverVersion: '1.0.0', tools: { @@ -560,9 +567,6 @@ describe('ToolboxClient', () => { }, }, }); - (McpHttpTransportV20250618 as unknown as jest.Mock).mockImplementation( - () => httpTransport, - ); client = new ToolboxClient(httpUrl); await client.loadTool('testTool', {auth: () => 'token'}); @@ -570,10 +574,7 @@ describe('ToolboxClient', () => { expect.stringContaining('This connection is using HTTP'), ); }); - it('should NOT warn in loadTool with HTTPS and auth tokens', async () => { - // Re-setup mock with HTTPS url - const httpsTransport = new MockTransport(testBaseUrl); httpsTransport.toolGet.mockResolvedValue({ serverVersion: '1.0.0', tools: { @@ -584,9 +585,6 @@ describe('ToolboxClient', () => { }, }, }); - (McpHttpTransportV20250618 as unknown as jest.Mock).mockImplementation( - () => httpsTransport, - ); client = new ToolboxClient(testBaseUrl); await client.loadTool('testTool', {auth: () => 'token'}); @@ -596,7 +594,6 @@ describe('ToolboxClient', () => { }); it('should warn in loadToolset with HTTP and auth tokens', async () => { - const httpTransport = new MockTransport(httpUrl); httpTransport.toolsList.mockResolvedValue({ serverVersion: '1.0.0', tools: { @@ -607,9 +604,6 @@ describe('ToolboxClient', () => { }, }, }); - (McpHttpTransportV20250618 as unknown as jest.Mock).mockImplementation( - () => httpTransport, - ); client = new ToolboxClient(httpUrl); await client.loadToolset('set', {auth: () => 'token'}); diff --git a/packages/toolbox-core/test/test.tool.ts b/packages/toolbox-core/test/test.tool.ts index 92912f24..982613e0 100644 --- a/packages/toolbox-core/test/test.tool.ts +++ b/packages/toolbox-core/test/test.tool.ts @@ -15,12 +15,14 @@ import {ToolboxTool} from '../src/toolbox_core/tool.js'; import {z, ZodObject, ZodRawShape} from 'zod'; import {ITransport} from '../src/toolbox_core/transport.types.js'; +import {Protocol} from '../src/toolbox_core/protocol.js'; import * as utils from '../src/toolbox_core/utils.js'; import {ClientHeadersConfig} from '../src/toolbox_core/client.js'; // --- Mock Transport Implementation --- class MockTransport implements ITransport { readonly baseUrl: string; + protocolVersion = Protocol.MCP; toolGet: jest.MockedFunction; toolsList: jest.MockedFunction; toolInvoke: jest.MockedFunction; diff --git a/packages/toolbox-core/test/test.utils.ts b/packages/toolbox-core/test/test.utils.ts index 114eb0c8..17d09b99 100644 --- a/packages/toolbox-core/test/test.utils.ts +++ b/packages/toolbox-core/test/test.utils.ts @@ -15,6 +15,7 @@ import { resolveValue, identifyAuthRequirements, + warnIfHttpAndHeaders, } from '../src/toolbox_core/utils'; describe('resolveValue', () => { @@ -213,3 +214,45 @@ describe('identifyAuthRequirements', () => { expect(usedServices).toEqual(new Set(['serviceA'])); }); }); + +describe('warnIfHttpAndHeaders', () => { + let consoleSpy: jest.SpiedFunction; + + beforeEach(() => { + consoleSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + }); + + afterEach(() => { + consoleSpy.mockRestore(); + }); + + it('should log a warning if URL is HTTP and headers are present', () => { + warnIfHttpAndHeaders('http://api.example.com', { + Authorization: 'Bearer token', + }); + expect(consoleSpy).toHaveBeenCalledTimes(1); + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining( + 'This connection is using HTTP. To prevent credential exposure, please ensure all communication is sent over HTTPS.', + ), + ); + }); + + it('should not log a warning if URL is HTTP but headers are empty', () => { + warnIfHttpAndHeaders('http://api.example.com', {}); + expect(consoleSpy).not.toHaveBeenCalled(); + }); + + it('should not log a warning if URL is HTTP but headers are null/undefined', () => { + warnIfHttpAndHeaders('http://api.example.com', null); + warnIfHttpAndHeaders('http://api.example.com', undefined); + expect(consoleSpy).not.toHaveBeenCalled(); + }); + + it('should not log a warning if URL is HTTPS even if headers are present', () => { + warnIfHttpAndHeaders('https://api.example.com', { + Authorization: 'Bearer token', + }); + expect(consoleSpy).not.toHaveBeenCalled(); + }); +});