From 7128a4f1b99bd912c46be534bccb69ae5cabbf09 Mon Sep 17 00:00:00 2001 From: Anubhav Dhawan Date: Sat, 4 Jul 2026 04:09:33 +0530 Subject: [PATCH] feat(core): implement reference support for Stateless MCP (SEP-2575) --- .../toolbox-core/src/toolbox_core/client.ts | 82 ++- .../src/toolbox_core/errorUtils.ts | 11 + .../src/toolbox_core/mcp/v20250618/mcp.ts | 2 +- .../src/toolbox_core/mcp/v20251125/mcp.ts | 2 +- .../src/toolbox_core/mcp/v20260618/mcp.ts | 301 ++++++++++ .../src/toolbox_core/mcp/v20260618/types.ts | 117 ++++ .../toolbox-core/src/toolbox_core/protocol.ts | 10 +- packages/toolbox-core/test/e2e/test.e2e.ts | 20 +- .../toolbox-core/test/mcp/test.v20260618.ts | 549 ++++++++++++++++++ packages/toolbox-core/test/test.client.ts | 102 ++++ 10 files changed, 1175 insertions(+), 21 deletions(-) create mode 100644 packages/toolbox-core/src/toolbox_core/mcp/v20260618/mcp.ts create mode 100644 packages/toolbox-core/src/toolbox_core/mcp/v20260618/types.ts create mode 100644 packages/toolbox-core/test/mcp/test.v20260618.ts diff --git a/packages/toolbox-core/src/toolbox_core/client.ts b/packages/toolbox-core/src/toolbox_core/client.ts index 9895d8e5..294cfc36 100644 --- a/packages/toolbox-core/src/toolbox_core/client.ts +++ b/packages/toolbox-core/src/toolbox_core/client.ts @@ -27,6 +27,8 @@ import {McpHttpTransportV20241105} from './mcp/v20241105/mcp.js'; import {McpHttpTransportV20250618} from './mcp/v20250618/mcp.js'; import {McpHttpTransportV20250326} from './mcp/v20250326/mcp.js'; import {McpHttpTransportV20251125} from './mcp/v20251125/mcp.js'; +import {McpHttpTransportV20260618} from './mcp/v20260618/mcp.js'; +import {ProtocolNegotiationError} from './errorUtils.js'; import { BoundParams, identifyAuthRequirements, @@ -49,6 +51,8 @@ export type ClientHeadersConfig = Record; class ToolboxClient { #transport: ITransport; #clientHeaders: ClientHeadersConfig; + #session: AxiosInstance | undefined; + #baseUrl: string; /** * Initializes the ToolboxClient. @@ -68,7 +72,9 @@ class ToolboxClient { clientName?: string, clientVersion?: string, ) { + this.#baseUrl = url; this.#clientHeaders = clientHeaders || {}; + this.#session = session || undefined; warnIfHttpAndHeaders(url, this.#clientHeaders); if (!getSupportedMcpVersions().includes(protocol)) { throw new Error(`Unsupported protocol version: ${protocol}`); @@ -80,43 +86,63 @@ class ToolboxClient { ); } + this.#transport = this.#createTransport( + url, + session || undefined, + protocol, + clientName, + clientVersion, + ); + } + + #createTransport( + url: string, + session: AxiosInstance | undefined, + protocol: Protocol, + clientName?: string, + clientVersion?: string, + ): ITransport { switch (protocol) { case Protocol.MCP_v20241105: - this.#transport = new McpHttpTransportV20241105( + return new McpHttpTransportV20241105( url, - session || undefined, + session, protocol, clientName, clientVersion, ); - break; case Protocol.MCP_v20250326: - this.#transport = new McpHttpTransportV20250326( + return new McpHttpTransportV20250326( url, - session || undefined, + session, protocol, clientName, clientVersion, ); - break; case Protocol.MCP_v20250618: - this.#transport = new McpHttpTransportV20250618( + return new McpHttpTransportV20250618( url, - session || undefined, + session, protocol, clientName, clientVersion, ); - break; case Protocol.MCP_v20251125: - this.#transport = new McpHttpTransportV20251125( + return new McpHttpTransportV20251125( url, - session || undefined, + session, + protocol, + clientName, + clientVersion, + ); + case Protocol.MCP_DRAFT_2026_v1: + return new McpHttpTransportV20260618( + url, + session, protocol, clientName, clientVersion, ); - break; default: throw new Error(`Unsupported MCP protocol version: ${protocol}`); } @@ -214,7 +240,21 @@ class ToolboxClient { ): Promise { warnIfHttpAndHeaders(this.#transport.baseUrl, authTokenGetters); const headers = await this.#resolveClientHeaders(); - const manifest = await this.#transport.toolGet(name, headers); + let manifest; + try { + manifest = await this.#transport.toolGet(name, headers); + } catch (e: unknown) { + if (e instanceof ProtocolNegotiationError) { + this.#transport = this.#createTransport( + this.#baseUrl, + this.#session, + e.fallbackVersion, + ); + manifest = await this.#transport.toolGet(name, headers); + } else { + throw e; + } + } if ( manifest.tools && @@ -285,7 +325,21 @@ class ToolboxClient { const toolsetName = name || ''; const headers = await this.#resolveClientHeaders(); - const manifest = await this.#transport.toolsList(toolsetName, headers); + let manifest; + try { + manifest = await this.#transport.toolsList(toolsetName, headers); + } catch (e: unknown) { + if (e instanceof ProtocolNegotiationError) { + this.#transport = this.#createTransport( + this.#baseUrl, + this.#session, + e.fallbackVersion, + ); + manifest = await this.#transport.toolsList(toolsetName, headers); + } else { + throw e; + } + } const tools: ToolboxTool[] = []; const overallUsedAuthKeys: Set = new Set(); diff --git a/packages/toolbox-core/src/toolbox_core/errorUtils.ts b/packages/toolbox-core/src/toolbox_core/errorUtils.ts index e27f90fe..ede1a9c4 100644 --- a/packages/toolbox-core/src/toolbox_core/errorUtils.ts +++ b/packages/toolbox-core/src/toolbox_core/errorUtils.ts @@ -13,6 +13,7 @@ // limitations under the License. import {isAxiosError} from 'axios'; +import {Protocol} from './protocol.js'; /** * Logs a standardized error message to the console, differentiating between @@ -40,3 +41,13 @@ export function logApiError(baseMessage: string, error: unknown): void { } console.error(baseMessage, loggableDetails); } + +export class ProtocolNegotiationError extends Error { + fallbackVersion: Protocol; + + constructor(fallbackVersion: Protocol) { + super(`Server requires protocol fallback to ${fallbackVersion}`); + this.name = 'ProtocolNegotiationError'; + this.fallbackVersion = fallbackVersion; + } +} diff --git a/packages/toolbox-core/src/toolbox_core/mcp/v20250618/mcp.ts b/packages/toolbox-core/src/toolbox_core/mcp/v20250618/mcp.ts index 379276ec..cc858ade 100644 --- a/packages/toolbox-core/src/toolbox_core/mcp/v20250618/mcp.ts +++ b/packages/toolbox-core/src/toolbox_core/mcp/v20250618/mcp.ts @@ -51,7 +51,7 @@ export class McpHttpTransportV20250618 extends McpHttpTransportBase { }; } - // Inject Protocol Version into headers (v2025-06-18 specific) + // Inject Protocol Version into headers as required by MCP spec const reqHeaders = {...(headers || {})}; reqHeaders['MCP-Protocol-Version'] = this._protocolVersion; diff --git a/packages/toolbox-core/src/toolbox_core/mcp/v20251125/mcp.ts b/packages/toolbox-core/src/toolbox_core/mcp/v20251125/mcp.ts index 04110287..91ac4edb 100644 --- a/packages/toolbox-core/src/toolbox_core/mcp/v20251125/mcp.ts +++ b/packages/toolbox-core/src/toolbox_core/mcp/v20251125/mcp.ts @@ -51,7 +51,7 @@ export class McpHttpTransportV20251125 extends McpHttpTransportBase { }; } - // Inject Protocol Version into headers (v2025-06-18 specific) + // Inject Protocol Version into headers as required by MCP spec const reqHeaders = {...(headers || {})}; reqHeaders['MCP-Protocol-Version'] = this._protocolVersion; diff --git a/packages/toolbox-core/src/toolbox_core/mcp/v20260618/mcp.ts b/packages/toolbox-core/src/toolbox_core/mcp/v20260618/mcp.ts new file mode 100644 index 00000000..6aa18c43 --- /dev/null +++ b/packages/toolbox-core/src/toolbox_core/mcp/v20260618/mcp.ts @@ -0,0 +1,301 @@ +// 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, + Protocol, + getSupportedMcpVersions, +} from '../../protocol.js'; +import {logApiError} from '../../errorUtils.js'; +import {warnIfHttpAndHeaders} from '../../utils.js'; + +import {v4 as uuidv4} from 'uuid'; +import {VERSION} from '../../version.js'; + +import {ProtocolNegotiationError} from '../../errorUtils.js'; + +export class McpHttpTransportV20260618 extends McpHttpTransportBase { + #getMeta() { + return { + protocolVersion: this._protocolVersion, + clientInfo: { + name: this._clientName || 'toolbox-core-js', + version: this._clientVersion || VERSION, + }, + clientCapabilities: {}, + }; + } + + #checkProtocolNegotiationError(errVal: unknown): void { + if (!errVal) return; + + // Check for unsupported protocol version error code (-32022) + if ( + typeof errVal === 'object' && + errVal !== null && + 'code' in errVal && + (errVal as Record).code === -32022 + ) { + const serverSupported = (( + (errVal as Record).data as Record + )?.supported || []) as string[]; + const clientSupported = getSupportedMcpVersions(); + const mutuallySupported = clientSupported.filter(v => + serverSupported.includes(v), + ); + + if (mutuallySupported.length > 0) { + throw new ProtocolNegotiationError(mutuallySupported[0]); + } else { + throw new Error( + `No mutually supported protocol version. Client supports: ${clientSupported.join( + ', ', + )}, Server supports: ${serverSupported.join(', ')}`, + ); + } + } + + // Check for legacy fallback (string or object message matching) + const isLegacyError = + (typeof errVal === 'string' && + (errVal.toLowerCase().includes('invalid protocol version') || + errVal.toLowerCase().includes('unsupported protocol version'))) || + (typeof errVal === 'object' && + errVal !== null && + 'message' in errVal && + (String((errVal as Record).message) + .toLowerCase() + .includes('invalid protocol version') || + String((errVal as Record).message) + .toLowerCase() + .includes('unsupported protocol version'))); + + if (isLegacyError) { + // Cascading Fallback + const clientSupported = getSupportedMcpVersions(); + const currentIdx = clientSupported.indexOf( + this._protocolVersion as Protocol, + ); + if (currentIdx !== -1 && currentIdx + 1 < clientSupported.length) { + throw new ProtocolNegotiationError(clientSupported[currentIdx + 1]); + } else { + throw new Error( + "Server threw 'invalid protocol version' but no fallback versions remain in the user's supported protocols array.", + ); + } + } + } + + 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 as required by MCP spec + 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 && typeof jsonResp === 'object' && jsonResp.error) { + const errVal = jsonResp.error; + this.#checkProtocolNegotiationError(errVal); + + 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) { + if (error instanceof AxiosError) { + const jsonResp = error.response?.data; + if (jsonResp && typeof jsonResp === 'object' && 'error' in jsonResp) { + const errVal = (jsonResp as Record).error; + this.#checkProtocolNegotiationError(errVal); + } + } + logApiError(`Error posting data to ${url}:`, error); + throw error; + } + } + + protected async initializeSession(): Promise { + // Stateless MCP does not use initialize handshake + this._serverVersion = 'unknown'; + } + + async toolsList( + toolsetName?: string, + headers?: Record, + ): Promise { + await this.ensureInitialized(headers); + const url = `${this._mcpBaseUrl}${toolsetName || ''}`; + + const result = await this.#sendRequest( + url, + types.ListToolsRequest, + {_meta: this.#getMeta()}, + headers, + ); + + if (!result) { + const error = new Error('Failed to list tools: No response from server.'); + logApiError(`Error listing tools from ${url}`, 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 ?? 'unknown', + tools: toolsMap as unknown as ZodManifest['tools'], + }; + } + + 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); + + if (headers && Object.keys(headers).length > 0) { + warnIfHttpAndHeaders(this._mcpBaseUrl, headers); + } + + const params = { + name: toolName, + arguments: arguments_, + _meta: this.#getMeta(), + }; + + 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; + } + + return this.processToolResultContent(result.content); + } +} diff --git a/packages/toolbox-core/src/toolbox_core/mcp/v20260618/types.ts b/packages/toolbox-core/src/toolbox_core/mcp/v20260618/types.ts new file mode 100644 index 00000000..826fdc64 --- /dev/null +++ b/packages/toolbox-core/src/toolbox_core/mcp/v20260618/types.ts @@ -0,0 +1,117 @@ +// 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 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 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 e1f4c4cc..3f3a8013 100644 --- a/packages/toolbox-core/src/toolbox_core/protocol.ts +++ b/packages/toolbox-core/src/toolbox_core/protocol.ts @@ -15,6 +15,7 @@ import {z, ZodRawShape, ZodTypeAny, ZodObject} from 'zod'; export enum Protocol { + MCP_DRAFT_2026_v1 = 'DRAFT-2026-v1', MCP_v20241105 = '2024-11-05', MCP_v20250326 = '2025-03-26', MCP_v20250618 = '2025-06-18', @@ -22,14 +23,15 @@ export enum Protocol { MCP = MCP_v20250618, // Default MCP } -export const MCP_LATEST = Protocol.MCP_v20251125; +export const MCP_LATEST = Protocol.MCP_DRAFT_2026_v1; export function getSupportedMcpVersions(): Protocol[] { return [ - Protocol.MCP_v20241105, - Protocol.MCP_v20250326, - Protocol.MCP_v20250618, + Protocol.MCP_DRAFT_2026_v1, Protocol.MCP_v20251125, + Protocol.MCP_v20250618, + Protocol.MCP_v20250326, + Protocol.MCP_v20241105, ]; } diff --git a/packages/toolbox-core/test/e2e/test.e2e.ts b/packages/toolbox-core/test/e2e/test.e2e.ts index a613bacb..4ea26b8c 100644 --- a/packages/toolbox-core/test/e2e/test.e2e.ts +++ b/packages/toolbox-core/test/e2e/test.e2e.ts @@ -14,7 +14,10 @@ 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 { + Protocol, + getSupportedMcpVersions, +} from '../../src/toolbox_core/protocol.js'; import {AxiosError} from 'axios'; import {CustomGlobal} from './types.js'; @@ -618,3 +621,18 @@ describe.each(getSupportedMcpVersions())( }); }, ); + +describe('ToolboxClient E2E Protocol Negotiation Fallback', () => { + it('should successfully fallback to a supported version when draft specs are disabled on the server', async () => { + const testBaseUrl = 'http://localhost:5000'; + const client = new ToolboxClient( + testBaseUrl, + undefined, + undefined, + Protocol.MCP_DRAFT_2026_v1, + ); + + const tool = await client.loadTool('get-n-rows'); + expect(tool.getName()).toBe('get-n-rows'); + }); +}); diff --git a/packages/toolbox-core/test/mcp/test.v20260618.ts b/packages/toolbox-core/test/mcp/test.v20260618.ts new file mode 100644 index 00000000..a057bc90 --- /dev/null +++ b/packages/toolbox-core/test/mcp/test.v20260618.ts @@ -0,0 +1,549 @@ +// 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 {McpHttpTransportV20260618} from '../../src/toolbox_core/mcp/v20260618/mcp.js'; +import {jest} from '@jest/globals'; +import axios, {AxiosInstance, AxiosError} from 'axios'; + +import {Protocol} from '../../src/toolbox_core/protocol.js'; +import {ProtocolNegotiationError} from '../../src/toolbox_core/errorUtils.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('McpHttpTransportV20260618', () => { + const testBaseUrl = 'http://test.loc'; + let mockSession: jest.Mocked; + let transport: McpHttpTransportV20260618; + let consoleWarnSpy: ReturnType; + + 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 McpHttpTransportV20260618( + testBaseUrl, + mockSession, + Protocol.MCP_DRAFT_2026_v1, + ); + consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + }); + + afterEach(() => { + jest.clearAllMocks(); + consoleWarnSpy.mockRestore(); + }); + + describe('initialization', () => { + it('should not perform initialize handshake (no-op)', async () => { + const listResponse = { + data: { + jsonrpc: '2.0', + id: '1', + result: { + tools: [], + }, + }, + status: 200, + }; + + mockSession.post.mockResolvedValueOnce(listResponse); + + await transport.toolsList(); + + // Only tools/list request should be made, no initialize or initialized notification + expect(mockSession.post).toHaveBeenCalledTimes(1); + expect(mockSession.post).toHaveBeenLastCalledWith( + `${testBaseUrl}/mcp/`, + expect.objectContaining({ + method: 'tools/list', + params: { + _meta: expect.objectContaining({ + protocolVersion: 'DRAFT-2026-v1', + }), + }, + }), + expect.objectContaining({ + headers: expect.objectContaining({ + 'MCP-Protocol-Version': 'DRAFT-2026-v1', + }), + }), + ); + }); + }); + + describe('toolsList', () => { + it('should return converted tools', async () => { + const listResponse = { + data: { + jsonrpc: '2.0', + id: '1', + 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 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(); + }); + }); + + describe('toolGet', () => { + it('should return specific tool manifest', async () => { + const listResponse = { + data: { + jsonrpc: '2.0', + id: '1', + 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: '1', + 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', () => { + it('should invoke tool and return text content', async () => { + const invokeResponse = { + data: { + jsonrpc: '2.0', + id: '2', + 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'}, + _meta: expect.objectContaining({ + protocolVersion: 'DRAFT-2026-v1', + }), + }, + }), + expect.objectContaining({ + headers: expect.objectContaining({ + 'MCP-Protocol-Version': 'DRAFT-2026-v1', + }), + }), + ); + 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: '2', + 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 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 toolInvoke returns invalid JSON-RPC structure', async () => { + const errorSpy = jest + .spyOn(console, 'error') + .mockImplementation(() => {}); + const invalidResponse = { + data: { + foo: 'bar', + }, + status: 200, + }; + + mockSession.post.mockResolvedValueOnce(invalidResponse); + + await expect(transport.toolInvoke('testTool', {}, {})).rejects.toThrow( + 'Failed to parse JSON-RPC response structure', + ); + errorSpy.mockRestore(); + }); + }); + + describe('version negotiation', () => { + it('should throw ProtocolNegotiationError if server returns code -32022 with supported list', async () => { + const rpcError = { + jsonrpc: '2.0', + id: '1', + error: { + code: -32022, + message: 'Unsupported protocol version', + data: { + supported: ['2025-11-25'], + }, + }, + }; + + const config = + {} as unknown as import('axios').InternalAxiosRequestConfig; + const response = { + status: 400, + statusText: 'Bad Request', + headers: {}, + config, + data: rpcError, + } as unknown as import('axios').AxiosResponse; + + const axiosError = new AxiosError( + 'Request failed with status code 400', + 'ERR_BAD_REQUEST', + config, + {}, + response, + ); + + mockSession.post.mockRejectedValueOnce(axiosError); + + const errorSpy = jest + .spyOn(console, 'error') + .mockImplementation(() => {}); + + await expect(transport.toolsList()).rejects.toThrow( + ProtocolNegotiationError, + ); + + errorSpy.mockRestore(); + }); + + it('should throw ProtocolNegotiationError (legacy fallback) if server returns invalid protocol version string', async () => { + const rpcError = { + jsonrpc: '2.0', + id: '1', + error: 'invalid protocol version', + }; + + const config = + {} as unknown as import('axios').InternalAxiosRequestConfig; + const response = { + status: 400, + statusText: 'Bad Request', + headers: {}, + config, + data: rpcError, + } as unknown as import('axios').AxiosResponse; + + const axiosError = new AxiosError( + 'Request failed with status code 400', + 'ERR_BAD_REQUEST', + config, + {}, + response, + ); + + mockSession.post.mockRejectedValueOnce(axiosError); + + const errorSpy = jest + .spyOn(console, 'error') + .mockImplementation(() => {}); + + await expect(transport.toolsList()).rejects.toThrow( + ProtocolNegotiationError, + ); + + errorSpy.mockRestore(); + }); + + it('should throw Error if server returns code -32022 with no mutually supported version', async () => { + const rpcError = { + jsonrpc: '2.0', + id: '1', + error: { + code: -32022, + message: 'Unsupported protocol version', + data: { + supported: ['invalid-older-version'], + }, + }, + }; + + const config = + {} as unknown as import('axios').InternalAxiosRequestConfig; + const response = { + status: 400, + statusText: 'Bad Request', + headers: {}, + config, + data: rpcError, + } as unknown as import('axios').AxiosResponse; + + const axiosError = new AxiosError( + 'Request failed with status code 400', + 'ERR_BAD_REQUEST', + config, + {}, + response, + ); + + mockSession.post.mockRejectedValueOnce(axiosError); + + const errorSpy = jest + .spyOn(console, 'error') + .mockImplementation(() => {}); + + await expect(transport.toolsList()).rejects.toThrow( + /No mutually supported protocol version/, + ); + + errorSpy.mockRestore(); + }); + + it('should throw Error if server returns invalid protocol version but no fallback remains', async () => { + const oldestTransport = new McpHttpTransportV20260618( + testBaseUrl, + mockSession, + Protocol.MCP_v20241105, + ); + + const rpcError = { + jsonrpc: '2.0', + id: '1', + error: 'invalid protocol version', + }; + + const config = + {} as unknown as import('axios').InternalAxiosRequestConfig; + const response = { + status: 400, + statusText: 'Bad Request', + headers: {}, + config, + data: rpcError, + } as unknown as import('axios').AxiosResponse; + + const axiosError = new AxiosError( + 'Request failed with status code 400', + 'ERR_BAD_REQUEST', + config, + {}, + response, + ); + + mockSession.post.mockRejectedValueOnce(axiosError); + + const errorSpy = jest + .spyOn(console, 'error') + .mockImplementation(() => {}); + + await expect(oldestTransport.toolsList()).rejects.toThrow( + /no fallback versions remain/, + ); + + errorSpy.mockRestore(); + }); + }); + + describe('security and headers', () => { + it('should warn if sending headers over HTTP', async () => { + const invokeResponse = { + data: { + jsonrpc: '2.0', + id: '1', + result: {content: []}, + }, + status: 200, + }; + + mockSession.post.mockResolvedValueOnce(invokeResponse); + + await transport.toolInvoke( + 'testTool', + {arg: 'val'}, + {Authorization: 'Bearer token'}, + ); + + expect(consoleWarnSpy).toHaveBeenCalledWith( + expect.stringContaining( + 'This connection is using HTTP. To prevent credential exposure, please ensure all communication is sent over HTTPS.', + ), + ); + }); + + it('should not warn if using HTTPS', async () => { + const invokeResponse = { + data: { + jsonrpc: '2.0', + id: '1', + result: {content: []}, + }, + status: 200, + }; + // Create HTTPS transport + const httpsTransport = new McpHttpTransportV20260618( + 'https://secure.test.loc', + mockSession, + Protocol.MCP_DRAFT_2026_v1, + ); + + mockSession.post.mockResolvedValueOnce(invokeResponse); + + await httpsTransport.toolInvoke( + 'testTool', + {arg: 'val'}, + {Authorization: 'Bearer token'}, + ); + + expect(consoleWarnSpy).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/toolbox-core/test/test.client.ts b/packages/toolbox-core/test/test.client.ts index 881e6925..2db3b618 100644 --- a/packages/toolbox-core/test/test.client.ts +++ b/packages/toolbox-core/test/test.client.ts @@ -25,6 +25,8 @@ import {McpHttpTransportV20241105} from '../src/toolbox_core/mcp/v20241105/mcp.j import {McpHttpTransportV20250326} from '../src/toolbox_core/mcp/v20250326/mcp.js'; import {McpHttpTransportV20250618} from '../src/toolbox_core/mcp/v20250618/mcp.js'; import {McpHttpTransportV20251125} from '../src/toolbox_core/mcp/v20251125/mcp.js'; +import {McpHttpTransportV20260618} from '../src/toolbox_core/mcp/v20260618/mcp.js'; +import {ProtocolNegotiationError} from '../src/toolbox_core/errorUtils.js'; // --- Mock Transport Implementation --- class MockTransport implements ITransport { @@ -73,6 +75,14 @@ jest.mock('../src/toolbox_core/mcp/v20251125/mcp', () => { }; }); +// Mock the McpHttpTransportV20260618 module +jest.mock('../src/toolbox_core/mcp/v20260618/mcp', () => { + return { + __esModule: true, + McpHttpTransportV20260618: jest.fn(), + }; +}); + describe('ToolboxClient', () => { const testBaseUrl = 'https://api.example.com'; let mockTransport: MockTransport; @@ -95,6 +105,9 @@ describe('ToolboxClient', () => { (McpHttpTransportV20251125 as unknown as jest.Mock).mockImplementation( () => mockTransport, ); + (McpHttpTransportV20260618 as unknown as jest.Mock).mockImplementation( + () => mockTransport, + ); }); afterEach(async () => { @@ -605,4 +618,93 @@ describe('ToolboxClient', () => { ); }); }); + + describe('Protocol Fallback', () => { + it('should fall back to supported version in loadTool when transport throws ProtocolNegotiationError', async () => { + const draftTransport = new MockTransport(testBaseUrl); + const fallbackTransport = new MockTransport(testBaseUrl); + + draftTransport.toolGet.mockRejectedValueOnce( + new ProtocolNegotiationError(Protocol.MCP_v20251125), + ); + + fallbackTransport.toolGet.mockResolvedValueOnce({ + serverVersion: '1.0.0', + tools: { + testTool: { + description: 'desc', + parameters: [], + }, + }, + }); + + (McpHttpTransportV20260618 as unknown as jest.Mock).mockImplementation( + () => { + return draftTransport; + }, + ); + (McpHttpTransportV20251125 as unknown as jest.Mock).mockImplementation( + () => { + return fallbackTransport; + }, + ); + + client = new ToolboxClient( + testBaseUrl, + undefined, + undefined, + Protocol.MCP_DRAFT_2026_v1, + ); + + const tool = await client.loadTool('testTool'); + expect(tool.getName()).toBe('testTool'); + expect(draftTransport.toolGet).toHaveBeenCalledTimes(1); + expect(fallbackTransport.toolGet).toHaveBeenCalledTimes(1); + expect(McpHttpTransportV20251125).toHaveBeenCalledTimes(1); + }); + + it('should fall back to supported version in loadToolset when transport throws ProtocolNegotiationError', async () => { + const draftTransport = new MockTransport(testBaseUrl); + const fallbackTransport = new MockTransport(testBaseUrl); + + draftTransport.toolsList.mockRejectedValueOnce( + new ProtocolNegotiationError(Protocol.MCP_v20251125), + ); + + fallbackTransport.toolsList.mockResolvedValueOnce({ + serverVersion: '1.0.0', + tools: { + testTool: { + description: 'desc', + parameters: [], + }, + }, + }); + + (McpHttpTransportV20260618 as unknown as jest.Mock).mockImplementation( + () => { + return draftTransport; + }, + ); + (McpHttpTransportV20251125 as unknown as jest.Mock).mockImplementation( + () => { + return fallbackTransport; + }, + ); + + client = new ToolboxClient( + testBaseUrl, + undefined, + undefined, + Protocol.MCP_DRAFT_2026_v1, + ); + + const tools = await client.loadToolset('set'); + expect(tools.length).toBe(1); + expect(tools[0].getName()).toBe('testTool'); + expect(draftTransport.toolsList).toHaveBeenCalledTimes(1); + expect(fallbackTransport.toolsList).toHaveBeenCalledTimes(1); + expect(McpHttpTransportV20251125).toHaveBeenCalledTimes(1); + }); + }); });