diff --git a/package-lock.json b/package-lock.json index 08956950..f8e8f177 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2784,6 +2784,13 @@ "integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==", "license": "MIT" }, + "node_modules/@types/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/yargs": { "version": "17.0.34", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.34.tgz", @@ -10462,6 +10469,7 @@ "google-auth-library": "^10.0.0" }, "devDependencies": { + "@types/uuid": "^10.0.0", "rimraf": "^6.1.2" }, "engines": { diff --git a/packages/toolbox-adk/README.md b/packages/toolbox-adk/README.md index 7a89f2e5..f2a39efa 100644 --- a/packages/toolbox-adk/README.md +++ b/packages/toolbox-adk/README.md @@ -20,6 +20,9 @@ involving Large Language Models (LLMs). - [Installation](#installation) - [Quickstart](#quickstart) - [Usage](#usage) + - [Transport Protocols](#transport-protocols) + - [Available Protocols](#available-protocols) + - [Specifying a Protocol](#specifying-a-protocol) - [Loading Tools](#loading-tools) - [Load a toolset](#load-a-toolset) - [Load a single tool](#load-a-single-tool) @@ -123,6 +126,34 @@ All interactions for loading and invoking tools happen through this client. > For advanced use cases, you can provide an external `AxiosInstance` > during initialization (e.g., `ToolboxClient(url, my_session)`). + +## Transport Protocols + +The SDK supports multiple transport protocols to communicate with the Toolbox server. You can specify the protocol version during client initialization. + +### Available Protocols + +- `Protocol.MCP`: The default protocol (currently aliases to `MCP_v20250618`). +- `Protocol.MCP_v20241105`: Use this for compatibility with older MCP servers (November 2024 version). +- `Protocol.MCP_v20250326`: March 2025 version. +- `Protocol.MCP_v20250618`: June 2025 version. +- `Protocol.TOOLBOX`: Legacy Toolbox protocol. + +### Specifying a Protocol + +You can explicitly set the protocol by passing the `protocol` argument to the `ToolboxClient` constructor. + +```javascript +import { ToolboxClient, Protocol } from '@toolbox-sdk/adk'; + +const URL = 'http://127.0.0.1:5000'; + +// Initialize with a specific protocol version +const client = new ToolboxClient(URL, null, null, Protocol.MCP_v20241105); + +const tools = await client.loadToolset(); +``` + ## Loading Tools You can load tools individually or in groups (toolsets) as defined in your diff --git a/packages/toolbox-adk/src/toolbox_adk/client.ts b/packages/toolbox-adk/src/toolbox_adk/client.ts index 568bfb56..fad51a6c 100644 --- a/packages/toolbox-adk/src/toolbox_adk/client.ts +++ b/packages/toolbox-adk/src/toolbox_adk/client.ts @@ -17,6 +17,7 @@ import { AuthTokenGetters, BoundParams, ClientHeadersConfig, + Protocol, } from '@toolbox-sdk/core'; import {ToolboxTool, CoreTool} from './tool.js'; import type {AxiosInstance} from 'axios'; @@ -44,8 +45,14 @@ export class ToolboxClient { url: string, session?: AxiosInstance | null, clientHeaders?: ClientHeadersConfig | null, + protocol: Protocol = Protocol.MCP, ) { - this.coreClient = new CoreToolboxClient(url, session, clientHeaders); + this.coreClient = new CoreToolboxClient( + url, + session, + clientHeaders, + protocol, + ); } /** diff --git a/packages/toolbox-adk/src/toolbox_adk/index.ts b/packages/toolbox-adk/src/toolbox_adk/index.ts index 35587748..5414a4b8 100644 --- a/packages/toolbox-adk/src/toolbox_adk/index.ts +++ b/packages/toolbox-adk/src/toolbox_adk/index.ts @@ -17,3 +17,4 @@ // Export the main factory function and the core tool type export {ToolboxClient} from './client.js'; export {ToolboxTool} from './tool.js'; +export {Protocol} from '@toolbox-sdk/core'; diff --git a/packages/toolbox-adk/test/e2e/test.e2e.ts b/packages/toolbox-adk/test/e2e/test.e2e.ts index 244b27de..691a1e73 100644 --- a/packages/toolbox-adk/test/e2e/test.e2e.ts +++ b/packages/toolbox-adk/test/e2e/test.e2e.ts @@ -12,8 +12,11 @@ // See the License for the specific language governing permissions and // limitations under the License. -import {ToolboxClient} from '../../src/toolbox_adk/client.js'; -import {ToolboxTool} from '../../src/toolbox_adk/tool.js'; +import { + ToolboxClient, + ToolboxTool, + Protocol, +} from '../../src/toolbox_adk/index.js'; import {AxiosError} from 'axios'; import {CustomGlobal} from './types.js'; @@ -30,7 +33,12 @@ describe('ToolboxClient E2E Tests', () => { const mockToolContext = {} as ToolContext; beforeAll(async () => { - commonToolboxClient = new ToolboxClient(testBaseUrl); + commonToolboxClient = new ToolboxClient( + testBaseUrl, + undefined, + undefined, + Protocol.TOOLBOX, + ); }); beforeEach(async () => { diff --git a/packages/toolbox-adk/test/test.client.ts b/packages/toolbox-adk/test/test.client.ts index a884c1bb..233b2076 100644 --- a/packages/toolbox-adk/test/test.client.ts +++ b/packages/toolbox-adk/test/test.client.ts @@ -34,6 +34,7 @@ type MockCoreClientConstructor = ( url: string, session?: AxiosInstance | null, clientHeaders?: ClientHeadersConfig | null, + protocol?: string | null, ) => MockCoreClient; const mockLoadTool = @@ -66,6 +67,10 @@ const MockToolboxTool = jest.fn(); jest.unstable_mockModule('@toolbox-sdk/core', () => ({ ToolboxClient: MockCoreToolboxClient, + Protocol: { + MCP: 'mcp-default', + TOOLBOX: 'toolbox', + }, })); jest.unstable_mockModule('../src/toolbox_adk/tool.js', () => ({ @@ -94,6 +99,7 @@ describe('ToolboxClient', () => { 'http://test.url', mockSession, mockHeaders, + 'mcp-default', ); }); diff --git a/packages/toolbox-core/README.md b/packages/toolbox-core/README.md index 90644db3..ef0a7913 100644 --- a/packages/toolbox-core/README.md +++ b/packages/toolbox-core/README.md @@ -18,6 +18,9 @@ involving Large Language Models (LLMs). - [Installation](#installation) - [Quickstart](#quickstart) - [Usage](#usage) +- [Transport Protocols](#transport-protocols) + - [Available Protocols](#available-protocols) + - [Specifying a Protocol](#specifying-a-protocol) - [Loading Tools](#loading-tools) - [Load a toolset](#load-a-toolset) - [Load a single tool](#load-a-single-tool) @@ -121,6 +124,34 @@ All interactions for loading and invoking tools happen through this client. > For advanced use cases, you can provide an external `AxiosInstance` > during initialization (e.g., `ToolboxClient(url, my_session)`). + +## Transport Protocols + +The SDK supports multiple transport protocols to communicate with the Toolbox server. You can specify the protocol version during client initialization. + +### Available Protocols + +- `Protocol.MCP`: The default protocol (currently aliases to `MCP_v20250618`). +- `Protocol.MCP_v20241105`: Use this for compatibility with older MCP servers (November 2024 version). +- `Protocol.MCP_v20250326`: March 2025 version. +- `Protocol.MCP_v20250618`: June 2025 version. +- `Protocol.TOOLBOX`: Legacy Toolbox protocol. + +### Specifying a Protocol + +You can explicitly set the protocol by passing the `protocol` argument to the `ToolboxClient` constructor. + +```javascript +import { ToolboxClient, Protocol } from '@toolbox-sdk/core'; + +const URL = 'http://127.0.0.1:5000'; + +// Initialize with a specific protocol version +const client = new ToolboxClient(URL, null, null, Protocol.MCP_v20241105); + +const tools = await client.loadToolset(); +``` + ## Loading Tools You can load tools individually or in groups (toolsets) as defined in your diff --git a/packages/toolbox-core/jest.config.json b/packages/toolbox-core/jest.config.json index 3b405a9c..0fa3bdbb 100644 --- a/packages/toolbox-core/jest.config.json +++ b/packages/toolbox-core/jest.config.json @@ -1,6 +1,7 @@ { "testMatch": [ - "/test/*.ts" + "/test/*.ts", + "/test/mcp/*.ts" ], "preset": "ts-jest", "transform": { diff --git a/packages/toolbox-core/package.json b/packages/toolbox-core/package.json index 9831d4ee..7af84413 100644 --- a/packages/toolbox-core/package.json +++ b/packages/toolbox-core/package.json @@ -17,14 +17,14 @@ ], "exports": { ".": { - "import": "./build/index.js", + "import": "./build/esm/index.js", "require": "./build/cjs/index.js", - "types": "./build/index.d.ts" + "types": "./build/esm/index.d.ts" }, "./auth": { - "import": "./build/authMethods.js", + "import": "./build/esm/authMethods.js", "require": "./build/cjs/authMethods.js", - "types": "./build/authMethods.d.ts" + "types": "./build/esm/authMethods.d.ts" } }, "files": [ @@ -63,6 +63,7 @@ "zod": "^3.24.4" }, "devDependencies": { + "@types/uuid": "^10.0.0", "rimraf": "^6.1.2" } } diff --git a/packages/toolbox-core/src/toolbox_core/client.ts b/packages/toolbox-core/src/toolbox_core/client.ts index 994e14af..23f41618 100644 --- a/packages/toolbox-core/src/toolbox_core/client.ts +++ b/packages/toolbox-core/src/toolbox_core/client.ts @@ -20,7 +20,12 @@ import { createZodSchemaFromParams, ParameterSchema, ZodManifestSchema, + Protocol, + getSupportedMcpVersions, } from './protocol.js'; +import {McpHttpTransportV20241105} from './mcp/v20241105/mcp.js'; +import {McpHttpTransportV20250618} from './mcp/v20250618/mcp.js'; +import {McpHttpTransportV20250326} from './mcp/v20250326/mcp.js'; import {BoundParams, identifyAuthRequirements, resolveValue} from './utils.js'; import {AuthTokenGetters, RequiredAuthnParams} from './tool.js'; @@ -51,9 +56,36 @@ class ToolboxClient { url: string, session?: AxiosInstance | null, clientHeaders?: ClientHeadersConfig | null, + protocol: Protocol = Protocol.MCP, ) { - this.#transport = new ToolboxTransport(url, session || undefined); this.#clientHeaders = clientHeaders || {}; + if (protocol === Protocol.TOOLBOX) { + this.#transport = new ToolboxTransport(url, session || undefined); + } else if (getSupportedMcpVersions().includes(protocol)) { + if (protocol === Protocol.MCP_v20241105) { + this.#transport = new McpHttpTransportV20241105( + url, + session || undefined, + protocol, + ); + } else if (protocol === Protocol.MCP_v20250326) { + this.#transport = new McpHttpTransportV20250326( + url, + session || undefined, + protocol, + ); + } else if (protocol === Protocol.MCP_v20250618) { + this.#transport = new McpHttpTransportV20250618( + url, + session || undefined, + protocol, + ); + } else { + throw new Error(`Unsupported MCP protocol version: ${protocol}`); + } + } else { + throw new Error(`Unsupported protocol version: ${protocol}`); + } } /** diff --git a/packages/toolbox-core/src/toolbox_core/index.ts b/packages/toolbox-core/src/toolbox_core/index.ts index 4cf963b9..d7c4bf46 100644 --- a/packages/toolbox-core/src/toolbox_core/index.ts +++ b/packages/toolbox-core/src/toolbox_core/index.ts @@ -31,3 +31,4 @@ export type { export type {BoundParams, BoundValue} from './utils.js'; export type {ClientHeadersConfig} from './client.js'; +export {Protocol} from './protocol.js'; diff --git a/packages/toolbox-core/src/toolbox_core/mcp/transportBase.ts b/packages/toolbox-core/src/toolbox_core/mcp/transportBase.ts new file mode 100644 index 00000000..50026814 --- /dev/null +++ b/packages/toolbox-core/src/toolbox_core/mcp/transportBase.ts @@ -0,0 +1,193 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import axios, {AxiosInstance} from 'axios'; +import {ITransport} from '../transport.types.js'; +import { + ParameterSchema, + PrimitiveTypeSchema, + TypeSchema, + ZodManifest, + Protocol, +} from '../protocol.js'; + +interface JsonSchema { + type?: string; + items?: JsonSchema; + properties?: Record; + additionalProperties?: boolean | JsonSchema; + description?: string; + required?: string[]; +} + +interface ToolDefinition { + description?: string; + inputSchema?: JsonSchema; + _meta?: { + 'toolbox/authParam'?: Record; + 'toolbox/authInvoke'?: string[]; + }; +} + +export abstract class McpHttpTransportBase implements ITransport { + protected _mcpBaseUrl: string; + protected _protocolVersion: string; + protected _serverVersion: string | null = null; + + protected _manageSession: boolean; + protected _session: AxiosInstance; + + private _initPromise: Promise | null = null; + + constructor( + baseUrl: string, + session?: AxiosInstance, + protocol: Protocol = Protocol.MCP, + ) { + this._mcpBaseUrl = `${baseUrl}/mcp/`; + this._protocolVersion = protocol; + + this._manageSession = !session; + this._session = session || axios.create(); + } + + protected async ensureInitialized( + headers?: Record, + ): Promise { + if (!this._initPromise) { + this._initPromise = this.initializeSession(headers); + } + await this._initPromise; + } + + get baseUrl(): string { + return this._mcpBaseUrl; + } + + protected convertToolSchema(toolData: unknown): { + description: string; + parameters: ParameterSchema[]; + authRequired?: string[]; + } { + const data = toolData as ToolDefinition; + let paramAuth: Record | null = null; + let invokeAuth: string[] = []; + + if (data._meta && typeof data._meta === 'object') { + const meta = data._meta; + if ( + meta['toolbox/authParam'] && + typeof meta['toolbox/authParam'] === 'object' + ) { + paramAuth = meta['toolbox/authParam']; + } + if ( + meta['toolbox/authInvoke'] && + Array.isArray(meta['toolbox/authInvoke']) + ) { + invokeAuth = meta['toolbox/authInvoke']; + } + } + + const parameters: ParameterSchema[] = []; + const inputSchema = data.inputSchema || {}; + const properties = inputSchema.properties || {}; + const required = new Set(inputSchema.required || []); + + for (const [name, schema] of Object.entries(properties) as [ + string, + JsonSchema, + ][]) { + const typeSchema = this._convertTypeSchema(schema); + + let authSources: string[] | undefined; + if (paramAuth && paramAuth[name]) { + authSources = paramAuth[name]; + } + + parameters.push({ + name, + description: schema.description || '', + required: required.has(name), + authSources, + ...typeSchema, + } as ParameterSchema); + } + + return { + description: data.description || '', + parameters, + authRequired: invokeAuth.length > 0 ? invokeAuth : undefined, + }; + } + + private _convertTypeSchema(schemaData: unknown): TypeSchema { + const schema = schemaData as JsonSchema; + if (schema.type === 'array') { + return { + type: 'array', + items: this._convertTypeSchema(schema.items || {type: 'string'}), + }; + } else if (schema.type === 'object') { + let additionalProperties: boolean | PrimitiveTypeSchema | undefined; + if ( + schema.additionalProperties && + typeof schema.additionalProperties === 'object' + ) { + additionalProperties = { + type: schema.additionalProperties.type as + | 'string' + | 'integer' + | 'float' + | 'boolean', + } as PrimitiveTypeSchema; + } else { + additionalProperties = schema.additionalProperties !== false; + } + return { + type: 'object', + additionalProperties, + }; + } else { + return { + type: schema.type as + | 'string' + | 'integer' + | 'float' + | 'boolean' + | undefined, + } as PrimitiveTypeSchema; + } + } + + protected abstract initializeSession( + headers?: Record, + ): Promise; + + abstract toolGet( + toolName: string, + headers?: Record, + ): Promise; + + abstract toolsList( + toolsetName?: string, + headers?: Record, + ): Promise; + + abstract toolInvoke( + toolName: string, + arguments_: Record, + headers: Record, + ): Promise; +} diff --git a/packages/toolbox-core/src/toolbox_core/mcp/v20241105/mcp.ts b/packages/toolbox-core/src/toolbox_core/mcp/v20241105/mcp.ts new file mode 100644 index 00000000..f108d9ec --- /dev/null +++ b/packages/toolbox-core/src/toolbox_core/mcp/v20241105/mcp.ts @@ -0,0 +1,259 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import {AxiosError} from 'axios'; +import {McpHttpTransportBase} from '../transportBase.js'; +import * as types from './types.js'; + +import {ZodManifest} from '../../protocol.js'; +import {logApiError} from '../../errorUtils.js'; + +import {v4 as uuidv4} from 'uuid'; +import {VERSION} from '../../version.js'; + +export class McpHttpTransportV20241105 extends McpHttpTransportBase { + async #sendRequest( + url: string, + request: types.MCPRequest | types.MCPNotification, + paramsOverride?: unknown, + headers?: Record, + ): Promise { + const params = paramsOverride || request.params; + let payload: types.JSONRPCRequest | types.JSONRPCNotification; + + const isNotification = !('getResultModel' in request); + const method = request.method; + + if (isNotification) { + payload = { + jsonrpc: '2.0', + method, + params: params as Record, + }; + } else { + payload = { + jsonrpc: '2.0', + id: uuidv4(), + method, + params: params as Record, + }; + } + + try { + const response = await this._session.post(url, payload, {headers}); + + if ( + response.status !== 200 && + response.status !== 204 && + response.status !== 202 + ) { + const errorText = JSON.stringify(response.data); + throw new Error( + `API request failed with status ${response.status} (${response.statusText}). Server response: ${errorText}`, + ); + } + + if (response.status === 204 || response.status === 202) { + return null; + } + + const jsonResp = response.data; + + if (jsonResp.error) { + const errResult = types.JSONRPCErrorSchema.safeParse(jsonResp); + let message = `MCP request failed: ${JSON.stringify(jsonResp.error)}`; + let code = 'MCP_ERROR'; + + if (errResult.success) { + const err = errResult.data.error; + message = `MCP request failed with code ${err.code}: ${err.message}`; + code = String(err.code); + } + + throw new AxiosError( + message, + code, + response.config, + response.request, + response, + ); + } + + // Parse Result + if (!isNotification && 'getResultModel' in request) { + const rpcRespResult = types.JSONRPCResponseSchema.safeParse(jsonResp); + if (rpcRespResult.success) { + const resultModel = request.getResultModel(); + return resultModel.parse(rpcRespResult.data.result); + } + throw new Error('Failed to parse JSON-RPC response structure'); + } + + return null; + } catch (error) { + logApiError(`Error posting data to ${url}:`, error); + throw error; + } + } + + protected async initializeSession( + headers?: Record, + ): Promise { + const params: types.InitializeRequestParams = { + protocolVersion: this._protocolVersion, + capabilities: {}, + clientInfo: { + name: 'toolbox-js-sdk', + version: VERSION, + }, + }; + + const result = await this.#sendRequest( + this._mcpBaseUrl, + types.InitializeRequest, + params, + headers, + ); + + if (!result) { + const error = new Error('Initialization failed: No response'); + logApiError('MCP Initialization Error', error); + throw error; + } + + this._serverVersion = result.serverInfo.version; + + if (result.protocolVersion !== this._protocolVersion) { + const error = new Error( + `MCP version mismatch: client does not support server version ${result.protocolVersion}`, + ); + logApiError('MCP Initialization Error', error); + throw error; + } + + if (!result.capabilities.tools) { + const error = new Error( + "Server does not support the 'tools' capability.", + ); + logApiError('MCP Initialization Error', error); + throw error; + } + + await this.#sendRequest( + this._mcpBaseUrl, + types.InitializedNotification, + {}, + headers, + ); + } + + async toolsList( + toolsetName?: string, + headers?: Record, + ): Promise { + await this.ensureInitialized(headers); + const url = `${this._mcpBaseUrl}${toolsetName || ''}`; + + const result = await this.#sendRequest( + url, + types.ListToolsRequest, + {}, + headers, + ); + + if (!result) { + const error = new Error('Failed to list tools: No response from server.'); + logApiError(`Error listing tools from ${url}`, error); + throw error; + } + + if (this._serverVersion === null) { + const error = new Error('Server version not available.'); + logApiError('Error listing tools', error); + throw error; + } + + const toolsMap: Record< + string, + { + description: string; + parameters: import('../../protocol.js').ParameterSchema[]; + authRequired?: string[]; + } + > = {}; + + for (const tool of result.tools) { + toolsMap[tool.name] = this.convertToolSchema(tool); + } + + return { + serverVersion: this._serverVersion, + tools: toolsMap as unknown as ZodManifest['tools'], // Cast to verify structure compliance or rely on structural typing + }; + } + + async toolGet( + toolName: string, + headers?: Record, + ): Promise { + const manifest = await this.toolsList(undefined, headers); + if (!manifest.tools[toolName]) { + const error = new Error(`Tool '${toolName}' not found.`); + logApiError(`Error getting tool ${toolName}`, error); + throw error; + } + + return { + serverVersion: manifest.serverVersion, + tools: { + [toolName]: manifest.tools[toolName], + }, + }; + } + + async toolInvoke( + toolName: string, + arguments_: Record, + headers: Record, + ): Promise { + await this.ensureInitialized(headers); + + const params: types.CallToolRequestParams = { + name: toolName, + arguments: arguments_, + }; + + const result = await this.#sendRequest( + this._mcpBaseUrl, + types.CallToolRequest, + params, + headers, + ); + + if (!result) { + const error = new Error( + `Failed to invoke tool '${toolName}': No response from server.`, + ); + logApiError(`Error invoking tool ${toolName}`, error); + throw error; + } + + const textContent = result.content + .filter(c => c.type === 'text') + .map(c => c.text) + .join(''); + + return textContent || 'null'; + } +} diff --git a/packages/toolbox-core/src/toolbox_core/mcp/v20241105/types.ts b/packages/toolbox-core/src/toolbox_core/mcp/v20241105/types.ts new file mode 100644 index 00000000..54406317 --- /dev/null +++ b/packages/toolbox-core/src/toolbox_core/mcp/v20241105/types.ts @@ -0,0 +1,158 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import {z} from 'zod'; + +export const RequestParamsSchema = z.object({}).passthrough(); +export type RequestParams = z.infer; + +export const JSONRPCRequestSchema = z.object({ + jsonrpc: z.literal('2.0').default('2.0'), + id: z.union([z.string(), z.number()]).optional(), // Default handled in usage, or logic + method: z.string(), + params: z.record(z.unknown()).optional().nullable(), +}); +export type JSONRPCRequest = z.infer; + +export const JSONRPCNotificationSchema = z.object({ + jsonrpc: z.literal('2.0').default('2.0'), + method: z.string(), + params: z.record(z.unknown()).optional().nullable(), +}); +export type JSONRPCNotification = z.infer; + +export const JSONRPCResponseSchema = z.object({ + jsonrpc: z.literal('2.0'), + id: z.union([z.string(), z.number()]), + result: z.record(z.unknown()), +}); +export type JSONRPCResponse = z.infer; + +export const ErrorDataSchema = z.object({ + code: z.number().int(), + message: z.string(), + data: z.unknown().optional().nullable(), +}); +export type ErrorData = z.infer; + +export const JSONRPCErrorSchema = z.object({ + jsonrpc: z.literal('2.0'), + id: z.union([z.string(), z.number()]), + error: ErrorDataSchema, +}); +export type JSONRPCError = z.infer; + +export const BaseMetadataSchema = z + .object({ + name: z.string(), + }) + .passthrough(); +export type BaseMetadata = z.infer; + +export const ImplementationSchema = BaseMetadataSchema.extend({ + version: z.string(), +}); +export type Implementation = z.infer; + +export const ClientCapabilitiesSchema = z.object({}).passthrough(); +export type ClientCapabilities = z.infer; + +export const InitializeRequestParamsSchema = RequestParamsSchema.extend({ + protocolVersion: z.string(), + capabilities: ClientCapabilitiesSchema, + clientInfo: ImplementationSchema, +}); +export type InitializeRequestParams = z.infer< + typeof InitializeRequestParamsSchema +>; + +export const ServerCapabilitiesSchema = z.object({ + prompts: z.record(z.unknown()).optional().nullable(), + tools: z.record(z.unknown()).optional().nullable(), +}); +export type ServerCapabilities = z.infer; + +export const InitializeResultSchema = z.object({ + protocolVersion: z.string(), + capabilities: ServerCapabilitiesSchema, + serverInfo: ImplementationSchema, + instructions: z.string().optional().nullable(), +}); +export type InitializeResult = z.infer; + +export const ToolSchema = BaseMetadataSchema.extend({ + description: z.string().optional().nullable(), + inputSchema: z.record(z.unknown()), +}); + +export type Tool = z.infer; + +export const ListToolsResultSchema = z.object({ + tools: z.array(ToolSchema), +}); +export type ListToolsResult = z.infer; + +export const TextContentSchema = z.object({ + type: z.literal('text'), + text: z.string(), +}); +export type TextContent = z.infer; + +export const CallToolResultSchema = z.object({ + content: z.array(TextContentSchema), + isError: z.boolean().default(false).optional(), +}); +export type CallToolResult = z.infer; + +// Generic Request/Notification types for internal usage (not full schemas) +export type MCPRequest = { + method: string; + params?: Record | unknown | null; + getResultModel: () => z.ZodType; +}; + +export type MCPNotification = { + method: string; + params?: Record | unknown | null; +}; + +// Request/Notification Classes/Factories +export const InitializeRequest: MCPRequest = { + method: 'initialize', + // params handled at runtime + getResultModel: () => InitializeResultSchema, +}; + +export const InitializedNotification: MCPNotification = { + method: 'notifications/initialized', + params: {}, +}; + +export const ListToolsRequest: MCPRequest = { + method: 'tools/list', + params: {}, + getResultModel: () => ListToolsResultSchema, +}; + +export const CallToolRequestParamsSchema = z.object({ + name: z.string(), + arguments: z.record(z.unknown()), +}); +export type CallToolRequestParams = z.infer; + +export const CallToolRequest: MCPRequest = { + method: 'tools/call', + // params computed at runtime + getResultModel: () => CallToolResultSchema, +}; diff --git a/packages/toolbox-core/src/toolbox_core/mcp/v20250326/mcp.ts b/packages/toolbox-core/src/toolbox_core/mcp/v20250326/mcp.ts new file mode 100644 index 00000000..f0cd0881 --- /dev/null +++ b/packages/toolbox-core/src/toolbox_core/mcp/v20250326/mcp.ts @@ -0,0 +1,289 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import {AxiosError} from 'axios'; +import {McpHttpTransportBase} from '../transportBase.js'; +import * as types from './types.js'; + +import {ZodManifest} from '../../protocol.js'; +import {logApiError} from '../../errorUtils.js'; + +import {v4 as uuidv4} from 'uuid'; +import {VERSION} from '../../version.js'; + +export class McpHttpTransportV20250326 extends McpHttpTransportBase { + private _sessionId: string | null = null; + + async #sendRequest( + url: string, + request: types.MCPRequest | types.MCPNotification, + paramsOverride?: unknown, + headers?: Record, + ): Promise { + const params = paramsOverride || request.params; + let payload: types.JSONRPCRequest | types.JSONRPCNotification; + + const isNotification = !('getResultModel' in request); + const method = request.method; + + if (isNotification) { + payload = { + jsonrpc: '2.0', + method, + params: params as Record, + }; + } else { + payload = { + jsonrpc: '2.0', + id: uuidv4(), + method, + params: params as Record, + }; + } + + // Inject Session ID into headers if available (v2025-03-26 specific) + const reqHeaders = {...(headers || {})}; + if (request.method !== 'initialize' && this._sessionId) { + reqHeaders['Mcp-Session-Id'] = this._sessionId; + } + + try { + const response = await this._session.post(url, payload, { + headers: reqHeaders, + }); + + if (request.method === 'initialize') { + const sessionId = + response.headers['mcp-session-id'] || + response.headers['Mcp-Session-Id']; + if (sessionId) { + this._sessionId = sessionId; + } + } + + if ( + response.status !== 200 && + response.status !== 204 && + response.status !== 202 + ) { + const errorText = JSON.stringify(response.data); + throw new Error( + `API request failed with status ${response.status} (${response.statusText}). Server response: ${errorText}`, + ); + } + + if (response.status === 204 || response.status === 202) { + return null; + } + + const jsonResp = response.data; + + if (jsonResp.error) { + const errResult = types.JSONRPCErrorSchema.safeParse(jsonResp); + let message = `MCP request failed: ${JSON.stringify(jsonResp.error)}`; + let code = 'MCP_ERROR'; + + if (errResult.success) { + const err = errResult.data.error; + message = `MCP request failed with code ${err.code}: ${err.message}`; + code = String(err.code); + } + + throw new AxiosError( + message, + code, + response.config, + response.request, + response, + ); + } + + // Parse Result + if (!isNotification && 'getResultModel' in request) { + const rpcRespResult = types.JSONRPCResponseSchema.safeParse(jsonResp); + if (rpcRespResult.success) { + const resultModel = request.getResultModel(); + return resultModel.parse(rpcRespResult.data.result); + } + throw new Error('Failed to parse JSON-RPC response structure'); + } + + return null; + } catch (error) { + logApiError(`Error posting data to ${url}:`, error); + throw error; + } + } + + protected async initializeSession( + headers?: Record, + ): Promise { + const params: types.InitializeRequestParams = { + protocolVersion: this._protocolVersion, + capabilities: {}, + clientInfo: { + name: 'toolbox-js-sdk', + version: VERSION, + }, + }; + + const result = await this.#sendRequest( + this._mcpBaseUrl, + types.InitializeRequest, + params, + headers, + ); + + if (!result) { + const error = new Error('Initialization failed: No response'); + logApiError('MCP Initialization Error', error); + throw error; + } + + this._serverVersion = result.serverInfo.version; + + if (result.protocolVersion !== this._protocolVersion) { + const error = new Error( + `MCP version mismatch: client does not support server version ${result.protocolVersion}`, + ); + logApiError('MCP Initialization Error', error); + throw error; + } + + if (!result.capabilities.tools) { + const error = new Error( + "Server does not support the 'tools' capability.", + ); + logApiError('MCP Initialization Error', error); + throw error; + } + + // Extract session ID from extra fields (v2025-03-26 specific) + // Session ID is captured from headers in #sendRequest + + if (!this._sessionId) { + const error = new Error( + 'Server did not return a Mcp-Session-Id during initialization.', + ); + logApiError('MCP Initialization Error', error); + throw error; + } + + await this.#sendRequest( + this._mcpBaseUrl, + types.InitializedNotification, + {}, + headers, + ); + } + + async toolsList( + toolsetName?: string, + headers?: Record, + ): Promise { + await this.ensureInitialized(headers); + const url = `${this._mcpBaseUrl}${toolsetName || ''}`; + + const result = await this.#sendRequest( + url, + types.ListToolsRequest, + {}, + headers, + ); + + if (!result) { + const error = new Error('Failed to list tools: No response from server.'); + logApiError(`Error listing tools from ${url}`, error); + throw error; + } + + if (this._serverVersion === null) { + const error = new Error('Server version not available.'); + logApiError('Error listing tools', error); + throw error; + } + + const toolsMap: Record< + string, + { + description: string; + parameters: import('../../protocol.js').ParameterSchema[]; + authRequired?: string[]; + } + > = {}; + + for (const tool of result.tools) { + toolsMap[tool.name] = this.convertToolSchema(tool); + } + + return { + serverVersion: this._serverVersion, + tools: toolsMap as unknown as ZodManifest['tools'], // Cast to verify structure compliance or rely on structural typing + }; + } + + async toolGet( + toolName: string, + headers?: Record, + ): Promise { + const manifest = await this.toolsList(undefined, headers); + if (!manifest.tools[toolName]) { + const error = new Error(`Tool '${toolName}' not found.`); + logApiError(`Error getting tool ${toolName}`, error); + throw error; + } + + return { + serverVersion: manifest.serverVersion, + tools: { + [toolName]: manifest.tools[toolName], + }, + }; + } + + async toolInvoke( + toolName: string, + arguments_: Record, + headers: Record, + ): Promise { + await this.ensureInitialized(headers); + + const params: types.CallToolRequestParams = { + name: toolName, + arguments: arguments_, + }; + + const result = await this.#sendRequest( + this._mcpBaseUrl, + types.CallToolRequest, + params, + headers, + ); + + if (!result) { + const error = new Error( + `Failed to invoke tool '${toolName}': No response from server.`, + ); + logApiError(`Error invoking tool ${toolName}`, error); + throw error; + } + + const textContent = result.content + .filter(c => c.type === 'text') + .map(c => c.text) + .join(''); + + return textContent || 'null'; + } +} diff --git a/packages/toolbox-core/src/toolbox_core/mcp/v20250326/types.ts b/packages/toolbox-core/src/toolbox_core/mcp/v20250326/types.ts new file mode 100644 index 00000000..54406317 --- /dev/null +++ b/packages/toolbox-core/src/toolbox_core/mcp/v20250326/types.ts @@ -0,0 +1,158 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import {z} from 'zod'; + +export const RequestParamsSchema = z.object({}).passthrough(); +export type RequestParams = z.infer; + +export const JSONRPCRequestSchema = z.object({ + jsonrpc: z.literal('2.0').default('2.0'), + id: z.union([z.string(), z.number()]).optional(), // Default handled in usage, or logic + method: z.string(), + params: z.record(z.unknown()).optional().nullable(), +}); +export type JSONRPCRequest = z.infer; + +export const JSONRPCNotificationSchema = z.object({ + jsonrpc: z.literal('2.0').default('2.0'), + method: z.string(), + params: z.record(z.unknown()).optional().nullable(), +}); +export type JSONRPCNotification = z.infer; + +export const JSONRPCResponseSchema = z.object({ + jsonrpc: z.literal('2.0'), + id: z.union([z.string(), z.number()]), + result: z.record(z.unknown()), +}); +export type JSONRPCResponse = z.infer; + +export const ErrorDataSchema = z.object({ + code: z.number().int(), + message: z.string(), + data: z.unknown().optional().nullable(), +}); +export type ErrorData = z.infer; + +export const JSONRPCErrorSchema = z.object({ + jsonrpc: z.literal('2.0'), + id: z.union([z.string(), z.number()]), + error: ErrorDataSchema, +}); +export type JSONRPCError = z.infer; + +export const BaseMetadataSchema = z + .object({ + name: z.string(), + }) + .passthrough(); +export type BaseMetadata = z.infer; + +export const ImplementationSchema = BaseMetadataSchema.extend({ + version: z.string(), +}); +export type Implementation = z.infer; + +export const ClientCapabilitiesSchema = z.object({}).passthrough(); +export type ClientCapabilities = z.infer; + +export const InitializeRequestParamsSchema = RequestParamsSchema.extend({ + protocolVersion: z.string(), + capabilities: ClientCapabilitiesSchema, + clientInfo: ImplementationSchema, +}); +export type InitializeRequestParams = z.infer< + typeof InitializeRequestParamsSchema +>; + +export const ServerCapabilitiesSchema = z.object({ + prompts: z.record(z.unknown()).optional().nullable(), + tools: z.record(z.unknown()).optional().nullable(), +}); +export type ServerCapabilities = z.infer; + +export const InitializeResultSchema = z.object({ + protocolVersion: z.string(), + capabilities: ServerCapabilitiesSchema, + serverInfo: ImplementationSchema, + instructions: z.string().optional().nullable(), +}); +export type InitializeResult = z.infer; + +export const ToolSchema = BaseMetadataSchema.extend({ + description: z.string().optional().nullable(), + inputSchema: z.record(z.unknown()), +}); + +export type Tool = z.infer; + +export const ListToolsResultSchema = z.object({ + tools: z.array(ToolSchema), +}); +export type ListToolsResult = z.infer; + +export const TextContentSchema = z.object({ + type: z.literal('text'), + text: z.string(), +}); +export type TextContent = z.infer; + +export const CallToolResultSchema = z.object({ + content: z.array(TextContentSchema), + isError: z.boolean().default(false).optional(), +}); +export type CallToolResult = z.infer; + +// Generic Request/Notification types for internal usage (not full schemas) +export type MCPRequest = { + method: string; + params?: Record | unknown | null; + getResultModel: () => z.ZodType; +}; + +export type MCPNotification = { + method: string; + params?: Record | unknown | null; +}; + +// Request/Notification Classes/Factories +export const InitializeRequest: MCPRequest = { + method: 'initialize', + // params handled at runtime + getResultModel: () => InitializeResultSchema, +}; + +export const InitializedNotification: MCPNotification = { + method: 'notifications/initialized', + params: {}, +}; + +export const ListToolsRequest: MCPRequest = { + method: 'tools/list', + params: {}, + getResultModel: () => ListToolsResultSchema, +}; + +export const CallToolRequestParamsSchema = z.object({ + name: z.string(), + arguments: z.record(z.unknown()), +}); +export type CallToolRequestParams = z.infer; + +export const CallToolRequest: MCPRequest = { + method: 'tools/call', + // params computed at runtime + getResultModel: () => CallToolResultSchema, +}; diff --git a/packages/toolbox-core/src/toolbox_core/mcp/v20250618/mcp.ts b/packages/toolbox-core/src/toolbox_core/mcp/v20250618/mcp.ts new file mode 100644 index 00000000..e14bdd87 --- /dev/null +++ b/packages/toolbox-core/src/toolbox_core/mcp/v20250618/mcp.ts @@ -0,0 +1,265 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import {AxiosError} from 'axios'; +import {McpHttpTransportBase} from '../transportBase.js'; +import * as types from './types.js'; + +import {ZodManifest} from '../../protocol.js'; +import {logApiError} from '../../errorUtils.js'; + +import {v4 as uuidv4} from 'uuid'; +import {VERSION} from '../../version.js'; + +export class McpHttpTransportV20250618 extends McpHttpTransportBase { + async #sendRequest( + url: string, + request: types.MCPRequest | types.MCPNotification, + paramsOverride?: unknown, + headers?: Record, + ): Promise { + const params = paramsOverride || request.params; + let payload: types.JSONRPCRequest | types.JSONRPCNotification; + + const isNotification = !('getResultModel' in request); + const method = request.method; + + if (isNotification) { + payload = { + jsonrpc: '2.0', + method, + params: params as Record, + }; + } else { + payload = { + jsonrpc: '2.0', + id: uuidv4(), + method, + params: params as Record, + }; + } + + // Inject Protocol Version into headers (v2025-06-18 specific) + const reqHeaders = {...(headers || {})}; + reqHeaders['MCP-Protocol-Version'] = this._protocolVersion; + + try { + const response = await this._session.post(url, payload, { + headers: reqHeaders, + }); + + if ( + response.status !== 200 && + response.status !== 204 && + response.status !== 202 + ) { + const errorText = JSON.stringify(response.data); + throw new Error( + `API request failed with status ${response.status} (${response.statusText}). Server response: ${errorText}`, + ); + } + + if (response.status === 204 || response.status === 202) { + return null; + } + + const jsonResp = response.data; + + if (jsonResp.error) { + const errResult = types.JSONRPCErrorSchema.safeParse(jsonResp); + let message = `MCP request failed: ${JSON.stringify(jsonResp.error)}`; + let code = 'MCP_ERROR'; + + if (errResult.success) { + const err = errResult.data.error; + message = `MCP request failed with code ${err.code}: ${err.message}`; + code = String(err.code); + } + + throw new AxiosError( + message, + code, + response.config, + response.request, + response, + ); + } + + // Parse Result + if (!isNotification && 'getResultModel' in request) { + const rpcRespResult = types.JSONRPCResponseSchema.safeParse(jsonResp); + if (rpcRespResult.success) { + const resultModel = request.getResultModel(); + return resultModel.parse(rpcRespResult.data.result); + } + throw new Error('Failed to parse JSON-RPC response structure'); + } + + return null; + } catch (error) { + logApiError(`Error posting data to ${url}:`, error); + throw error; + } + } + + protected async initializeSession( + headers?: Record, + ): Promise { + const params: types.InitializeRequestParams = { + protocolVersion: this._protocolVersion, + capabilities: {}, + clientInfo: { + name: 'toolbox-js-sdk', + version: VERSION, + }, + }; + + const result = await this.#sendRequest( + this._mcpBaseUrl, + types.InitializeRequest, + params, + headers, + ); + + if (!result) { + const error = new Error('Initialization failed: No response'); + logApiError('MCP Initialization Error', error); + throw error; + } + + this._serverVersion = result.serverInfo.version; + + if (result.protocolVersion !== this._protocolVersion) { + const error = new Error( + `MCP version mismatch: client does not support server version ${result.protocolVersion}`, + ); + logApiError('MCP Initialization Error', error); + throw error; + } + + if (!result.capabilities.tools) { + const error = new Error( + "Server does not support the 'tools' capability.", + ); + logApiError('MCP Initialization Error', error); + throw error; + } + + await this.#sendRequest( + this._mcpBaseUrl, + types.InitializedNotification, + {}, + headers, + ); + } + + async toolsList( + toolsetName?: string, + headers?: Record, + ): Promise { + await this.ensureInitialized(headers); + const url = `${this._mcpBaseUrl}${toolsetName || ''}`; + + const result = await this.#sendRequest( + url, + types.ListToolsRequest, + {}, + headers, + ); + + if (!result) { + const error = new Error('Failed to list tools: No response from server.'); + logApiError(`Error listing tools from ${url}`, error); + throw error; + } + + if (this._serverVersion === null) { + const error = new Error('Server version not available.'); + logApiError('Error listing tools', error); + throw error; + } + + const toolsMap: Record< + string, + { + description: string; + parameters: import('../../protocol.js').ParameterSchema[]; + authRequired?: string[]; + } + > = {}; + + for (const tool of result.tools) { + toolsMap[tool.name] = this.convertToolSchema(tool); + } + + return { + serverVersion: this._serverVersion, + tools: toolsMap as unknown as ZodManifest['tools'], // Cast to verify structure compliance or rely on structural typing + }; + } + + async toolGet( + toolName: string, + headers?: Record, + ): Promise { + const manifest = await this.toolsList(undefined, headers); + if (!manifest.tools[toolName]) { + const error = new Error(`Tool '${toolName}' not found.`); + logApiError(`Error getting tool ${toolName}`, error); + throw error; + } + + return { + serverVersion: manifest.serverVersion, + tools: { + [toolName]: manifest.tools[toolName], + }, + }; + } + + async toolInvoke( + toolName: string, + arguments_: Record, + headers: Record, + ): Promise { + await this.ensureInitialized(headers); + + const params: types.CallToolRequestParams = { + name: toolName, + arguments: arguments_, + }; + + const result = await this.#sendRequest( + this._mcpBaseUrl, + types.CallToolRequest, + params, + headers, + ); + + if (!result) { + const error = new Error( + `Failed to invoke tool '${toolName}': No response from server.`, + ); + logApiError(`Error invoking tool ${toolName}`, error); + throw error; + } + + const textContent = result.content + .filter(c => c.type === 'text') + .map(c => c.text) + .join(''); + + return textContent || 'null'; + } +} diff --git a/packages/toolbox-core/src/toolbox_core/mcp/v20250618/types.ts b/packages/toolbox-core/src/toolbox_core/mcp/v20250618/types.ts new file mode 100644 index 00000000..54406317 --- /dev/null +++ b/packages/toolbox-core/src/toolbox_core/mcp/v20250618/types.ts @@ -0,0 +1,158 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import {z} from 'zod'; + +export const RequestParamsSchema = z.object({}).passthrough(); +export type RequestParams = z.infer; + +export const JSONRPCRequestSchema = z.object({ + jsonrpc: z.literal('2.0').default('2.0'), + id: z.union([z.string(), z.number()]).optional(), // Default handled in usage, or logic + method: z.string(), + params: z.record(z.unknown()).optional().nullable(), +}); +export type JSONRPCRequest = z.infer; + +export const JSONRPCNotificationSchema = z.object({ + jsonrpc: z.literal('2.0').default('2.0'), + method: z.string(), + params: z.record(z.unknown()).optional().nullable(), +}); +export type JSONRPCNotification = z.infer; + +export const JSONRPCResponseSchema = z.object({ + jsonrpc: z.literal('2.0'), + id: z.union([z.string(), z.number()]), + result: z.record(z.unknown()), +}); +export type JSONRPCResponse = z.infer; + +export const ErrorDataSchema = z.object({ + code: z.number().int(), + message: z.string(), + data: z.unknown().optional().nullable(), +}); +export type ErrorData = z.infer; + +export const JSONRPCErrorSchema = z.object({ + jsonrpc: z.literal('2.0'), + id: z.union([z.string(), z.number()]), + error: ErrorDataSchema, +}); +export type JSONRPCError = z.infer; + +export const BaseMetadataSchema = z + .object({ + name: z.string(), + }) + .passthrough(); +export type BaseMetadata = z.infer; + +export const ImplementationSchema = BaseMetadataSchema.extend({ + version: z.string(), +}); +export type Implementation = z.infer; + +export const ClientCapabilitiesSchema = z.object({}).passthrough(); +export type ClientCapabilities = z.infer; + +export const InitializeRequestParamsSchema = RequestParamsSchema.extend({ + protocolVersion: z.string(), + capabilities: ClientCapabilitiesSchema, + clientInfo: ImplementationSchema, +}); +export type InitializeRequestParams = z.infer< + typeof InitializeRequestParamsSchema +>; + +export const ServerCapabilitiesSchema = z.object({ + prompts: z.record(z.unknown()).optional().nullable(), + tools: z.record(z.unknown()).optional().nullable(), +}); +export type ServerCapabilities = z.infer; + +export const InitializeResultSchema = z.object({ + protocolVersion: z.string(), + capabilities: ServerCapabilitiesSchema, + serverInfo: ImplementationSchema, + instructions: z.string().optional().nullable(), +}); +export type InitializeResult = z.infer; + +export const ToolSchema = BaseMetadataSchema.extend({ + description: z.string().optional().nullable(), + inputSchema: z.record(z.unknown()), +}); + +export type Tool = z.infer; + +export const ListToolsResultSchema = z.object({ + tools: z.array(ToolSchema), +}); +export type ListToolsResult = z.infer; + +export const TextContentSchema = z.object({ + type: z.literal('text'), + text: z.string(), +}); +export type TextContent = z.infer; + +export const CallToolResultSchema = z.object({ + content: z.array(TextContentSchema), + isError: z.boolean().default(false).optional(), +}); +export type CallToolResult = z.infer; + +// Generic Request/Notification types for internal usage (not full schemas) +export type MCPRequest = { + method: string; + params?: Record | unknown | null; + getResultModel: () => z.ZodType; +}; + +export type MCPNotification = { + method: string; + params?: Record | unknown | null; +}; + +// Request/Notification Classes/Factories +export const InitializeRequest: MCPRequest = { + method: 'initialize', + // params handled at runtime + getResultModel: () => InitializeResultSchema, +}; + +export const InitializedNotification: MCPNotification = { + method: 'notifications/initialized', + params: {}, +}; + +export const ListToolsRequest: MCPRequest = { + method: 'tools/list', + params: {}, + getResultModel: () => ListToolsResultSchema, +}; + +export const CallToolRequestParamsSchema = z.object({ + name: z.string(), + arguments: z.record(z.unknown()), +}); +export type CallToolRequestParams = z.infer; + +export const CallToolRequest: MCPRequest = { + method: 'tools/call', + // params computed at runtime + getResultModel: () => CallToolResultSchema, +}; diff --git a/packages/toolbox-core/src/toolbox_core/protocol.ts b/packages/toolbox-core/src/toolbox_core/protocol.ts index c5b6fad0..9583478a 100644 --- a/packages/toolbox-core/src/toolbox_core/protocol.ts +++ b/packages/toolbox-core/src/toolbox_core/protocol.ts @@ -14,6 +14,22 @@ import {z, ZodRawShape, ZodTypeAny, ZodObject} from 'zod'; +export enum Protocol { + TOOLBOX = 'toolbox', + MCP_v20241105 = '2024-11-05', + MCP_v20250326 = '2025-03-26', + MCP_v20250618 = '2025-06-18', + MCP = MCP_v20250618, // Default MCP +} + +export function getSupportedMcpVersions(): Protocol[] { + return [ + Protocol.MCP_v20241105, + Protocol.MCP_v20250326, + Protocol.MCP_v20250618, + ]; +} + // Type Definitions interface StringType { type: 'string'; diff --git a/packages/toolbox-core/src/toolbox_core/version.ts b/packages/toolbox-core/src/toolbox_core/version.ts new file mode 100644 index 00000000..4de7d815 --- /dev/null +++ b/packages/toolbox-core/src/toolbox_core/version.ts @@ -0,0 +1,15 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +export const VERSION = '0.1.5'; // x-release-please-version diff --git a/packages/toolbox-core/test/e2e/test.e2e.ts b/packages/toolbox-core/test/e2e/test.e2e.ts index 2a9b41f8..4b1df50e 100644 --- a/packages/toolbox-core/test/e2e/test.e2e.ts +++ b/packages/toolbox-core/test/e2e/test.e2e.ts @@ -14,6 +14,7 @@ import {ToolboxClient} from '../../src/toolbox_core/client'; import {ToolboxTool} from '../../src/toolbox_core/tool'; +import {Protocol} from '../../src/toolbox_core/protocol'; import {AxiosError} from 'axios'; import {CustomGlobal} from './types'; @@ -27,7 +28,12 @@ describe('ToolboxClient E2E Tests', () => { const projectId = (globalThis as CustomGlobal).__GOOGLE_CLOUD_PROJECT__; beforeAll(async () => { - commonToolboxClient = new ToolboxClient(testBaseUrl); + commonToolboxClient = new ToolboxClient( + testBaseUrl, + undefined, + undefined, + Protocol.TOOLBOX, + ); }); beforeEach(async () => { diff --git a/packages/toolbox-core/test/e2e/test.mcp.e2e.ts b/packages/toolbox-core/test/e2e/test.mcp.e2e.ts new file mode 100644 index 00000000..96bfcd18 --- /dev/null +++ b/packages/toolbox-core/test/e2e/test.mcp.e2e.ts @@ -0,0 +1,599 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import {ToolboxClient} from '../../src/toolbox_core/client.js'; +import {ToolboxTool} from '../../src/toolbox_core/tool.js'; +import {getSupportedMcpVersions} from '../../src/toolbox_core/protocol.js'; + +import {AxiosError} from 'axios'; +import {CustomGlobal} from './types.js'; +import {authTokenGetter} from './utils.js'; +import {ZodOptional, ZodNullable, 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'); + }); + + 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).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 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 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'; + }; + + 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 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'.", + ); + }); + }); + + 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'); + + 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 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 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 auth', async () => { + const tool = await commonToolboxClient.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 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({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', + ); + }); + + 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(); + 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 () => { + const tool = await commonToolboxClient.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/, + ), + }), + }), + ); + } + }); + }); + + 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'); + + // Optional param 'data' + expect(shape.data.isOptional()).toBe(true); + expect(shape.data.isNullable()).toBe(true); + expect( + (shape.data as ZodOptional>).unwrap().unwrap() + ._def.typeName, + ).toBe('ZodString'); + + // Optional param 'id' + expect(shape.id.isOptional()).toBe(true); + expect(shape.id.isNullable()).toBe(true); + expect( + (shape.id as ZodOptional>).unwrap().unwrap() + ._def.typeName, + ).toBe('ZodNumber'); + }); + + 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'); + }); + + 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 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 id provided', async () => { + const response = await searchRowsTool({ + email: 'twishabansal@google.com', + id: 1, + }); + 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, + }); + 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 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 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'); + }); + + 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 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 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'); + }); + + 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'); + }); + }); + describe('Map/Object Params E2E Tests', () => { + let processDataTool: ReturnType; + + beforeAll(async () => { + processDataTool = await commonToolboxClient.loadTool('process-data'); + }); + + 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 + }); + + 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/, + ); + }); + }); + }, +); diff --git a/packages/toolbox-core/test/mcp/test.base.ts b/packages/toolbox-core/test/mcp/test.base.ts new file mode 100644 index 00000000..2bbacbce --- /dev/null +++ b/packages/toolbox-core/test/mcp/test.base.ts @@ -0,0 +1,337 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import {McpHttpTransportBase} from '../../src/toolbox_core/mcp/transportBase.js'; +import {Protocol, ZodManifest} from '../../src/toolbox_core/protocol.js'; +import axios, {AxiosInstance} from 'axios'; +import {jest} from '@jest/globals'; + +jest.mock('axios'); +const mockedAxios = axios as jest.Mocked; + +class TestMcpTransport extends McpHttpTransportBase { + public initializeSessionMock = jest.fn<() => Promise>(); + public toolGetMock = + jest.fn< + ( + toolName: string, + headers?: Record, + ) => Promise + >(); + public toolsListMock = + jest.fn< + ( + toolsetName?: string, + headers?: Record, + ) => Promise + >(); + public toolInvokeMock = + jest.fn< + ( + toolName: string, + arguments_: Record, + headers: Record, + ) => Promise + >(); + + constructor( + baseUrl: string, + session?: AxiosInstance, + protocol: Protocol = Protocol.MCP, + ) { + super(baseUrl, session, protocol); + } + + protected async initializeSession(): Promise { + return this.initializeSessionMock(); + } + + async toolGet( + toolName: string, + headers?: Record, + ): Promise { + return this.toolGetMock(toolName, headers); + } + + async toolsList( + toolsetName?: string, + headers?: Record, + ): Promise { + return this.toolsListMock(toolsetName, headers); + } + + async toolInvoke( + toolName: string, + arguments_: Record, + headers: Record, + ): Promise { + return this.toolInvokeMock(toolName, arguments_, headers); + } + + public testConvertToolSchema(toolData: Record) { + return this.convertToolSchema(toolData); + } + + // Helper to access protected ensureInitialized + public async testEnsureInitialized() { + return this.ensureInitialized(); + } + + public getSession(): AxiosInstance { + return this._session; + } +} + +describe('McpHttpTransportBase', () => { + const testBaseUrl = 'http://test.loc'; + let mockSession: jest.Mocked; + + beforeEach(() => { + mockSession = { + get: jest.fn(), + post: jest.fn(), + defaults: {headers: {}}, + interceptors: { + request: {use: jest.fn()}, + response: {use: jest.fn()}, + }, + } as unknown as jest.Mocked; + + mockedAxios.create.mockReturnValue(mockSession); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('constructor', () => { + it('should initialize with provided session', () => { + const transport = new TestMcpTransport(testBaseUrl, mockSession); + expect(transport.getSession()).toBe(mockSession); + expect(transport.baseUrl).toBe(`${testBaseUrl}/mcp/`); + }); + + it('should create new session if not provided', () => { + const transport = new TestMcpTransport(testBaseUrl); + expect(mockedAxios.create).toHaveBeenCalled(); + expect(transport.getSession()).toBe(mockSession); + expect(transport.baseUrl).toBe(`${testBaseUrl}/mcp/`); + }); + + it('should set protocol version', () => { + new TestMcpTransport(testBaseUrl, undefined, Protocol.MCP_v20241105); + }); + }); + + describe('ensureInitialized', () => { + it('should call initializeSession only once', async () => { + const transport = new TestMcpTransport(testBaseUrl); + transport.initializeSessionMock.mockResolvedValue(undefined); + + await transport.testEnsureInitialized(); + await transport.testEnsureInitialized(); + + expect(transport.initializeSessionMock).toHaveBeenCalledTimes(1); + }); + + it('should handle concurrent initialization calls', async () => { + const transport = new TestMcpTransport(testBaseUrl); + let resolveInit: () => void; + const initPromise = new Promise(resolve => { + resolveInit = resolve; + }); + transport.initializeSessionMock.mockReturnValue(initPromise); + + const p1 = transport.testEnsureInitialized(); + const p2 = transport.testEnsureInitialized(); + + resolveInit!(); + await Promise.all([p1, p2]); + + expect(transport.initializeSessionMock).toHaveBeenCalledTimes(1); + }); + }); + + describe('convertToolSchema', () => { + let transport: TestMcpTransport; + + beforeEach(() => { + transport = new TestMcpTransport(testBaseUrl); + }); + + it('should convert simple tool schema correctly', () => { + const toolData = { + name: 'testTool', + description: 'Test Description', + inputSchema: { + type: 'object', + properties: { + arg1: {type: 'string', description: 'desc1'}, + arg2: {type: 'integer'}, + }, + required: ['arg1'], + }, + }; + + const result = transport.testConvertToolSchema(toolData); + + expect(result.description).toBe('Test Description'); + expect(result.parameters).toHaveLength(2); + expect(result.parameters).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: 'arg1', + type: 'string', + description: 'desc1', + required: true, + }), + expect.objectContaining({ + name: 'arg2', + type: 'integer', + description: '', + required: false, + }), + ]), + ); + }); + + it('should handle array parameters', () => { + const toolData = { + description: 'Array Tool', + inputSchema: { + properties: { + tags: { + type: 'array', + items: {type: 'string'}, + }, + }, + }, + }; + + const result = transport.testConvertToolSchema(toolData); + expect(result.parameters[0]).toEqual( + expect.objectContaining({ + name: 'tags', + type: 'array', + items: {type: 'string'}, + }), + ); + }); + + it('should handle object parameters', () => { + const toolData = { + description: 'Object Tool', + inputSchema: { + properties: { + config: { + type: 'object', + additionalProperties: {type: 'boolean'}, + }, + meta: { + type: 'object', + }, + }, + }, + }; + + const result = transport.testConvertToolSchema(toolData); + const configParam = result.parameters.find(p => p.name === 'config'); + const metaParam = result.parameters.find(p => p.name === 'meta'); + + expect(configParam).toEqual( + expect.objectContaining({ + name: 'config', + type: 'object', + additionalProperties: {type: 'boolean'}, + }), + ); + + expect(metaParam).toEqual( + expect.objectContaining({ + name: 'meta', + type: 'object', + additionalProperties: true, + }), + ); + }); + + it('should handle tool with auth metadata', () => { + const toolData = { + name: 'authTool', + description: 'Auth required', + inputSchema: { + properties: { + secureParam: {type: 'string'}, + }, + }, + _meta: { + 'toolbox/authInvoke': ['scope:read'], + 'toolbox/authParam': { + secureParam: ['scope:admin'], + }, + }, + }; + + const result = transport.testConvertToolSchema(toolData); + expect(result.authRequired).toEqual(['scope:read']); + const param = result.parameters.find(p => p.name === 'secureParam'); + expect(param?.authSources).toEqual(['scope:admin']); + }); + + it('should handle minimal tool definition (defaults)', () => { + const toolData = { + name: 'minimal', + // No description, no inputSchema + }; + + const result = transport.testConvertToolSchema(toolData); + expect(result.description).toBe(''); + expect(result.parameters).toEqual([]); + expect(result.authRequired).toBeUndefined(); + }); + + it('should handle array without items (default to string)', () => { + const toolData = { + name: 'arrayDefault', + inputSchema: { + properties: { + list: {type: 'array'}, // No items defined + }, + }, + }; + + const result = transport.testConvertToolSchema(toolData); + expect(result.parameters[0]).toEqual( + expect.objectContaining({ + type: 'array', + items: {type: 'string'}, + }), + ); + }); + + it('should handle partial auth metadata', () => { + const toolData = { + name: 'partialAuth', + inputSchema: {properties: {a: {type: 'string'}}}, + _meta: { + 'toolbox/authInvoke': 'not-an-array', // Should be ignored + 'toolbox/authParam': 'not-an-object', // Should be ignored + }, + }; + const result = transport.testConvertToolSchema(toolData); + expect(result.authRequired).toBeUndefined(); + expect(result.parameters[0].authSources).toBeUndefined(); + }); + }); +}); diff --git a/packages/toolbox-core/test/mcp/test.v20241105.ts b/packages/toolbox-core/test/mcp/test.v20241105.ts new file mode 100644 index 00000000..4e0c5fd4 --- /dev/null +++ b/packages/toolbox-core/test/mcp/test.v20241105.ts @@ -0,0 +1,674 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import {McpHttpTransportV20241105} from '../../src/toolbox_core/mcp/v20241105/mcp.js'; +import {jest} from '@jest/globals'; +import axios, {AxiosInstance} from 'axios'; + +import {Protocol} from '../../src/toolbox_core/protocol.js'; + +jest.mock('axios', () => { + const actual = jest.requireActual('axios') as { + default: typeof import('axios'); + }; + return { + __esModule: true, + ...actual, + default: { + ...actual.default, + create: jest.fn(), + }, + }; +}); +const mockedAxios = axios as jest.Mocked; + +describe('McpHttpTransportV20241105', () => { + const testBaseUrl = 'http://test.loc'; + let mockSession: jest.Mocked; + let transport: McpHttpTransportV20241105; + + beforeEach(() => { + mockSession = { + get: jest.fn(), + post: jest.fn(), + defaults: {headers: {}}, + interceptors: { + request: {use: jest.fn()}, + response: {use: jest.fn()}, + }, + } as unknown as jest.Mocked; + + mockedAxios.create.mockReturnValue(mockSession); + transport = new McpHttpTransportV20241105( + testBaseUrl, + mockSession, + Protocol.MCP_v20241105, + ); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('initialization', () => { + it('should perform handshake successfully', async () => { + // Mock responses for initialization + // 1. InitializeRequest -> result with tools capability + // 2. InitializedNotification -> (no response needed usually, or empty) + + const initResponse = { + data: { + jsonrpc: '2.0', + id: '1', + result: { + protocolVersion: '2024-11-05', + capabilities: { + tools: {}, + }, + serverInfo: { + name: 'test-server', + version: '1.0.0', + }, + }, + }, + status: 200, + }; + + const initializedNotificationResponse = { + data: { + jsonrpc: '2.0', + }, + status: 200, + }; + + mockSession.post + .mockResolvedValueOnce(initResponse) + .mockResolvedValueOnce(initializedNotificationResponse); + + const listResponse = { + data: { + jsonrpc: '2.0', + id: '2', + result: { + tools: [], + }, + }, + status: 200, + }; + + mockSession.post.mockResolvedValueOnce(listResponse); + + await transport.toolsList(); + + expect(mockSession.post).toHaveBeenNthCalledWith( + 1, + `${testBaseUrl}/mcp/`, + expect.objectContaining({ + method: 'initialize', + params: expect.objectContaining({ + protocolVersion: '2024-11-05', + clientInfo: expect.any(Object), + }), + }), + expect.any(Object), + ); + + expect(mockSession.post).toHaveBeenNthCalledWith( + 2, + `${testBaseUrl}/mcp/`, + expect.objectContaining({ + method: 'notifications/initialized', + }), + expect.any(Object), + ); + }); + + it('should throw error on protocol version mismatch', async () => { + const initResponse = { + data: { + jsonrpc: '2.0', + id: '1', + result: { + protocolVersion: '2023-01-01', // Mismatch + capabilities: {tools: {}}, + serverInfo: {name: 'old-server', version: '0.1'}, + }, + }, + status: 200, + }; + + mockSession.post.mockResolvedValueOnce(initResponse); + + const errorSpy = jest + .spyOn(console, 'error') + .mockImplementation(() => {}); + await expect(transport.toolsList()).rejects.toThrow( + /MCP version mismatch/, + ); + errorSpy.mockRestore(); + }); + + it('should throw error if tools capability missing', async () => { + const initResponse = { + data: { + jsonrpc: '2.0', + id: '1', + result: { + protocolVersion: '2024-11-05', + capabilities: {}, // No tools + serverInfo: {name: 'server', version: '1.0'}, + }, + }, + status: 200, + }; + + mockSession.post.mockResolvedValueOnce(initResponse); + + const errorSpy = jest + .spyOn(console, 'error') + .mockImplementation(() => {}); + await expect(transport.toolsList()).rejects.toThrow( + /Server does not support the 'tools' capability/, + ); + errorSpy.mockRestore(); + }); + + it('should throw error if initialization returns no response (204)', async () => { + mockSession.post.mockResolvedValueOnce({ + status: 204, + data: null, + }); + + const errorSpy = jest + .spyOn(console, 'error') + .mockImplementation(() => {}); + await expect(transport.toolsList()).rejects.toThrow( + 'Initialization failed: No response', + ); + errorSpy.mockRestore(); + }); + + it('should handle initialized notification returning 202 without error', async () => { + const initResponse = { + data: { + jsonrpc: '2.0', + id: '1', + result: { + protocolVersion: '2024-11-05', + capabilities: {tools: {}}, + serverInfo: {name: 'test-server', version: '1.0.0'}, + }, + }, + status: 200, + }; + + const initializedNotificationResponse = { + data: '', + status: 202, + statusText: 'Accepted', + }; + + mockSession.post + .mockResolvedValueOnce(initResponse) + .mockResolvedValueOnce(initializedNotificationResponse); + + const listResponse = { + data: { + jsonrpc: '2.0', + id: '2', + result: { + tools: [], + }, + }, + status: 200, + }; + mockSession.post.mockResolvedValueOnce(listResponse); + + await expect(transport.toolsList()).resolves.not.toThrow(); + }); + + it('should propagate headers during initialization', async () => { + const initResponse = { + data: { + jsonrpc: '2.0', + id: '1', + result: { + protocolVersion: '2024-11-05', + capabilities: {tools: {}}, + serverInfo: {name: 'test-server', version: '1.0.0'}, + }, + }, + status: 200, + }; + + const initializedNotificationResponse = { + data: {jsonrpc: '2.0'}, + status: 200, + }; + + const listResponse = { + data: { + jsonrpc: '2.0', + id: '2', + result: {tools: []}, + }, + status: 200, + }; + + mockSession.post + .mockResolvedValueOnce(initResponse) + .mockResolvedValueOnce(initializedNotificationResponse) + .mockResolvedValueOnce(listResponse); + + const testHeaders = {'X-Test-Header': 'test-value'}; + await transport.toolsList(undefined, testHeaders); + + // Verify Initialize request has headers + expect(mockSession.post).toHaveBeenNthCalledWith( + 1, + expect.anything(), + expect.anything(), + expect.objectContaining({headers: testHeaders}), + ); + + // Verify Initialized notification has headers + expect(mockSession.post).toHaveBeenNthCalledWith( + 2, + expect.anything(), + expect.anything(), + expect.objectContaining({headers: testHeaders}), + ); + }); + }); + + describe('toolsList', () => { + beforeEach(() => { + // Setup successful init for tool tests + const initResponse = { + data: { + jsonrpc: '2.0', + id: '1', + result: { + protocolVersion: '2024-11-05', + capabilities: {tools: {}}, + serverInfo: {name: 'test-server', version: '1.0.0'}, + }, + }, + status: 200, + }; + const notifResponse = {data: {}, status: 200}; + + mockSession.post + .mockResolvedValueOnce(initResponse) + .mockResolvedValueOnce(notifResponse); + }); + + it('should return converted tools', async () => { + const listResponse = { + data: { + jsonrpc: '2.0', + id: '2', + result: { + tools: [ + { + name: 'testTool', + description: 'A test tool', + inputSchema: { + type: 'object', + properties: { + x: {type: 'string'}, + }, + }, + }, + ], + }, + }, + status: 200, + }; + + mockSession.post.mockResolvedValueOnce(listResponse); + + const manifest = await transport.toolsList(); + + expect(manifest.tools['testTool']).toBeDefined(); + expect(manifest.tools['testTool'].description).toBe('A test tool'); + expect(manifest.tools['testTool'].parameters).toBeDefined(); + }); + + it('should correctly map auth fields', async () => { + const listResponse = { + data: { + jsonrpc: '2.0', + id: '2', + result: { + tools: [ + { + name: 'authTool', + description: 'Tool with auth', + inputSchema: { + type: 'object', + properties: { + x: { + type: 'string', + }, + }, + }, + _meta: { + 'toolbox/authInvoke': ['service-auth'], + 'toolbox/authParam': { + x: ['param-auth'], + }, + }, + }, + ], + }, + }, + status: 200, + }; + + mockSession.post.mockResolvedValueOnce(listResponse); + + const manifest = await transport.toolsList(); + const tool = manifest.tools['authTool']; + + expect(tool).toBeDefined(); + expect(tool.authRequired).toEqual(['service-auth']); + expect(tool.parameters[0].authSources).toEqual(['param-auth']); + }); + + it('should throw if toolsList returns no response (204)', async () => { + mockSession.post.mockResolvedValueOnce({ + status: 204, + data: null, + }); + + const errorSpy = jest + .spyOn(console, 'error') + .mockImplementation(() => {}); + await expect(transport.toolsList()).rejects.toThrow( + 'Failed to list tools: No response from server.', + ); + errorSpy.mockRestore(); + }); + + it('should throw if server version is not available after init', async () => { + mockSession.post.mockReset(); + mockSession.post.mockResolvedValueOnce({ + data: { + jsonrpc: '2.0', + id: '2', + result: { + tools: [], + }, + }, + status: 200, + }); + jest + .spyOn( + transport as unknown as {ensureInitialized: () => Promise}, + 'ensureInitialized', + ) + .mockResolvedValue(undefined); + const errorSpy = jest + .spyOn(console, 'error') + .mockImplementation(() => {}); + await expect(transport.toolsList()).rejects.toThrow( + 'Server version not available.', + ); + errorSpy.mockRestore(); + }); + }); + + describe('toolGet', () => { + beforeEach(() => { + const initResponse = { + data: { + jsonrpc: '2.0', + id: '1', + result: { + protocolVersion: '2024-11-05', + capabilities: {tools: {}}, + serverInfo: {name: 'test-server', version: '1.0.0'}, + }, + }, + status: 200, + }; + mockSession.post + .mockResolvedValueOnce(initResponse) + .mockResolvedValueOnce({data: {}, status: 200}); + }); + + it('should return specific tool manifest', async () => { + const listResponse = { + data: { + jsonrpc: '2.0', + id: '2', + result: { + tools: [ + { + name: 'targetTool', + description: 'desc', + inputSchema: {type: 'object'}, + }, + { + name: 'otherTool', + description: 'desc2', + inputSchema: {type: 'object'}, + }, + ], + }, + }, + status: 200, + }; + + mockSession.post.mockResolvedValueOnce(listResponse); + + const manifest = await transport.toolGet('targetTool'); + + expect(manifest.tools).toHaveProperty('targetTool'); + expect(Object.keys(manifest.tools).length).toBe(1); + }); + + it('should throw if tool not found', async () => { + const listResponse = { + data: { + jsonrpc: '2.0', + id: '2', + result: {tools: []}, + }, + status: 200, + }; + + mockSession.post.mockResolvedValueOnce(listResponse); + + const errorSpy = jest + .spyOn(console, 'error') + .mockImplementation(() => {}); + await expect(transport.toolGet('missing')).rejects.toThrow( + /Tool 'missing' not found/, + ); + errorSpy.mockRestore(); + }); + }); + + describe('toolInvoke', () => { + beforeEach(() => { + // Init sequence + const initResponse = { + data: { + jsonrpc: '2.0', + id: '1', + result: { + protocolVersion: '2024-11-05', + capabilities: {tools: {}}, + serverInfo: {name: 'test-server', version: '1.0.0'}, + }, + }, + status: 200, + }; + mockSession.post + .mockResolvedValueOnce(initResponse) + .mockResolvedValueOnce({data: {}, status: 200}); + }); + + it('should invoke tool and return text content', async () => { + const invokeResponse = { + data: { + jsonrpc: '2.0', + id: '3', + result: { + content: [{type: 'text', text: 'Result output'}], + }, + }, + status: 200, + }; + + mockSession.post.mockResolvedValueOnce(invokeResponse); + + const result = await transport.toolInvoke('testTool', {arg: 'val'}, {}); + + expect(mockSession.post).toHaveBeenCalledWith( + `${testBaseUrl}/mcp/`, + expect.objectContaining({ + method: 'tools/call', + params: { + name: 'testTool', + arguments: {arg: 'val'}, + }, + }), + expect.any(Object), + ); + expect(result).toBe('Result output'); + }); + + it('should handle JSON-RPC errors', async () => { + const errorSpy = jest + .spyOn(console, 'error') + .mockImplementation(() => {}); + const errorResponse = { + data: { + jsonrpc: '2.0', + id: '3', + error: { + code: -32601, + message: 'Method not found', + }, + }, + status: 200, + }; + + mockSession.post.mockResolvedValueOnce(errorResponse); + + await expect(transport.toolInvoke('badTool', {}, {})).rejects.toThrow( + /MCP request failed with code -32601: Method not found/, + ); + errorSpy.mockRestore(); + }); + + it('should handle HTTP errors', async () => { + const errorSpy = jest + .spyOn(console, 'error') + .mockImplementation(() => {}); + const httpErrorResponse = { + data: 'Server Error', + status: 500, + statusText: 'Internal Server Error', + }; + + mockSession.post.mockResolvedValueOnce(httpErrorResponse); + + await expect(transport.toolInvoke('testTool', {}, {})).rejects.toThrow( + /API request failed with status 500/, + ); + errorSpy.mockRestore(); + }); + + it('should return "null" if content is empty', async () => { + const invokeResponse = { + data: { + jsonrpc: '2.0', + id: '3', + result: { + content: [], + }, + }, + status: 200, + }; + + mockSession.post.mockResolvedValueOnce(invokeResponse); + + const result = await transport.toolInvoke('testTool', {}, {}); + expect(result).toBe('null'); + expect(result).toBe('null'); + }); + + it('should throw if toolInvoke returns no response (204)', async () => { + mockSession.post.mockResolvedValueOnce({ + status: 204, + data: null, + }); + + const errorSpy = jest + .spyOn(console, 'error') + .mockImplementation(() => {}); + await expect(transport.toolInvoke('testTool', {}, {})).rejects.toThrow( + "Failed to invoke tool 'testTool': No response from server.", + ); + errorSpy.mockRestore(); + }); + + it('should throw if JSON-RPC response structure is invalid', async () => { + const errorSpy = jest + .spyOn(console, 'error') + .mockImplementation(() => {}); + const invalidResponse = { + data: { + jsonrpc: '2.0', + id: '3', + // Missing 'result' and 'error' + somethingElse: true, + }, + status: 200, + }; + + mockSession.post.mockResolvedValueOnce(invalidResponse); + + await expect(transport.toolInvoke('testTool', {}, {})).rejects.toThrow( + 'Failed to parse JSON-RPC response structure', + ); + errorSpy.mockRestore(); + }); + + it('should throw explicit error for malformed error object', async () => { + const errorSpy = jest + .spyOn(console, 'error') + .mockImplementation(() => {}); + const malformedErrorResponse = { + data: { + jsonrpc: '2.0', + id: '3', + error: 'Just a string error', // Invalid, should be object with code/message + }, + status: 200, + }; + + mockSession.post.mockResolvedValueOnce(malformedErrorResponse); + + await expect(transport.toolInvoke('testTool', {}, {})).rejects.toThrow( + 'MCP request failed: "Just a string error"', + ); + errorSpy.mockRestore(); + }); + }); +}); diff --git a/packages/toolbox-core/test/mcp/test.v20250326.ts b/packages/toolbox-core/test/mcp/test.v20250326.ts new file mode 100644 index 00000000..a9842267 --- /dev/null +++ b/packages/toolbox-core/test/mcp/test.v20250326.ts @@ -0,0 +1,717 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import {McpHttpTransportV20250326} from '../../src/toolbox_core/mcp/v20250326/mcp.js'; +import {jest} from '@jest/globals'; +import axios, {AxiosInstance} from 'axios'; + +import {Protocol} from '../../src/toolbox_core/protocol.js'; + +jest.mock('axios', () => { + const actual = jest.requireActual('axios') as { + default: typeof import('axios'); + }; + return { + __esModule: true, + ...actual, + default: { + ...actual.default, + create: jest.fn(), + }, + }; +}); +const mockedAxios = axios as jest.Mocked; + +describe('McpHttpTransportV20250326', () => { + const testBaseUrl = 'http://test.loc'; + let mockSession: jest.Mocked; + let transport: McpHttpTransportV20250326; + + beforeEach(() => { + mockSession = { + get: jest.fn(), + post: jest.fn(), + defaults: {headers: {}}, + interceptors: { + request: {use: jest.fn()}, + response: {use: jest.fn()}, + }, + } as unknown as jest.Mocked; + + mockedAxios.create.mockReturnValue(mockSession); + transport = new McpHttpTransportV20250326( + testBaseUrl, + mockSession, + Protocol.MCP_v20250326, + ); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('initialization', () => { + it('should perform handshake successfully', async () => { + // Mock responses for initialization + // 1. InitializeRequest -> result with tools capability + // 2. InitializedNotification -> (no response needed usually, or empty) + + const initResponse = { + headers: {'mcp-session-id': 'sess-1'}, + data: { + jsonrpc: '2.0', + id: '1', + result: { + protocolVersion: '2025-03-26', + capabilities: { + tools: {}, + }, + serverInfo: { + name: 'test-server', + version: '1.0.0', + }, + }, + }, + status: 200, + }; + + const initializedNotificationResponse = { + data: { + jsonrpc: '2.0', + }, + status: 200, + }; + + mockSession.post + .mockResolvedValueOnce(initResponse) + .mockResolvedValueOnce(initializedNotificationResponse); + + const listResponse = { + data: { + jsonrpc: '2.0', + id: '2', + result: { + tools: [], + }, + }, + status: 200, + }; + + mockSession.post.mockResolvedValueOnce(listResponse); + + await transport.toolsList(); + + expect(mockSession.post).toHaveBeenNthCalledWith( + 1, + `${testBaseUrl}/mcp/`, + expect.objectContaining({ + method: 'initialize', + params: expect.objectContaining({ + protocolVersion: '2025-03-26', + clientInfo: expect.any(Object), + }), + }), + expect.any(Object), + ); + + expect(mockSession.post).toHaveBeenNthCalledWith( + 2, + `${testBaseUrl}/mcp/`, + expect.objectContaining({ + method: 'notifications/initialized', + }), + expect.any(Object), + ); + }); + + it('should throw error on protocol version mismatch', async () => { + const initResponse = { + headers: {'mcp-session-id': 'sess-1'}, + data: { + jsonrpc: '2.0', + id: '1', + result: { + protocolVersion: '2023-01-01', // Mismatch + capabilities: {tools: {}}, + serverInfo: {name: 'old-server', version: '0.1'}, + }, + }, + status: 200, + }; + + mockSession.post.mockResolvedValueOnce(initResponse); + + const errorSpy = jest + .spyOn(console, 'error') + .mockImplementation(() => {}); + await expect(transport.toolsList()).rejects.toThrow( + /MCP version mismatch/, + ); + errorSpy.mockRestore(); + }); + + it('should throw error if tools capability missing', async () => { + const initResponse = { + headers: {'mcp-session-id': 'sess-1'}, + data: { + jsonrpc: '2.0', + id: '1', + result: { + protocolVersion: '2025-03-26', + capabilities: {}, // No tools + serverInfo: {name: 'server', version: '1.0'}, + }, + }, + status: 200, + }; + + mockSession.post.mockResolvedValueOnce(initResponse); + + const errorSpy = jest + .spyOn(console, 'error') + .mockImplementation(() => {}); + await expect(transport.toolsList()).rejects.toThrow( + /Server does not support the 'tools' capability/, + ); + errorSpy.mockRestore(); + }); + + it('should throw error if initialization returns no response (204)', async () => { + mockSession.post.mockResolvedValueOnce({ + status: 204, + data: null, + headers: {}, + }); + + const errorSpy = jest + .spyOn(console, 'error') + .mockImplementation(() => {}); + await expect(transport.toolsList()).rejects.toThrow( + 'Initialization failed: No response', + ); + errorSpy.mockRestore(); + }); + + it('should throw error if Mcp-Session-Id header is missing', async () => { + const initResponse = { + headers: {}, // Missing session ID + data: { + jsonrpc: '2.0', + id: '1', + result: { + protocolVersion: '2025-03-26', + capabilities: {tools: {}}, + serverInfo: {name: 'test-server', version: '1.0.0'}, + }, + }, + status: 200, + }; + + mockSession.post.mockResolvedValueOnce(initResponse); + + const errorSpy = jest + .spyOn(console, 'error') + .mockImplementation(() => {}); + await expect(transport.toolsList()).rejects.toThrow( + 'Server did not return a Mcp-Session-Id during initialization.', + ); + errorSpy.mockRestore(); + }); + + it('should handle initialized notification returning 202 without error', async () => { + const initResponse = { + headers: {'mcp-session-id': 'sess-1'}, + data: { + jsonrpc: '2.0', + id: '1', + result: { + protocolVersion: '2025-03-26', + capabilities: {tools: {}}, + serverInfo: {name: 'test-server', version: '1.0.0'}, + }, + }, + status: 200, + }; + + const initializedNotificationResponse = { + data: '', + status: 202, + statusText: 'Accepted', + }; + + mockSession.post + .mockResolvedValueOnce(initResponse) + .mockResolvedValueOnce(initializedNotificationResponse); + + const listResponse = { + data: { + jsonrpc: '2.0', + id: '2', + result: { + tools: [], + }, + }, + status: 200, + }; + mockSession.post.mockResolvedValueOnce(listResponse); + + await expect(transport.toolsList()).resolves.not.toThrow(); + }); + + it('should propagate headers during initialization', async () => { + const initResponse = { + headers: {'mcp-session-id': 'sess-1'}, + data: { + jsonrpc: '2.0', + id: '1', + result: { + protocolVersion: '2025-03-26', + capabilities: {tools: {}}, + serverInfo: {name: 'test-server', version: '1.0.0'}, + }, + }, + status: 200, + }; + + const initializedNotificationResponse = { + data: {jsonrpc: '2.0'}, + status: 200, + }; + + const listResponse = { + data: { + jsonrpc: '2.0', + id: '2', + result: {tools: []}, + }, + status: 200, + }; + + mockSession.post + .mockResolvedValueOnce(initResponse) + .mockResolvedValueOnce(initializedNotificationResponse) + .mockResolvedValueOnce(listResponse); + + const testHeaders = {'X-Test-Header': 'test-value'}; + // Note: Mcp-Session-Id is NOT sent on the initialize request itself, but subsequent requests. + // However, the initialize request SHOULD have the propagated headers. + + await transport.toolsList(undefined, testHeaders); + + // Verify Initialize request has client headers + expect(mockSession.post).toHaveBeenNthCalledWith( + 1, + expect.anything(), + expect.anything(), + expect.objectContaining({headers: testHeaders}), + ); + + // Verify Initialized notification has client headers + session ID + expect(mockSession.post).toHaveBeenNthCalledWith( + 2, + expect.anything(), + expect.anything(), + expect.objectContaining({ + headers: expect.objectContaining({ + ...testHeaders, + 'Mcp-Session-Id': 'sess-1', + }), + }), + ); + }); + }); + + describe('toolsList', () => { + beforeEach(() => { + // Setup successful init for tool tests + const initResponse = { + headers: {'mcp-session-id': 'sess-1'}, + data: { + jsonrpc: '2.0', + id: '1', + result: { + protocolVersion: '2025-03-26', + capabilities: {tools: {}}, + serverInfo: {name: 'test-server', version: '1.0.0'}, + }, + }, + status: 200, + }; + const notifResponse = {data: {}, status: 200}; + + mockSession.post + .mockResolvedValueOnce(initResponse) + .mockResolvedValueOnce(notifResponse); + }); + + it('should return converted tools', async () => { + const listResponse = { + data: { + jsonrpc: '2.0', + id: '2', + result: { + tools: [ + { + name: 'testTool', + description: 'A test tool', + inputSchema: { + type: 'object', + properties: { + x: {type: 'string'}, + }, + }, + }, + ], + }, + }, + status: 200, + }; + + mockSession.post.mockResolvedValueOnce(listResponse); + + const manifest = await transport.toolsList(); + + expect(manifest.tools['testTool']).toBeDefined(); + expect(manifest.tools['testTool'].description).toBe('A test tool'); + expect(manifest.tools['testTool'].parameters).toBeDefined(); + }); + + it('should correctly map auth fields', async () => { + const listResponse = { + data: { + jsonrpc: '2.0', + id: '2', + result: { + tools: [ + { + name: 'authTool', + description: 'Tool with auth', + inputSchema: { + type: 'object', + properties: { + x: { + type: 'string', + }, + }, + }, + _meta: { + 'toolbox/authInvoke': ['service-auth'], + 'toolbox/authParam': { + x: ['param-auth'], + }, + }, + }, + ], + }, + }, + status: 200, + }; + + mockSession.post.mockResolvedValueOnce(listResponse); + + const manifest = await transport.toolsList(); + const tool = manifest.tools['authTool']; + + expect(tool).toBeDefined(); + expect(tool.authRequired).toEqual(['service-auth']); + expect(tool.parameters[0].authSources).toEqual(['param-auth']); + }); + + it('should throw if toolsList returns no response (204)', async () => { + mockSession.post.mockResolvedValueOnce({ + status: 204, + data: null, + }); + + const errorSpy = jest + .spyOn(console, 'error') + .mockImplementation(() => {}); + await expect(transport.toolsList()).rejects.toThrow( + 'Failed to list tools: No response from server.', + ); + errorSpy.mockRestore(); + }); + + it('should throw if server version is not available after init', async () => { + mockSession.post.mockReset(); + mockSession.post.mockResolvedValueOnce({ + data: { + jsonrpc: '2.0', + id: '2', + result: { + tools: [], + }, + }, + status: 200, + }); + jest + .spyOn( + transport as unknown as {ensureInitialized: () => Promise}, + 'ensureInitialized', + ) + .mockResolvedValue(undefined); + const errorSpy = jest + .spyOn(console, 'error') + .mockImplementation(() => {}); + await expect(transport.toolsList()).rejects.toThrow( + 'Server version not available.', + ); + errorSpy.mockRestore(); + }); + }); + + describe('toolGet', () => { + beforeEach(() => { + const initResponse = { + headers: {'mcp-session-id': 'sess-1'}, + data: { + jsonrpc: '2.0', + id: '1', + result: { + protocolVersion: '2025-03-26', + capabilities: {tools: {}}, + serverInfo: {name: 'test-server', version: '1.0.0'}, + }, + }, + status: 200, + }; + mockSession.post + .mockResolvedValueOnce(initResponse) + .mockResolvedValueOnce({data: {}, status: 200}); + }); + + it('should return specific tool manifest', async () => { + const listResponse = { + data: { + jsonrpc: '2.0', + id: '2', + result: { + tools: [ + { + name: 'targetTool', + description: 'desc', + inputSchema: {type: 'object'}, + }, + { + name: 'otherTool', + description: 'desc2', + inputSchema: {type: 'object'}, + }, + ], + }, + }, + status: 200, + }; + + mockSession.post.mockResolvedValueOnce(listResponse); + + const manifest = await transport.toolGet('targetTool'); + + expect(manifest.tools).toHaveProperty('targetTool'); + expect(Object.keys(manifest.tools).length).toBe(1); + }); + + it('should throw if tool not found', async () => { + const listResponse = { + data: { + jsonrpc: '2.0', + id: '2', + result: {tools: []}, + }, + status: 200, + }; + + mockSession.post.mockResolvedValueOnce(listResponse); + + const errorSpy = jest + .spyOn(console, 'error') + .mockImplementation(() => {}); + await expect(transport.toolGet('missing')).rejects.toThrow( + /Tool 'missing' not found/, + ); + errorSpy.mockRestore(); + }); + }); + + describe('toolInvoke', () => { + beforeEach(() => { + // Init sequence + const initResponse = { + headers: {'mcp-session-id': 'sess-1'}, + data: { + jsonrpc: '2.0', + id: '1', + result: { + protocolVersion: '2025-03-26', + capabilities: {tools: {}}, + serverInfo: {name: 'test-server', version: '1.0.0'}, + }, + }, + status: 200, + }; + mockSession.post + .mockResolvedValueOnce(initResponse) + .mockResolvedValueOnce({data: {}, status: 200}); + }); + + it('should invoke tool and return text content', async () => { + const invokeResponse = { + data: { + jsonrpc: '2.0', + id: '3', + result: { + content: [{type: 'text', text: 'Result output'}], + }, + }, + status: 200, + }; + + mockSession.post.mockResolvedValueOnce(invokeResponse); + + const result = await transport.toolInvoke('testTool', {arg: 'val'}, {}); + + expect(mockSession.post).toHaveBeenCalledWith( + `${testBaseUrl}/mcp/`, + expect.objectContaining({ + method: 'tools/call', + params: { + name: 'testTool', + arguments: {arg: 'val'}, + }, + }), + expect.objectContaining({headers: {'Mcp-Session-Id': 'sess-1'}}), + ); + expect(result).toBe('Result output'); + }); + + it('should handle JSON-RPC errors', async () => { + const errorSpy = jest + .spyOn(console, 'error') + .mockImplementation(() => {}); + const errorResponse = { + data: { + jsonrpc: '2.0', + id: '3', + error: { + code: -32601, + message: 'Method not found', + }, + }, + status: 200, + }; + + mockSession.post.mockResolvedValueOnce(errorResponse); + + await expect(transport.toolInvoke('badTool', {}, {})).rejects.toThrow( + /MCP request failed with code -32601: Method not found/, + ); + errorSpy.mockRestore(); + }); + + it('should handle HTTP errors', async () => { + const errorSpy = jest + .spyOn(console, 'error') + .mockImplementation(() => {}); + const httpErrorResponse = { + data: 'Server Error', + status: 500, + statusText: 'Internal Server Error', + }; + + mockSession.post.mockResolvedValueOnce(httpErrorResponse); + + await expect(transport.toolInvoke('testTool', {}, {})).rejects.toThrow( + /API request failed with status 500/, + ); + errorSpy.mockRestore(); + }); + + it('should return "null" if content is empty', async () => { + const invokeResponse = { + data: { + jsonrpc: '2.0', + id: '3', + result: { + content: [], + }, + }, + status: 200, + }; + + mockSession.post.mockResolvedValueOnce(invokeResponse); + + const result = await transport.toolInvoke('testTool', {}, {}); + expect(result).toBe('null'); + expect(result).toBe('null'); + }); + + it('should throw if toolInvoke returns no response (204)', async () => { + mockSession.post.mockResolvedValueOnce({ + status: 204, + data: null, + }); + + const errorSpy = jest + .spyOn(console, 'error') + .mockImplementation(() => {}); + await expect(transport.toolInvoke('testTool', {}, {})).rejects.toThrow( + "Failed to invoke tool 'testTool': No response from server.", + ); + errorSpy.mockRestore(); + }); + + it('should throw if JSON-RPC response structure is invalid', async () => { + const errorSpy = jest + .spyOn(console, 'error') + .mockImplementation(() => {}); + const invalidResponse = { + data: { + jsonrpc: '2.0', + id: '3', + // Missing 'result' and 'error' + somethingElse: true, + }, + status: 200, + }; + + mockSession.post.mockResolvedValueOnce(invalidResponse); + + await expect(transport.toolInvoke('testTool', {}, {})).rejects.toThrow( + 'Failed to parse JSON-RPC response structure', + ); + errorSpy.mockRestore(); + }); + + it('should throw explicit error for malformed error object', async () => { + const errorSpy = jest + .spyOn(console, 'error') + .mockImplementation(() => {}); + const malformedErrorResponse = { + data: { + jsonrpc: '2.0', + id: '3', + error: 'Just a string error', // Invalid, should be object with code/message + }, + status: 200, + }; + + mockSession.post.mockResolvedValueOnce(malformedErrorResponse); + + await expect(transport.toolInvoke('testTool', {}, {})).rejects.toThrow( + 'MCP request failed: "Just a string error"', + ); + errorSpy.mockRestore(); + }); + }); +}); diff --git a/packages/toolbox-core/test/mcp/test.v20250618.ts b/packages/toolbox-core/test/mcp/test.v20250618.ts new file mode 100644 index 00000000..612abdda --- /dev/null +++ b/packages/toolbox-core/test/mcp/test.v20250618.ts @@ -0,0 +1,689 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import {McpHttpTransportV20250618} from '../../src/toolbox_core/mcp/v20250618/mcp.js'; +import {jest} from '@jest/globals'; +import axios, {AxiosInstance} from 'axios'; + +import {Protocol} from '../../src/toolbox_core/protocol.js'; + +jest.mock('axios', () => { + const actual = jest.requireActual('axios') as { + default: typeof import('axios'); + }; + return { + __esModule: true, + ...actual, + default: { + ...actual.default, + create: jest.fn(), + }, + }; +}); +const mockedAxios = axios as jest.Mocked; + +describe('McpHttpTransportV20250618', () => { + const testBaseUrl = 'http://test.loc'; + let mockSession: jest.Mocked; + let transport: McpHttpTransportV20250618; + + beforeEach(() => { + mockSession = { + get: jest.fn(), + post: jest.fn(), + defaults: {headers: {}}, + interceptors: { + request: {use: jest.fn()}, + response: {use: jest.fn()}, + }, + } as unknown as jest.Mocked; + + mockedAxios.create.mockReturnValue(mockSession); + transport = new McpHttpTransportV20250618( + testBaseUrl, + mockSession, + Protocol.MCP_v20250618, + ); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('initialization', () => { + it('should perform handshake successfully', async () => { + // Mock responses for initialization + // 1. InitializeRequest -> result with tools capability + // 2. InitializedNotification -> (no response needed usually, or empty) + + const initResponse = { + data: { + jsonrpc: '2.0', + id: '1', + result: { + protocolVersion: '2025-06-18', + capabilities: { + tools: {}, + }, + serverInfo: { + name: 'test-server', + version: '1.0.0', + }, + }, + }, + status: 200, + }; + + const initializedNotificationResponse = { + data: { + jsonrpc: '2.0', + }, + status: 200, + }; + + mockSession.post + .mockResolvedValueOnce(initResponse) + .mockResolvedValueOnce(initializedNotificationResponse); + + const listResponse = { + data: { + jsonrpc: '2.0', + id: '2', + result: { + tools: [], + }, + }, + status: 200, + }; + + mockSession.post.mockResolvedValueOnce(listResponse); + + await transport.toolsList(); + + expect(mockSession.post).toHaveBeenNthCalledWith( + 1, + `${testBaseUrl}/mcp/`, + expect.objectContaining({ + method: 'initialize', + params: expect.objectContaining({ + protocolVersion: '2025-06-18', + clientInfo: expect.any(Object), + }), + }), + expect.objectContaining({ + headers: expect.objectContaining({ + 'MCP-Protocol-Version': '2025-06-18', + }), + }), + ); + + expect(mockSession.post).toHaveBeenNthCalledWith( + 2, + `${testBaseUrl}/mcp/`, + expect.objectContaining({ + method: 'notifications/initialized', + }), + expect.any(Object), + ); + }); + + it('should throw error on protocol version mismatch', async () => { + const initResponse = { + data: { + jsonrpc: '2.0', + id: '1', + result: { + protocolVersion: '2023-01-01', // Mismatch + capabilities: {tools: {}}, + serverInfo: {name: 'old-server', version: '0.1'}, + }, + }, + status: 200, + }; + + mockSession.post.mockResolvedValueOnce(initResponse); + + const errorSpy = jest + .spyOn(console, 'error') + .mockImplementation(() => {}); + await expect(transport.toolsList()).rejects.toThrow( + /MCP version mismatch/, + ); + errorSpy.mockRestore(); + }); + + it('should throw error if tools capability missing', async () => { + const initResponse = { + data: { + jsonrpc: '2.0', + id: '1', + result: { + protocolVersion: '2025-06-18', + capabilities: {}, // No tools + serverInfo: {name: 'server', version: '1.0'}, + }, + }, + status: 200, + }; + + mockSession.post.mockResolvedValueOnce(initResponse); + + const errorSpy = jest + .spyOn(console, 'error') + .mockImplementation(() => {}); + await expect(transport.toolsList()).rejects.toThrow( + /Server does not support the 'tools' capability/, + ); + errorSpy.mockRestore(); + }); + + it('should throw error if initialization returns no response (204)', async () => { + mockSession.post.mockResolvedValueOnce({ + status: 204, + data: null, + headers: {}, + }); + + const errorSpy = jest + .spyOn(console, 'error') + .mockImplementation(() => {}); + await expect(transport.toolsList()).rejects.toThrow( + 'Initialization failed: No response', + ); + errorSpy.mockRestore(); + }); + + it('should handle initialized notification returning 202 without error', async () => { + const initResponse = { + data: { + jsonrpc: '2.0', + id: '1', + result: { + protocolVersion: '2025-06-18', + capabilities: {tools: {}}, + serverInfo: {name: 'test-server', version: '1.0.0'}, + }, + }, + status: 200, + }; + + const initializedNotificationResponse = { + data: '', + status: 202, + statusText: 'Accepted', + }; + + mockSession.post + .mockResolvedValueOnce(initResponse) + .mockResolvedValueOnce(initializedNotificationResponse); + + const listResponse = { + data: { + jsonrpc: '2.0', + id: '2', + result: { + tools: [], + }, + }, + status: 200, + }; + mockSession.post.mockResolvedValueOnce(listResponse); + + await expect(transport.toolsList()).resolves.not.toThrow(); + }); + + it('should propagate headers during initialization', async () => { + const initResponse = { + data: { + jsonrpc: '2.0', + id: '1', + result: { + protocolVersion: '2025-06-18', + capabilities: {tools: {}}, + serverInfo: {name: 'test-server', version: '1.0.0'}, + }, + }, + status: 200, + }; + + const initializedNotificationResponse = { + data: {jsonrpc: '2.0'}, + status: 200, + }; + + const listResponse = { + data: { + jsonrpc: '2.0', + id: '2', + result: {tools: []}, + }, + status: 200, + }; + + mockSession.post + .mockResolvedValueOnce(initResponse) + .mockResolvedValueOnce(initializedNotificationResponse) + .mockResolvedValueOnce(listResponse); + + const testHeaders = {'X-Test-Header': 'test-value'}; + // v20250618 adds 'MCP-Protocol-Version' header automatically + const expectedHeaders = { + ...testHeaders, + 'MCP-Protocol-Version': '2025-06-18', + }; + + await transport.toolsList(undefined, testHeaders); + + // Verify Initialize request has headers + expect(mockSession.post).toHaveBeenNthCalledWith( + 1, + expect.anything(), + expect.anything(), + expect.objectContaining({headers: expectedHeaders}), + ); + + // Verify Initialized notification has headers + expect(mockSession.post).toHaveBeenNthCalledWith( + 2, + expect.anything(), + expect.anything(), + expect.objectContaining({headers: expectedHeaders}), + ); + }); + }); + + describe('toolsList', () => { + beforeEach(() => { + // Setup successful init for tool tests + const initResponse = { + data: { + jsonrpc: '2.0', + id: '1', + result: { + protocolVersion: '2025-06-18', + capabilities: {tools: {}}, + serverInfo: {name: 'test-server', version: '1.0.0'}, + }, + }, + status: 200, + }; + const notifResponse = {data: {}, status: 200}; + + mockSession.post + .mockResolvedValueOnce(initResponse) + .mockResolvedValueOnce(notifResponse); + }); + + it('should return converted tools', async () => { + const listResponse = { + data: { + jsonrpc: '2.0', + id: '2', + result: { + tools: [ + { + name: 'testTool', + description: 'A test tool', + inputSchema: { + type: 'object', + properties: { + x: {type: 'string'}, + }, + }, + }, + ], + }, + }, + status: 200, + }; + + mockSession.post.mockResolvedValueOnce(listResponse); + + const manifest = await transport.toolsList(); + + expect(manifest.tools['testTool']).toBeDefined(); + expect(manifest.tools['testTool'].description).toBe('A test tool'); + expect(manifest.tools['testTool'].parameters).toBeDefined(); + }); + + it('should correctly map auth fields', async () => { + const listResponse = { + data: { + jsonrpc: '2.0', + id: '2', + result: { + tools: [ + { + name: 'authTool', + description: 'Tool with auth', + inputSchema: { + type: 'object', + properties: { + x: { + type: 'string', + }, + }, + }, + _meta: { + 'toolbox/authInvoke': ['service-auth'], + 'toolbox/authParam': { + x: ['param-auth'], + }, + }, + }, + ], + }, + }, + status: 200, + }; + + mockSession.post.mockResolvedValueOnce(listResponse); + + const manifest = await transport.toolsList(); + const tool = manifest.tools['authTool']; + + expect(tool).toBeDefined(); + expect(tool.authRequired).toEqual(['service-auth']); + expect(tool.parameters[0].authSources).toEqual(['param-auth']); + }); + + it('should throw if toolsList returns no response (204)', async () => { + mockSession.post.mockResolvedValueOnce({ + status: 204, + data: null, + }); + + const errorSpy = jest + .spyOn(console, 'error') + .mockImplementation(() => {}); + await expect(transport.toolsList()).rejects.toThrow( + 'Failed to list tools: No response from server.', + ); + errorSpy.mockRestore(); + }); + + it('should throw if server version is not available after init', async () => { + mockSession.post.mockReset(); + mockSession.post.mockResolvedValueOnce({ + data: { + jsonrpc: '2.0', + id: '2', + result: { + tools: [], + }, + }, + status: 200, + }); + jest + .spyOn( + transport as unknown as {ensureInitialized: () => Promise}, + 'ensureInitialized', + ) + .mockResolvedValue(undefined); + const errorSpy = jest + .spyOn(console, 'error') + .mockImplementation(() => {}); + await expect(transport.toolsList()).rejects.toThrow( + 'Server version not available.', + ); + errorSpy.mockRestore(); + }); + }); + + describe('toolGet', () => { + beforeEach(() => { + const initResponse = { + data: { + jsonrpc: '2.0', + id: '1', + result: { + protocolVersion: '2025-06-18', + capabilities: {tools: {}}, + serverInfo: {name: 'test-server', version: '1.0.0'}, + }, + }, + status: 200, + }; + mockSession.post + .mockResolvedValueOnce(initResponse) + .mockResolvedValueOnce({data: {}, status: 200}); + }); + + it('should return specific tool manifest', async () => { + const listResponse = { + data: { + jsonrpc: '2.0', + id: '2', + result: { + tools: [ + { + name: 'targetTool', + description: 'desc', + inputSchema: {type: 'object'}, + }, + { + name: 'otherTool', + description: 'desc2', + inputSchema: {type: 'object'}, + }, + ], + }, + }, + status: 200, + }; + + mockSession.post.mockResolvedValueOnce(listResponse); + + const manifest = await transport.toolGet('targetTool'); + + expect(manifest.tools).toHaveProperty('targetTool'); + expect(Object.keys(manifest.tools).length).toBe(1); + }); + + it('should throw if tool not found', async () => { + const listResponse = { + data: { + jsonrpc: '2.0', + id: '2', + result: {tools: []}, + }, + status: 200, + }; + + mockSession.post.mockResolvedValueOnce(listResponse); + + const errorSpy = jest + .spyOn(console, 'error') + .mockImplementation(() => {}); + await expect(transport.toolGet('missing')).rejects.toThrow( + /Tool 'missing' not found/, + ); + errorSpy.mockRestore(); + }); + }); + + describe('toolInvoke', () => { + beforeEach(() => { + // Init sequence + const initResponse = { + data: { + jsonrpc: '2.0', + id: '1', + result: { + protocolVersion: '2025-06-18', + capabilities: {tools: {}}, + serverInfo: {name: 'test-server', version: '1.0.0'}, + }, + }, + status: 200, + }; + mockSession.post + .mockResolvedValueOnce(initResponse) + .mockResolvedValueOnce({data: {}, status: 200}); + }); + + it('should invoke tool and return text content', async () => { + const invokeResponse = { + data: { + jsonrpc: '2.0', + id: '3', + result: { + content: [{type: 'text', text: 'Result output'}], + }, + }, + status: 200, + }; + + mockSession.post.mockResolvedValueOnce(invokeResponse); + + const result = await transport.toolInvoke('testTool', {arg: 'val'}, {}); + + expect(mockSession.post).toHaveBeenLastCalledWith( + `${testBaseUrl}/mcp/`, + expect.objectContaining({ + method: 'tools/call', + params: { + name: 'testTool', + arguments: {arg: 'val'}, + }, + }), + expect.objectContaining({ + headers: expect.objectContaining({ + 'MCP-Protocol-Version': '2025-06-18', + }), + }), + ); + expect(result).toBe('Result output'); + }); + + it('should handle JSON-RPC errors', async () => { + const errorSpy = jest + .spyOn(console, 'error') + .mockImplementation(() => {}); + const errorResponse = { + data: { + jsonrpc: '2.0', + id: '3', + error: { + code: -32601, + message: 'Method not found', + }, + }, + status: 200, + }; + + mockSession.post.mockResolvedValueOnce(errorResponse); + + await expect(transport.toolInvoke('badTool', {}, {})).rejects.toThrow( + /MCP request failed with code -32601: Method not found/, + ); + errorSpy.mockRestore(); + }); + + it('should handle HTTP errors', async () => { + const errorSpy = jest + .spyOn(console, 'error') + .mockImplementation(() => {}); + const httpErrorResponse = { + data: 'Server Error', + status: 500, + statusText: 'Internal Server Error', + }; + + mockSession.post.mockResolvedValueOnce(httpErrorResponse); + + await expect(transport.toolInvoke('testTool', {}, {})).rejects.toThrow( + /API request failed with status 500/, + ); + errorSpy.mockRestore(); + }); + + it('should return "null" if content is empty', async () => { + const invokeResponse = { + data: { + jsonrpc: '2.0', + id: '3', + result: { + content: [], + }, + }, + status: 200, + }; + + mockSession.post.mockResolvedValueOnce(invokeResponse); + + const result = await transport.toolInvoke('testTool', {}, {}); + expect(result).toBe('null'); + expect(result).toBe('null'); + }); + + it('should throw if toolInvoke returns no response (204)', async () => { + mockSession.post.mockResolvedValueOnce({ + status: 204, + data: null, + }); + + const errorSpy = jest + .spyOn(console, 'error') + .mockImplementation(() => {}); + await expect(transport.toolInvoke('testTool', {}, {})).rejects.toThrow( + "Failed to invoke tool 'testTool': No response from server.", + ); + errorSpy.mockRestore(); + }); + + it('should throw if JSON-RPC response structure is invalid', async () => { + const errorSpy = jest + .spyOn(console, 'error') + .mockImplementation(() => {}); + const invalidResponse = { + data: { + jsonrpc: '2.0', + id: '3', + // Missing 'result' and 'error' + somethingElse: true, + }, + status: 200, + }; + + mockSession.post.mockResolvedValueOnce(invalidResponse); + + await expect(transport.toolInvoke('testTool', {}, {})).rejects.toThrow( + 'Failed to parse JSON-RPC response structure', + ); + errorSpy.mockRestore(); + }); + + it('should throw explicit error for malformed error object', async () => { + const errorSpy = jest + .spyOn(console, 'error') + .mockImplementation(() => {}); + const malformedErrorResponse = { + data: { + jsonrpc: '2.0', + id: '3', + error: 'Just a string error', // Invalid, should be object with code/message + }, + status: 200, + }; + + mockSession.post.mockResolvedValueOnce(malformedErrorResponse); + + await expect(transport.toolInvoke('testTool', {}, {})).rejects.toThrow( + 'MCP request failed: "Just a string error"', + ); + errorSpy.mockRestore(); + }); + }); +}); diff --git a/packages/toolbox-core/test/test.client.ts b/packages/toolbox-core/test/test.client.ts index 4827feb3..b7086fbb 100644 --- a/packages/toolbox-core/test/test.client.ts +++ b/packages/toolbox-core/test/test.client.ts @@ -13,10 +13,14 @@ // limitations under the License. import {jest} from '@jest/globals'; -import {ToolboxClient} from '../src/toolbox_core/client.js'; import {ITransport} from '../src/toolbox_core/transport.types.js'; -import {ZodManifest} 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 {ToolboxTransport} from '../src/toolbox_core/toolboxTransport.js'; +import {McpHttpTransportV20241105} from '../src/toolbox_core/mcp/v20241105/mcp.js'; +import {McpHttpTransportV20250326} from '../src/toolbox_core/mcp/v20250326/mcp.js'; +import {McpHttpTransportV20250618} from '../src/toolbox_core/mcp/v20250618/mcp.js'; // --- Mock Transport Implementation --- class MockTransport implements ITransport { @@ -33,17 +37,41 @@ class MockTransport implements ITransport { } } -// Mock the ToolboxTransport module to return our MockTransport +// Mock the ToolboxTransport module jest.mock('../src/toolbox_core/toolboxTransport.js', () => { return { ToolboxTransport: jest.fn(), }; }); +// Mock the McpHttpTransportV20241105 module +jest.mock('../src/toolbox_core/mcp/v20241105/mcp', () => { + return { + __esModule: true, + McpHttpTransportV20241105: jest.fn(), + }; +}); + +// Mock the McpHttpTransportV20250326 module +jest.mock('../src/toolbox_core/mcp/v20250326/mcp', () => { + return { + __esModule: true, + McpHttpTransportV20250326: jest.fn(), + }; +}); + +// Mock the McpHttpTransportV20250618 module +jest.mock('../src/toolbox_core/mcp/v20250618/mcp', () => { + return { + __esModule: true, + McpHttpTransportV20250618: jest.fn(), + }; +}); + describe('ToolboxClient', () => { const testBaseUrl = 'https://api.example.com'; let mockTransport: MockTransport; - let client: ToolboxClient; + let client: ToolboxClientType; beforeEach(() => { jest.clearAllMocks(); @@ -51,6 +79,16 @@ describe('ToolboxClient', () => { (ToolboxTransport as unknown as jest.Mock).mockImplementation( () => mockTransport, ); + // Explicitly reference the imported symbol which should be the mock + (McpHttpTransportV20241105 as unknown as jest.Mock).mockImplementation( + () => mockTransport, + ); + (McpHttpTransportV20250326 as unknown as jest.Mock).mockImplementation( + () => mockTransport, + ); + (McpHttpTransportV20250618 as unknown as jest.Mock).mockImplementation( + () => mockTransport, + ); }); afterEach(async () => { @@ -58,17 +96,102 @@ describe('ToolboxClient', () => { }); describe('Initialization', () => { - it('should initialize with the correct base URL', () => { + it('should initialize with the correct base URL (default MCP)', () => { client = new ToolboxClient(testBaseUrl); - expect(ToolboxTransport).toHaveBeenCalledWith(testBaseUrl, undefined); + expect(McpHttpTransportV20250618).toHaveBeenCalledWith( + testBaseUrl, + undefined, + Protocol.MCP_v20250618, + ); }); - it('should pass provided axios session to transport', () => { + it('should pass provided axios session to transport (default MCP)', () => { const mockSession = { get: jest.fn(), } as unknown as import('axios').AxiosInstance; client = new ToolboxClient(testBaseUrl, mockSession); - expect(ToolboxTransport).toHaveBeenCalledWith(testBaseUrl, mockSession); + expect(McpHttpTransportV20250618).toHaveBeenCalledWith( + testBaseUrl, + mockSession, + Protocol.MCP_v20250618, + ); + }); + + it('should initialize with MCP transport (explicit) when specified', () => { + client = new ToolboxClient( + testBaseUrl, + undefined, + undefined, + Protocol.MCP, + ); + expect(McpHttpTransportV20250618).toHaveBeenCalledWith( + testBaseUrl, + undefined, + Protocol.MCP_v20250618, + ); + }); + + it('should initialize with MCP v20241105 transport when specified', () => { + client = new ToolboxClient( + testBaseUrl, + undefined, + undefined, + Protocol.MCP_v20241105, + ); + expect(McpHttpTransportV20241105).toHaveBeenCalledWith( + testBaseUrl, + undefined, + Protocol.MCP_v20241105, + ); + }); + + it('should initialize with MCP v20250326 transport when specified', () => { + client = new ToolboxClient( + testBaseUrl, + undefined, + undefined, + Protocol.MCP_v20250326, + ); + expect(McpHttpTransportV20250326).toHaveBeenCalledWith( + testBaseUrl, + undefined, + Protocol.MCP_v20250326, + ); + }); + + it('should initialize with MCP v20250618 transport when specified', () => { + client = new ToolboxClient( + testBaseUrl, + undefined, + undefined, + Protocol.MCP_v20250618, + ); + expect(McpHttpTransportV20250618).toHaveBeenCalledWith( + testBaseUrl, + undefined, + Protocol.MCP_v20250618, + ); + }); + + it('should initialize with ToolboxTransport when specified', () => { + client = new ToolboxClient( + testBaseUrl, + undefined, + undefined, + Protocol.TOOLBOX, + ); + expect(ToolboxTransport).toHaveBeenCalledWith(testBaseUrl, undefined); + }); + + it('should throw error for unsupported protocol', () => { + expect(() => { + new ToolboxClient( + testBaseUrl, + undefined, + undefined, + 'unknown-protocol' as Protocol, + ); + }).toThrow('Unsupported protocol version: unknown-protocol'); }); }); diff --git a/packages/toolbox-core/tsconfig.esm.json b/packages/toolbox-core/tsconfig.esm.json index e9d37d20..3bac7dc7 100644 --- a/packages/toolbox-core/tsconfig.esm.json +++ b/packages/toolbox-core/tsconfig.esm.json @@ -2,7 +2,7 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "rootDir": "src/toolbox_core", - "outDir": "build" + "outDir": "build/esm" }, "include": [ "src/**/*.ts" diff --git a/release-please-config.json b/release-please-config.json index 361ab7b6..1220f993 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -12,7 +12,11 @@ ], "packages": { "packages/toolbox-core": { - "component": "core" + "component": "core", + "extra-files": [ + "src/toolbox_core/version.ts" + ] + }, "packages/toolbox-adk": { "component": "adk"