Skip to content

Commit c648adc

Browse files
committed
feat(core): support user defined fallback protocol array
1 parent a0c429f commit c648adc

12 files changed

Lines changed: 311 additions & 45 deletions

File tree

packages/toolbox-adk/src/toolbox_adk/client.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ export class ToolboxClient {
4646
url: string,
4747
session?: AxiosInstance | null,
4848
clientHeaders?: ClientHeadersConfig | null,
49-
protocol: Protocol = Protocol.MCP,
49+
protocol: Protocol | Protocol[] = Protocol.MCP,
5050
) {
5151
this.coreClient = new CoreToolboxClient(
5252
url,

packages/toolbox-core/README.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,11 +69,23 @@ The core package provides a framework-agnostic way to interact with your MCP Too
6969

7070
- [Transport Protocols](https://mcp-toolbox.dev/documentation/connect-to/toolbox-sdks/javascript-sdk/core/#transport-protocols)
7171
- [Loading Tools](https://mcp-toolbox.dev/documentation/connect-to/toolbox-sdks/javascript-sdk/core/#loading-tools)
72+
7273
- [Invoking Tools](https://mcp-toolbox.dev/documentation/connect-to/toolbox-sdks/javascript-sdk/core/#invoking-tools)
7374
- [Client to Server Authentication](https://mcp-toolbox.dev/documentation/connect-to/toolbox-sdks/javascript-sdk/core/#client-to-server-authentication)
7475
- [Authenticating Tools](https://mcp-toolbox.dev/documentation/connect-to/toolbox-sdks/javascript-sdk/core/#authenticating-tools)
7576
- [Binding Parameter Values](https://mcp-toolbox.dev/documentation/connect-to/toolbox-sdks/javascript-sdk/core/#binding-parameter-values)
7677

78+
## Protocol Negotiation
79+
80+
By default, the client negotiates the newest protocol version supported by the server. You can provide a custom list of supported protocols to restrict negotiation to specific versions or a single version. Ensure you pass the [`Protocol`](src/toolbox_core/protocol.ts) enum constants. Both `Protocol.MCP_LATEST` and `Protocol.MCP_DRAFT` are supported as well.
81+
82+
```javascript
83+
import { ToolboxClient, Protocol } from '@toolbox-sdk/core';
84+
85+
// Pass a custom list of supported protocols as the fourth argument
86+
const client = new ToolboxClient(URL, undefined, undefined, [Protocol.MCP_LATEST, Protocol.MCP_DRAFT]);
87+
```
88+
7789
# Contributing
7890

7991
Contributions are welcome! Please refer to the [DEVELOPER.md](./DEVELOPER.md)

packages/toolbox-core/src/toolbox_core/client.ts

Lines changed: 96 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ class ToolboxClient {
5151
#url: string;
5252
#transport: ITransport;
5353
#clientHeaders: ClientHeadersConfig;
54+
#supportedProtocols: string[] | undefined;
5455

5556
/**
5657
* The negotiated protocol version currently in use.
@@ -72,21 +73,53 @@ class ToolboxClient {
7273
url: string,
7374
session?: AxiosInstance | null,
7475
clientHeaders?: ClientHeadersConfig | null,
75-
protocol: Protocol = Protocol.MCP,
76+
protocol: Protocol | Protocol[] | string[] | string = Protocol.MCP,
7677
clientName?: string,
7778
clientVersion?: string,
7879
) {
7980
this.#url = url;
8081
this.#clientHeaders = clientHeaders || {};
8182
warnIfHttpAndHeaders(url, this.#clientHeaders);
82-
if (!getSupportedMcpVersions().includes(protocol)) {
83-
throw new Error(`Unsupported protocol version: ${protocol}`);
83+
84+
let initialProtocol: Protocol;
85+
if (Array.isArray(protocol)) {
86+
if (protocol.length === 0) {
87+
throw new Error('Protocol array cannot be empty');
88+
}
89+
90+
const globalSupported = getSupportedMcpVersions();
91+
92+
for (const p of protocol) {
93+
if (!globalSupported.includes(p as Protocol)) {
94+
throw new Error(`Invalid protocol version '${p}'. Must be one of: ${globalSupported.join(', ')}`);
95+
}
96+
}
97+
98+
const sorted: string[] = [];
99+
100+
for (const globalVer of globalSupported) {
101+
if (protocol.includes(globalVer as any)) {
102+
sorted.push(globalVer);
103+
}
104+
}
105+
106+
if (sorted.length === 0) {
107+
throw new Error('None of the provided protocols are supported');
108+
}
109+
110+
this.#supportedProtocols = sorted;
111+
initialProtocol = sorted[0] as Protocol; // Start with the highest requested version
112+
} else {
113+
initialProtocol = protocol as Protocol;
114+
if (!getSupportedMcpVersions().includes(initialProtocol)) {
115+
throw new Error(`Unsupported protocol version: ${initialProtocol}`);
116+
}
84117
}
85118

86-
this.#transport = this.#createTransport(
119+
this.#transport = this.#createTransportWithProtocols(
87120
url,
88121
session || undefined,
89-
protocol,
122+
initialProtocol,
90123
clientName,
91124
clientVersion,
92125
);
@@ -146,6 +179,20 @@ class ToolboxClient {
146179
}
147180
}
148181

182+
#createTransportWithProtocols(
183+
url: string,
184+
session: AxiosInstance | undefined,
185+
protocol: Protocol,
186+
clientName?: string,
187+
clientVersion?: string,
188+
): ITransport {
189+
const transport = this.#createTransport(url, session, protocol, clientName, clientVersion);
190+
if (this.#supportedProtocols) {
191+
transport.supportedProtocols = this.#supportedProtocols;
192+
}
193+
return transport;
194+
}
195+
149196
/**
150197
* Resolves client headers from their provider functions.
151198
* @returns {Promise<Record<string, string>>} A promise that resolves to the resolved headers.
@@ -217,6 +264,42 @@ class ToolboxClient {
217264
return {tool, usedAuthKeys, usedBoundKeys};
218265
}
219266

267+
async #executeWithFallback<T>(action: () => Promise<T>): Promise<T> {
268+
try {
269+
return await action();
270+
} catch (e: unknown) {
271+
if (e instanceof ProtocolNegotiationError) {
272+
const serverVersion = e.fallbackVersion as string;
273+
let mutuallySupported: string[] | null = null;
274+
275+
if (this.#supportedProtocols.includes(serverVersion as Protocol)) {
276+
mutuallySupported = [serverVersion];
277+
} else {
278+
const allVersions = getSupportedMcpVersions();
279+
if (allVersions.includes(serverVersion as Protocol)) {
280+
const idx = allVersions.indexOf(serverVersion as Protocol);
281+
const serverSupported = allVersions.slice(idx);
282+
mutuallySupported = this.#supportedProtocols.filter(v =>
283+
serverSupported.includes(v),
284+
);
285+
}
286+
}
287+
288+
if (!mutuallySupported || mutuallySupported.length === 0) {
289+
throw new Error('No mutually supported protocol version');
290+
}
291+
292+
this.#transport = this.#createTransportWithProtocols(
293+
this.#url,
294+
undefined,
295+
mutuallySupported[0] as Protocol,
296+
);
297+
return await action();
298+
}
299+
throw e;
300+
}
301+
}
302+
220303
/**
221304
* Asynchronously loads a tool from the server.
222305
* Retrieves the schema for the specified tool from the Toolbox server and
@@ -238,21 +321,10 @@ class ToolboxClient {
238321
): Promise<ToolboxTool> {
239322
warnIfHttpAndHeaders(this.#transport.baseUrl, authTokenGetters);
240323
const headers = await this.#resolveClientHeaders();
241-
let manifest;
242-
try {
243-
manifest = await this.#transport.toolGet(name, headers);
244-
} catch (e: unknown) {
245-
if (e instanceof ProtocolNegotiationError) {
246-
this.#transport = this.#createTransport(
247-
this.#url,
248-
undefined,
249-
e.fallbackVersion as Protocol,
250-
);
251-
manifest = await this.#transport.toolGet(name, headers);
252-
} else {
253-
throw e;
254-
}
255-
}
324+
const manifest = await this.#executeWithFallback(() =>
325+
this.#transport.toolGet(name, headers),
326+
);
327+
256328

257329
if (
258330
manifest.tools &&
@@ -323,21 +395,10 @@ class ToolboxClient {
323395
const toolsetName = name || '';
324396
const headers = await this.#resolveClientHeaders();
325397

326-
let manifest;
327-
try {
328-
manifest = await this.#transport.toolsList(toolsetName, headers);
329-
} catch (e: unknown) {
330-
if (e instanceof ProtocolNegotiationError) {
331-
this.#transport = this.#createTransport(
332-
this.#url,
333-
undefined,
334-
e.fallbackVersion as Protocol,
335-
);
336-
manifest = await this.#transport.toolsList(toolsetName, headers);
337-
} else {
338-
throw e;
339-
}
340-
}
398+
const manifest = await this.#executeWithFallback(() =>
399+
this.#transport.toolsList(toolsetName, headers),
400+
);
401+
341402
const tools: ToolboxTool[] = [];
342403

343404
const overallUsedAuthKeys: Set<string> = new Set();

packages/toolbox-core/src/toolbox_core/mcp/transportBase.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ export abstract class McpHttpTransportBase implements ITransport {
4545
protected _mcpBaseUrl: string;
4646
protected _protocolVersion: string;
4747
protected _serverVersion: string | null = null;
48+
public supportedProtocols?: string[];
4849

4950
protected _manageSession: boolean;
5051
protected _session: AxiosInstance;

packages/toolbox-core/src/toolbox_core/mcp/v20250618/mcp.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import {AxiosError} from 'axios';
1616
import {McpHttpTransportBase} from '../transportBase.js';
1717
import * as types from './types.js';
1818

19-
import {ZodManifest} from '../../protocol.js';
19+
import {ZodManifest, Protocol, getSupportedMcpVersions} from '../../protocol.js';
2020
import {logApiError, ProtocolNegotiationError} from '../../errorUtils.js';
2121
import {warnIfHttpAndHeaders} from '../../utils.js';
2222

@@ -122,6 +122,11 @@ export class McpHttpTransportV20250618 extends McpHttpTransportBase {
122122
const errObj = errorData.error;
123123

124124
if (typeof errObj === 'string' && errObj.includes('invalid protocol version')) {
125+
const supported = this.supportedProtocols || getSupportedMcpVersions();
126+
const currentIdx = supported.indexOf(this._protocolVersion as Protocol);
127+
if (currentIdx !== -1 && currentIdx + 1 < supported.length) {
128+
throw new ProtocolNegotiationError(supported[currentIdx + 1]);
129+
}
125130
throw new ProtocolNegotiationError(Protocol.MCP_v20250618);
126131
}
127132

@@ -136,7 +141,7 @@ export class McpHttpTransportV20250618 extends McpHttpTransportBase {
136141
const supported = (errData as Record<string, unknown>).supported;
137142
if (Array.isArray(supported) && supported.length > 0) {
138143
let mutuallySupportedVersion: string | null = null;
139-
for (const ourVer of getSupportedMcpVersions()) {
144+
for (const ourVer of (this.supportedProtocols || getSupportedMcpVersions())) {
140145
if (supported.includes(ourVer)) {
141146
mutuallySupportedVersion = ourVer;
142147
break;

packages/toolbox-core/src/toolbox_core/mcp/v20251125/mcp.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import {AxiosError} from 'axios';
1616
import {McpHttpTransportBase} from '../transportBase.js';
1717
import * as types from './types.js';
1818

19-
import {ZodManifest} from '../../protocol.js';
19+
import {ZodManifest, Protocol, getSupportedMcpVersions} from '../../protocol.js';
2020
import {logApiError, ProtocolNegotiationError} from '../../errorUtils.js';
2121
import {warnIfHttpAndHeaders} from '../../utils.js';
2222

@@ -122,6 +122,11 @@ export class McpHttpTransportV20251125 extends McpHttpTransportBase {
122122
const errObj = errorData.error;
123123

124124
if (typeof errObj === 'string' && errObj.includes('invalid protocol version')) {
125+
const supported = this.supportedProtocols || getSupportedMcpVersions();
126+
const currentIdx = supported.indexOf(this._protocolVersion as Protocol);
127+
if (currentIdx !== -1 && currentIdx + 1 < supported.length) {
128+
throw new ProtocolNegotiationError(supported[currentIdx + 1]);
129+
}
125130
throw new ProtocolNegotiationError(Protocol.MCP_v20250618);
126131
}
127132

@@ -136,7 +141,7 @@ export class McpHttpTransportV20251125 extends McpHttpTransportBase {
136141
const supported = (errData as Record<string, unknown>).supported;
137142
if (Array.isArray(supported) && supported.length > 0) {
138143
let mutuallySupportedVersion: string | null = null;
139-
for (const ourVer of getSupportedMcpVersions()) {
144+
for (const ourVer of (this.supportedProtocols || getSupportedMcpVersions())) {
140145
if (supported.includes(ourVer)) {
141146
mutuallySupportedVersion = ourVer;
142147
break;

packages/toolbox-core/src/toolbox_core/mcp/v20260618/mcp.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,7 @@ export class McpHttpTransportV20260618 extends McpHttpTransportBase {
123123
const supported = (err.data as Record<string, unknown>).supported;
124124
if (Array.isArray(supported) && supported.length > 0) {
125125
let mutuallySupportedVersion: string | null = null;
126-
for (const ourVer of getSupportedMcpVersions()) {
126+
for (const ourVer of (this.supportedProtocols || getSupportedMcpVersions())) {
127127
if (supported.includes(ourVer)) {
128128
mutuallySupportedVersion = ourVer;
129129
break;
@@ -172,6 +172,11 @@ export class McpHttpTransportV20260618 extends McpHttpTransportBase {
172172
const errObj = errorData.error;
173173

174174
if (typeof errObj === 'string' && errObj.includes('invalid protocol version')) {
175+
const supported = this.supportedProtocols || getSupportedMcpVersions();
176+
const currentIdx = supported.indexOf(this._protocolVersion as Protocol);
177+
if (currentIdx !== -1 && currentIdx + 1 < supported.length) {
178+
throw new ProtocolNegotiationError(supported[currentIdx + 1]);
179+
}
175180
throw new ProtocolNegotiationError(Protocol.MCP_v20250618);
176181
}
177182

@@ -186,7 +191,7 @@ export class McpHttpTransportV20260618 extends McpHttpTransportBase {
186191
const supported = (errData as Record<string, unknown>).supported;
187192
if (Array.isArray(supported) && supported.length > 0) {
188193
let mutuallySupportedVersion: string | null = null;
189-
for (const ourVer of getSupportedMcpVersions()) {
194+
for (const ourVer of (this.supportedProtocols || getSupportedMcpVersions())) {
190195
if (supported.includes(ourVer)) {
191196
mutuallySupportedVersion = ourVer;
192197
break;

packages/toolbox-core/src/toolbox_core/transport.types.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,11 @@ export interface ITransport {
2626
*/
2727
readonly protocolVersion: string;
2828

29+
/**
30+
* The list of supported protocols that the client claims to support.
31+
*/
32+
supportedProtocols?: string[];
33+
2934
/**
3035
* The base URL for the transport.
3136
*/

packages/toolbox-core/test/e2e/test.e2e.ts

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,9 @@ import {CustomGlobal} from './types.js';
2424
import {authTokenGetter} from './utils.js';
2525
import {ZodTypeAny} from 'zod';
2626

27-
const testBaseUrls = ['http://localhost:5000', 'http://localhost:5001'];
27+
const TOOLBOX_SERVER_URL_STABLE = 'http://localhost:5000';
28+
const TOOLBOX_SERVER_URL_DRAFT = 'http://localhost:5001';
29+
const testBaseUrls = [TOOLBOX_SERVER_URL_STABLE, TOOLBOX_SERVER_URL_DRAFT];
2830

2931
testBaseUrls.forEach(testBaseUrl => {
3032
describe.each(getSupportedMcpVersions())(
@@ -662,13 +664,49 @@ testBaseUrls.forEach(testBaseUrl => {
662664
expect(typeof response).toBe('string');
663665
expect(response).toContain('row1');
664666

665-
if (testBaseUrl === 'http://localhost:5001') {
667+
if (testBaseUrl === TOOLBOX_SERVER_URL_DRAFT) {
666668
// 5001 supports draft, so no fallback
667669
expect(client.protocolVersion).toBe(Protocol.MCP_v2026_DRAFT);
668670
} else {
669671
// 5000 does not support draft, so fallback
670672
expect(client.protocolVersion).not.toBe(Protocol.MCP_v2026_DRAFT);
671673
}
672674
});
675+
676+
it('should correctly negotiate Protocol.MCP_LATEST', async () => {
677+
const client = new ToolboxClient(
678+
testBaseUrl,
679+
undefined,
680+
undefined,
681+
Protocol.MCP_LATEST,
682+
);
683+
684+
const tool = await client.loadTool('get-n-rows');
685+
const response = await tool({num_rows: '1'});
686+
expect(typeof response).toBe('string');
687+
expect(response).toContain('row1');
688+
689+
expect(client.protocolVersion).toBe(Protocol.MCP_LATEST);
690+
});
691+
692+
it('should correctly negotiate with a custom list [Protocol.MCP_v20241105, Protocol.MCP_v20250326, Protocol.MCP_LATEST, Protocol.MCP_DRAFT]', async () => {
693+
const client = new ToolboxClient(
694+
testBaseUrl,
695+
undefined,
696+
undefined,
697+
[Protocol.MCP_v20241105, Protocol.MCP_v20250326, Protocol.MCP_LATEST, Protocol.MCP_DRAFT],
698+
);
699+
700+
const tool = await client.loadTool('get-n-rows');
701+
const response = await tool({num_rows: '1'});
702+
expect(typeof response).toBe('string');
703+
expect(response).toContain('row1');
704+
705+
if (testBaseUrl === TOOLBOX_SERVER_URL_DRAFT) {
706+
expect(client.protocolVersion).toBe(Protocol.MCP_DRAFT);
707+
} else {
708+
expect(client.protocolVersion).toBe(Protocol.MCP_LATEST);
709+
}
710+
});
673711
});
674712
});

0 commit comments

Comments
 (0)