Skip to content

Added auto-refreshing tool list notification handler to client #239

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 8 commits into
base: main
Choose a base branch
from
186 changes: 184 additions & 2 deletions src/client/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
ListResourceTemplatesRequest,
ListResourceTemplatesResultSchema,
ListToolsRequest,
ListToolsResult,
ListToolsResultSchema,
LoggingLevel,
Notification,
Expand All @@ -46,6 +47,27 @@
* Capabilities to advertise as being supported by this client.
*/
capabilities?: ClientCapabilities;
/**
* Configure automatic refresh behavior for tool list changes
*/
toolRefreshOptions?: {
/**
* Whether to automatically refresh the tools list when a change notification is received.
* Default: true
*/
autoRefresh?: boolean;
/**
* Debounce time in milliseconds for tool list refresh operations.
* Multiple notifications received within this timeframe will only trigger one refresh.
* Default: 300
*/
debounceMs?: number;
/**
* Optional callback for handling tool list refresh errors.
* When provided, this will be called instead of logging to console.
*/
onError?: (error: Error) => void;
};
};

/**
Expand Down Expand Up @@ -86,6 +108,18 @@
private _serverVersion?: Implementation;
private _capabilities: ClientCapabilities;
private _instructions?: string;
private _toolRefreshOptions: {
autoRefresh: boolean;
debounceMs: number;
onError?: (error: Error) => void;
};
private _toolRefreshDebounceTimer?: ReturnType<typeof setTimeout>;

/**
* Callback for when the server indicates that the tools list has changed.
* Client should typically refresh its list of tools in response.
*/
onToolListChanged?: (tools?: ListToolsResult["tools"]) => void;

/**
* Initializes this client with the given name and version information.
Expand All @@ -96,6 +130,64 @@
) {
super(options);
this._capabilities = options?.capabilities ?? {};
this._toolRefreshOptions = {
autoRefresh: options?.toolRefreshOptions?.autoRefresh ?? true,
debounceMs: options?.toolRefreshOptions?.debounceMs ?? 500,
onError: options?.toolRefreshOptions?.onError,
};

// Set up notification handlers
this.setNotificationHandler(
"notifications/tools/list_changed",

Check failure on line 141 in src/client/index.ts

View workflow job for this annotation

GitHub Actions / build

Argument of type 'string' is not assignable to parameter of type 'ZodObject<{ method: ZodLiteral<string>; }, UnknownKeysParam, ZodTypeAny, { method: string; }, { method: string; }>'.
async () => {
// Only proceed with refresh if auto-refresh is enabled
if (!this._toolRefreshOptions.autoRefresh) {
// Still call callback to notify about the change, but without tools data
this.onToolListChanged?.(undefined);
return;
}

// Clear any pending refresh timer
if (this._toolRefreshDebounceTimer) {
clearTimeout(this._toolRefreshDebounceTimer);
}

// Set up debounced refresh
this._toolRefreshDebounceTimer = setTimeout(() => {
this._refreshToolsList().catch((error) => {
// Use error callback if provided, otherwise log to console
if (this._toolRefreshOptions.onError) {
this._toolRefreshOptions.onError(error instanceof Error ? error : new Error(String(error)));
} else {
console.error("Failed to refresh tools list:", error);
}
});
}, this._toolRefreshOptions.debounceMs);
}
);
}

/**
* Private method to handle tools list refresh
*/
private async _refreshToolsList(): Promise<void> {
try {
// Only refresh if the server supports tools
if (this._serverCapabilities?.tools) {
const result = await this.listTools();
// Call the user's callback with the updated tools list
this.onToolListChanged?.(result.tools);
}
} catch (error) {
// Use error callback if provided, otherwise log to console
if (this._toolRefreshOptions.onError) {
this._toolRefreshOptions.onError(error instanceof Error ? error : new Error(String(error)));
} else {
console.error("Failed to refresh tools list:", error);
}
// Still call the callback even if refresh failed
this.onToolListChanged?.(undefined);
}
}

/**
Expand All @@ -113,13 +205,48 @@
this._capabilities = mergeCapabilities(this._capabilities, capabilities);
}

/**
* Updates the tool refresh options
*/
public setToolRefreshOptions(
options: ClientOptions["toolRefreshOptions"]
): void {
if (options) {
if (options.autoRefresh !== undefined) {
this._toolRefreshOptions.autoRefresh = options.autoRefresh;
}
if (options.debounceMs !== undefined) {
this._toolRefreshOptions.debounceMs = options.debounceMs;
}
if (options.onError !== undefined) {
this._toolRefreshOptions.onError = options.onError;
}
}
}

/**
* Gets the current tool refresh options
*/
public getToolRefreshOptions(): typeof this._toolRefreshOptions {
return { ...this._toolRefreshOptions };
}

/**
* Sets an error handler for tool list refresh errors
*
* @param handler Function to call when a tool list refresh error occurs
*/
public setToolRefreshErrorHandler(handler: (error: Error) => void): void {
this._toolRefreshOptions.onError = handler;
}

protected assertCapability(
capability: keyof ServerCapabilities,
method: string,
): void {
if (!this._serverCapabilities?.[capability]) {
throw new Error(
`Server does not support ${capability} (required for ${method})`,
`Server does not support ${String(capability)} (required for ${method})`,
);
}
}
Expand Down Expand Up @@ -266,7 +393,17 @@
case "notifications/roots/list_changed":
if (!this._capabilities.roots?.listChanged) {
throw new Error(
`Client does not support roots list changed notifications (required for ${method})`,
`Client does not support roots list changed notifications (required for ${method})`
);
}
break;

case "notifications/tools/list_changed":

Check failure on line 401 in src/client/index.ts

View workflow job for this annotation

GitHub Actions / build

Type '"notifications/tools/list_changed"' is not comparable to type '"notifications/cancelled" | "notifications/initialized" | "notifications/progress" | "notifications/roots/list_changed"'.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same, client will not send notifications/tools/list_changed

if (!this._capabilities.tools?.listChanged) {

Check failure on line 402 in src/client/index.ts

View workflow job for this annotation

GitHub Actions / build

Property 'listChanged' does not exist on type '{}'.
throw new Error(
`Client does not support tools capability (required for ${String(
method
)})`
);
}
break;
Expand Down Expand Up @@ -420,6 +557,35 @@
);
}

/**
* Retrieves the list of available tools from the server.
*
* This method is called automatically when a tools list changed notification
* is received (if auto-refresh is enabled and after debouncing).
*
* To manually refresh the tools list:
* ```typescript
* try {
* const result = await client.listTools();
* // Use result.tools
* } catch (error) {
* // Handle error
* }
* ```
*
* Alternatively, register an error handler:
* ```typescript
* client.setToolRefreshErrorHandler((error) => {
* // Handle error
* });
*
* const result = await client.listTools();
* ```
*
* @param params Optional parameters for the list tools request
* @param options Optional request options
* @returns The list tools result containing available tools
*/
async listTools(
params?: ListToolsRequest["params"],
options?: RequestOptions,
Expand All @@ -431,7 +597,23 @@
);
}

/**
* Registers a callback to be called when the server indicates that
* the tools list has changed. The callback should typically refresh the tools list.
*
* @param callback Function to call when tools list changes
*/
setToolListChangedCallback(
callback: (tools?: ListToolsResult["tools"]) => void
): void {
this.onToolListChanged = callback;
}

async sendRootsListChanged() {
return this.notification({ method: "notifications/roots/list_changed" });
}

async sendToolListChanged() {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this doesn't look right, client should receive the "notifications/tools/list_changed" notification, not send it

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@johnjjung did you have a chance to check this?

return this.notification({ method: "notifications/tools/list_changed" });

Check failure on line 617 in src/client/index.ts

View workflow job for this annotation

GitHub Actions / build

Object literal may only specify known properties, and 'method' does not exist in type 'never'.
}
}
Loading