From ce43192c801fea3f4181f159f953614fc2d457a8 Mon Sep 17 00:00:00 2001 From: Ryan Folsom Date: Wed, 11 Feb 2026 15:32:08 -0500 Subject: [PATCH] Add tools.list SDK support and bump CLI to 0.0.407 Add listTools() / list_tools() / ListTools() / ListToolsAsync() methods across all four SDK languages (TypeScript, Python, Go, .NET) to expose the new tools.list RPC from the CLI runtime. Includes ToolInfo types, client methods, exports, and E2E tests for each language. Bumps @github/copilot from ^0.0.405 to ^0.0.407 which includes the tools.list RPC handler. --- dotnet/src/Client.cs | 23 ++++++++++++++ dotnet/src/Types.cs | 37 ++++++++++++++++++++++ dotnet/test/ClientTests.cs | 29 ++++++++++++++++++ go/client.go | 21 +++++++++++++ go/internal/e2e/client_test.go | 33 ++++++++++++++++++++ go/types.go | 19 ++++++++++++ nodejs/package-lock.json | 56 +++++++++++++++++----------------- nodejs/package.json | 2 +- nodejs/src/client.ts | 19 ++++++++++++ nodejs/src/index.ts | 1 + nodejs/src/types.ts | 20 ++++++++++++ nodejs/test/e2e/client.test.ts | 20 ++++++++++++ python/copilot/__init__.py | 2 ++ python/copilot/client.py | 29 ++++++++++++++++++ python/copilot/types.py | 43 ++++++++++++++++++++++++++ python/e2e/test_client.py | 21 +++++++++++++ 16 files changed, 346 insertions(+), 29 deletions(-) diff --git a/dotnet/src/Client.cs b/dotnet/src/Client.cs index 74f1c66f..6e446c99 100644 --- a/dotnet/src/Client.cs +++ b/dotnet/src/Client.cs @@ -593,6 +593,23 @@ public async Task> ListModelsAsync(CancellationToken cancellatio } } + /// + /// Lists available built-in tools with their metadata. + /// + /// Optional model ID to get model-specific tool overrides. + /// A that can be used to cancel the operation. + /// A task that resolves with a list of available tools. + /// Thrown when the client is not connected. + public async Task> ListToolsAsync(string? model = null, CancellationToken cancellationToken = default) + { + var connection = await EnsureConnectedAsync(cancellationToken); + + var response = await InvokeRpcAsync( + connection.Rpc, "tools.list", [new ListToolsRequest { Model = model }], cancellationToken); + + return response.Tools; + } + /// /// Gets the ID of the most recently used session. /// @@ -1385,6 +1402,11 @@ internal record UserInputRequestResponse( internal record HooksInvokeResponse( object? Output); + internal record ListToolsRequest + { + public string? Model { get; init; } + } + /// Trace source that forwards all logs to the ILogger. internal sealed class LoggerTraceSource : TraceSource { @@ -1439,6 +1461,7 @@ public override void WriteLine(string? message) => [JsonSerializable(typeof(GetLastSessionIdResponse))] [JsonSerializable(typeof(HooksInvokeResponse))] [JsonSerializable(typeof(ListSessionsResponse))] + [JsonSerializable(typeof(ListToolsRequest))] [JsonSerializable(typeof(PermissionRequestResponse))] [JsonSerializable(typeof(PermissionRequestResult))] [JsonSerializable(typeof(ProviderConfig))] diff --git a/dotnet/src/Types.cs b/dotnet/src/Types.cs index 664b35d9..f3fbacf8 100644 --- a/dotnet/src/Types.cs +++ b/dotnet/src/Types.cs @@ -1063,6 +1063,41 @@ public class GetModelsResponse public List Models { get; set; } = new(); } +/// +/// Information about an available built-in tool +/// +public class ToolInfoItem +{ + /// Tool identifier (e.g., "bash", "grep", "str_replace_editor") + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; + + /// Optional namespaced name for declarative filtering (e.g., "playwright/navigate" for MCP tools) + [JsonPropertyName("namespacedName")] + public string? NamespacedName { get; set; } + + /// Description of what the tool does + [JsonPropertyName("description")] + public string Description { get; set; } = string.Empty; + + /// JSON Schema for the tool's input parameters + [JsonPropertyName("parameters")] + public JsonElement? Parameters { get; set; } + + /// Optional instructions for how to use this tool effectively + [JsonPropertyName("instructions")] + public string? Instructions { get; set; } +} + +/// +/// Response from tools.list +/// +public class GetToolsResponse +{ + [JsonPropertyName("tools")] + public List Tools { get; set; } = new(); +} + // ============================================================================ // Session Lifecycle Types (for TUI+server mode) // ============================================================================ @@ -1143,6 +1178,7 @@ public class SetForegroundSessionResponse [JsonSerializable(typeof(GetAuthStatusResponse))] [JsonSerializable(typeof(GetForegroundSessionResponse))] [JsonSerializable(typeof(GetModelsResponse))] +[JsonSerializable(typeof(GetToolsResponse))] [JsonSerializable(typeof(GetStatusResponse))] [JsonSerializable(typeof(McpLocalServerConfig))] [JsonSerializable(typeof(McpRemoteServerConfig))] @@ -1165,6 +1201,7 @@ public class SetForegroundSessionResponse [JsonSerializable(typeof(SetForegroundSessionResponse))] [JsonSerializable(typeof(SystemMessageConfig))] [JsonSerializable(typeof(ToolBinaryResult))] +[JsonSerializable(typeof(ToolInfoItem))] [JsonSerializable(typeof(ToolInvocation))] [JsonSerializable(typeof(ToolResultObject))] [JsonSerializable(typeof(JsonElement))] diff --git a/dotnet/test/ClientTests.cs b/dotnet/test/ClientTests.cs index e3419f98..40701eda 100644 --- a/dotnet/test/ClientTests.cs +++ b/dotnet/test/ClientTests.cs @@ -148,6 +148,35 @@ public async Task Should_List_Models_When_Authenticated() } } + [Fact] + public async Task Should_List_Tools() + { + using var client = new CopilotClient(new CopilotClientOptions { UseStdio = true }); + + try + { + await client.StartAsync(); + + var tools = await client.ListToolsAsync(); + Assert.NotNull(tools); + Assert.True(tools.Count > 0, "Expected at least one tool"); + if (tools.Count > 0) + { + var tool = tools[0]; + Assert.NotNull(tool.Name); + Assert.NotEmpty(tool.Name); + Assert.NotNull(tool.Description); + Assert.NotEmpty(tool.Description); + } + + await client.StopAsync(); + } + finally + { + await client.ForceStopAsync(); + } + } + [Fact] public void Should_Accept_GithubToken_Option() { diff --git a/go/client.go b/go/client.go index 319c6588..d0b21384 100644 --- a/go/client.go +++ b/go/client.go @@ -970,6 +970,27 @@ func (c *Client) ListModels(ctx context.Context) ([]ModelInfo, error) { return models, nil } +// ListTools returns available built-in tools with their metadata. +// +// When a model is provided, the returned tool list reflects model-specific overrides. +func (c *Client) ListTools(ctx context.Context, model string) ([]ToolInfo, error) { + if c.client == nil { + return nil, fmt.Errorf("client not connected") + } + + result, err := c.client.Request("tools.list", listToolsRequest{Model: model}) + if err != nil { + return nil, err + } + + var response listToolsResponse + if err := json.Unmarshal(result, &response); err != nil { + return nil, fmt.Errorf("failed to unmarshal tools response: %w", err) + } + + return response.Tools, nil +} + // verifyProtocolVersion verifies that the server's protocol version matches the SDK's expected version func (c *Client) verifyProtocolVersion(ctx context.Context) error { expectedVersion := GetSdkProtocolVersion() diff --git a/go/internal/e2e/client_test.go b/go/internal/e2e/client_test.go index d82b0926..86cf1429 100644 --- a/go/internal/e2e/client_test.go +++ b/go/internal/e2e/client_test.go @@ -225,4 +225,37 @@ func TestClient(t *testing.T) { client.Stop() }) + + t.Run("should list tools", func(t *testing.T) { + client := copilot.NewClient(&copilot.ClientOptions{ + CLIPath: cliPath, + UseStdio: copilot.Bool(true), + }) + t.Cleanup(func() { client.ForceStop() }) + + if err := client.Start(t.Context()); err != nil { + t.Fatalf("Failed to start client: %v", err) + } + + tools, err := client.ListTools(t.Context(), "") + if err != nil { + t.Fatalf("Failed to list tools: %v", err) + } + + if len(tools) == 0 { + t.Error("Expected at least one tool") + } + + if len(tools) > 0 { + tool := tools[0] + if tool.Name == "" { + t.Error("Expected tool.Name to be non-empty") + } + if tool.Description == "" { + t.Error("Expected tool.Description to be non-empty") + } + } + + client.Stop() + }) } diff --git a/go/types.go b/go/types.go index a3b38ee3..8a557107 100644 --- a/go/types.go +++ b/go/types.go @@ -541,6 +541,15 @@ type ModelInfo struct { DefaultReasoningEffort string `json:"defaultReasoningEffort,omitempty"` } +// ToolInfo contains information about an available built-in tool +type ToolInfo struct { + Name string `json:"name"` + NamespacedName string `json:"namespacedName,omitempty"` + Description string `json:"description"` + Parameters map[string]interface{} `json:"parameters,omitempty"` + Instructions string `json:"instructions,omitempty"` +} + // SessionMetadata contains metadata about a session type SessionMetadata struct { SessionID string `json:"sessionId"` @@ -733,6 +742,16 @@ type listModelsResponse struct { Models []ModelInfo `json:"models"` } +// listToolsRequest is the request for tools.list +type listToolsRequest struct { + Model string `json:"model,omitempty"` +} + +// listToolsResponse is the response from tools.list +type listToolsResponse struct { + Tools []ToolInfo `json:"tools"` +} + // sessionGetMessagesRequest is the request for session.getMessages type sessionGetMessagesRequest struct { SessionID string `json:"sessionId"` diff --git a/nodejs/package-lock.json b/nodejs/package-lock.json index 266d994e..e42779f2 100644 --- a/nodejs/package-lock.json +++ b/nodejs/package-lock.json @@ -9,7 +9,7 @@ "version": "0.1.8", "license": "MIT", "dependencies": { - "@github/copilot": "^0.0.405", + "@github/copilot": "^0.0.407", "vscode-jsonrpc": "^8.2.1", "zod": "^4.3.6" }, @@ -662,26 +662,26 @@ } }, "node_modules/@github/copilot": { - "version": "0.0.405", - "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-0.0.405.tgz", - "integrity": "sha512-zp0kGSkoKrO4MTWefAxU5w2VEc02QnhPY3FmVxOeduh6ayDIz2V368mXxs46ThremdMnMyZPL1k989BW4NpOVw==", + "version": "0.0.407", + "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-0.0.407.tgz", + "integrity": "sha512-kvhzWf5F6gbIuw2aF1qd4ueaxPQmXqP/OThf2zb1UpiJliu5OndXK+4ASUS46MJGHYV0IkgGv7Ux155xcuf4nQ==", "license": "SEE LICENSE IN LICENSE.md", "bin": { "copilot": "npm-loader.js" }, "optionalDependencies": { - "@github/copilot-darwin-arm64": "0.0.405", - "@github/copilot-darwin-x64": "0.0.405", - "@github/copilot-linux-arm64": "0.0.405", - "@github/copilot-linux-x64": "0.0.405", - "@github/copilot-win32-arm64": "0.0.405", - "@github/copilot-win32-x64": "0.0.405" + "@github/copilot-darwin-arm64": "0.0.407", + "@github/copilot-darwin-x64": "0.0.407", + "@github/copilot-linux-arm64": "0.0.407", + "@github/copilot-linux-x64": "0.0.407", + "@github/copilot-win32-arm64": "0.0.407", + "@github/copilot-win32-x64": "0.0.407" } }, "node_modules/@github/copilot-darwin-arm64": { - "version": "0.0.405", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-0.0.405.tgz", - "integrity": "sha512-RVFpU1cEMqjR0rLpwLwbIfT7XzqqVoQX99G6nsj+WrHu3TIeCgfffyd2YShd4QwZYsMRoTfKB+rirQ+0G5Uiig==", + "version": "0.0.407", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-0.0.407.tgz", + "integrity": "sha512-KMqFyE+T8/PnM8VvpxQMVWV2aajfXX7BtOrpmpACOJANTJv6UptGrrfAygsAB/82+sKubPj4OBGwiGjb+AT4Rw==", "cpu": [ "arm64" ], @@ -695,9 +695,9 @@ } }, "node_modules/@github/copilot-darwin-x64": { - "version": "0.0.405", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-0.0.405.tgz", - "integrity": "sha512-Xj2FyPzpZlfqPTuMrXtPNEijSmm2ivHvyMWgy5Ijv7Slabxe+2s3WXDaokE3SQHodK6M0Yle2yrx9kxiwWA+qw==", + "version": "0.0.407", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-0.0.407.tgz", + "integrity": "sha512-BFNaCwHB07hCPHCPKZidCZkiRc+/smuk1wtl9MUF8oHWIf/hYWy4Y5m4bA4NDEaKKwlvsChY0D4XalU90bMVLQ==", "cpu": [ "x64" ], @@ -711,9 +711,9 @@ } }, "node_modules/@github/copilot-linux-arm64": { - "version": "0.0.405", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-0.0.405.tgz", - "integrity": "sha512-16Wiq8EYB6ghwqZdYytnNkcCN4sT3jyt9XkjfMxI5DDdjLuPc8wbj5VV5pw8S6lZvBL4eAwXGE3+fPqXKxH6GQ==", + "version": "0.0.407", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-0.0.407.tgz", + "integrity": "sha512-dpLxNr2gFe68SYc2SQ9Vdn5sRDIuZbY2Zg3zykuFTuF1lp3HGJaeiCCoZ906Q+Ly+T0L7UniiibCEONCTei0Tw==", "cpu": [ "arm64" ], @@ -727,9 +727,9 @@ } }, "node_modules/@github/copilot-linux-x64": { - "version": "0.0.405", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-0.0.405.tgz", - "integrity": "sha512-HXpg7p235//pAuCvcL9m2EeIrL/K6OUEkFeHF3BFHzqUJR4a69gKLsxtUg0ZctypHqo2SehGCRAyVippTVlTyg==", + "version": "0.0.407", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-0.0.407.tgz", + "integrity": "sha512-PsHKKjd5ovNiFRro7e4IlCGJTNQZRIcPhojwL22m5rvSRCXsTZoyvNk2kVW0fhcnGlTjRik9ebjsz7mzKOwfrg==", "cpu": [ "x64" ], @@ -743,9 +743,9 @@ } }, "node_modules/@github/copilot-win32-arm64": { - "version": "0.0.405", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-0.0.405.tgz", - "integrity": "sha512-4JCUMiRjP7zB3j1XpEtJq7b7cxTzuwDJ9o76jayAL8HL9NhqKZ6Ys6uxhDA6f/l0N2GVD1TEICxsnPgadz6srg==", + "version": "0.0.407", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-0.0.407.tgz", + "integrity": "sha512-j8b9rYv04POb3V4pGmF/28qLv7p0Fd2eIP2r/mGrx1tuI7lfQ6w3DyAwvZcm1VCmybqkW7hLvg5y403MwxD7kw==", "cpu": [ "arm64" ], @@ -759,9 +759,9 @@ } }, "node_modules/@github/copilot-win32-x64": { - "version": "0.0.405", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-0.0.405.tgz", - "integrity": "sha512-uHoJ9N8kZbTLbzgqBE1szHwLElv2f+P2OWlqmRSawQhwPl0s7u55dka7mZYvj2ZoNvIyb0OyShCO56OpmCcy/w==", + "version": "0.0.407", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-0.0.407.tgz", + "integrity": "sha512-2u8ai+M6Bvx14RRateSagZILiMEfXY9qskjeqIW7C4Lr3F3egZpPssYZ3HfBAot8Zao86pzOv92ee2a/Hx8+zw==", "cpu": [ "x64" ], diff --git a/nodejs/package.json b/nodejs/package.json index b6e23f40..678f6f39 100644 --- a/nodejs/package.json +++ b/nodejs/package.json @@ -40,7 +40,7 @@ "author": "GitHub", "license": "MIT", "dependencies": { - "@github/copilot": "^0.0.405", + "@github/copilot": "^0.0.407", "vscode-jsonrpc": "^8.2.1", "zod": "^4.3.6" }, diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index af6260c9..8edba3d2 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -42,6 +42,7 @@ import type { ToolCallRequestPayload, ToolCallResponsePayload, ToolHandler, + ToolInfo, ToolResult, ToolResultObject, TypedSessionLifecycleHandler, @@ -721,6 +722,24 @@ export class CopilotClient { } } + /** + * List available built-in tools with their metadata. + * + * Returns the list of tools available in the runtime, optionally filtered + * by model-specific overrides when a model ID is provided. + * + * @param model - Optional model ID to get model-specific tool overrides + */ + async listTools(model?: string): Promise { + if (!this.connection) { + throw new Error("Client not connected"); + } + + const result = await this.connection.sendRequest("tools.list", { model }); + const response = result as { tools: ToolInfo[] }; + return response.tools; + } + /** * Verify that the server's protocol version matches the SDK's expected version */ diff --git a/nodejs/src/index.ts b/nodejs/src/index.ts index 4f9fcbf6..dbce73dd 100644 --- a/nodejs/src/index.ts +++ b/nodejs/src/index.ts @@ -45,6 +45,7 @@ export type { SystemMessageReplaceConfig, Tool, ToolHandler, + ToolInfo, ToolInvocation, ToolResultObject, TypedSessionEventHandler, diff --git a/nodejs/src/types.ts b/nodejs/src/types.ts index ffb96801..8fda5bf5 100644 --- a/nodejs/src/types.ts +++ b/nodejs/src/types.ts @@ -961,6 +961,26 @@ export interface ModelInfo { defaultReasoningEffort?: ReasoningEffort; } +// ============================================================================ +// Tool Info Types (for tools.list) +// ============================================================================ + +/** + * Information about an available built-in tool + */ +export interface ToolInfo { + /** Tool identifier (e.g., "bash", "grep", "str_replace_editor") */ + name: string; + /** Optional namespaced name for declarative filtering (e.g., "playwright/navigate" for MCP tools) */ + namespacedName?: string; + /** Description of what the tool does */ + description: string; + /** JSON Schema for the tool's input parameters */ + parameters?: Record; + /** Optional instructions for how to use this tool effectively */ + instructions?: string; +} + // ============================================================================ // Session Lifecycle Types (for TUI+server mode) // ============================================================================ diff --git a/nodejs/test/e2e/client.test.ts b/nodejs/test/e2e/client.test.ts index 526e9509..daebd1a2 100644 --- a/nodejs/test/e2e/client.test.ts +++ b/nodejs/test/e2e/client.test.ts @@ -132,4 +132,24 @@ describe("Client", () => { await client.stop(); }); + + it("should list tools", async () => { + const client = new CopilotClient({ useStdio: true }); + onTestFinishedForceStop(client); + + await client.start(); + + const tools = await client.listTools(); + expect(Array.isArray(tools)).toBe(true); + expect(tools.length).toBeGreaterThan(0); + if (tools.length > 0) { + const tool = tools[0]; + expect(tool.name).toBeDefined(); + expect(tool.name).not.toBe(""); + expect(tool.description).toBeDefined(); + expect(tool.description).not.toBe(""); + } + + await client.stop(); + }); }); diff --git a/python/copilot/__init__.py b/python/copilot/__init__.py index 90a05563..6aa93105 100644 --- a/python/copilot/__init__.py +++ b/python/copilot/__init__.py @@ -33,6 +33,7 @@ StopError, Tool, ToolHandler, + ToolInfo, ToolInvocation, ToolResult, ) @@ -67,6 +68,7 @@ "StopError", "Tool", "ToolHandler", + "ToolInfo", "ToolInvocation", "ToolResult", "define_tool", diff --git a/python/copilot/client.py b/python/copilot/client.py index 85b72897..f599e50c 100644 --- a/python/copilot/client.py +++ b/python/copilot/client.py @@ -44,6 +44,7 @@ SessionMetadata, StopError, ToolHandler, + ToolInfo, ToolInvocation, ToolResult, ) @@ -837,6 +838,34 @@ async def list_models(self) -> list["ModelInfo"]: return list(models) # Return a copy to prevent cache mutation + async def list_tools(self, model: str | None = None) -> list["ToolInfo"]: + """ + List available built-in tools with their metadata. + + Returns the list of tools available in the runtime, optionally filtered + by model-specific overrides when a model ID is provided. + + Args: + model: Optional model ID to get model-specific tool overrides. + + Returns: + A list of ToolInfo objects with tool details. + + Raises: + RuntimeError: If the client is not connected. + + Example: + >>> tools = await client.list_tools() + >>> for tool in tools: + ... print(f"{tool.name}: {tool.description}") + """ + if not self._client: + raise RuntimeError("Client not connected") + + response = await self._client.request("tools.list", {"model": model}) + tools_data = response.get("tools", []) + return [ToolInfo.from_dict(tool) for tool in tools_data] + async def list_sessions(self) -> list["SessionMetadata"]: """ List all available sessions known to the server. diff --git a/python/copilot/types.py b/python/copilot/types.py index 3cecbe64..89ac59de 100644 --- a/python/copilot/types.py +++ b/python/copilot/types.py @@ -918,6 +918,49 @@ def to_dict(self) -> dict: return result +@dataclass +class ToolInfo: + """Information about an available built-in tool""" + + name: str # Tool identifier (e.g., "bash", "grep", "str_replace_editor") + description: str # Description of what the tool does + namespaced_name: str | None = None # Optional namespaced name for filtering + parameters: dict | None = None # JSON Schema for the tool's input parameters + instructions: str | None = None # Optional instructions for how to use this tool + + @staticmethod + def from_dict(obj: Any) -> ToolInfo: + assert isinstance(obj, dict) + name = obj.get("name") + description = obj.get("description") + if name is None or description is None: + raise ValueError( + f"Missing required fields in ToolInfo: name={name}, description={description}" + ) + namespaced_name = obj.get("namespacedName") + parameters = obj.get("parameters") + instructions = obj.get("instructions") + return ToolInfo( + name=str(name), + description=str(description), + namespaced_name=namespaced_name, + parameters=parameters, + instructions=instructions, + ) + + def to_dict(self) -> dict: + result: dict = {} + result["name"] = self.name + result["description"] = self.description + if self.namespaced_name is not None: + result["namespacedName"] = self.namespaced_name + if self.parameters is not None: + result["parameters"] = self.parameters + if self.instructions is not None: + result["instructions"] = self.instructions + return result + + @dataclass class SessionMetadata: """Metadata about a session""" diff --git a/python/e2e/test_client.py b/python/e2e/test_client.py index aeaddbd9..529318c9 100644 --- a/python/e2e/test_client.py +++ b/python/e2e/test_client.py @@ -179,3 +179,24 @@ async def test_should_cache_models_list(self): await client.stop() finally: await client.force_stop() + + @pytest.mark.asyncio + async def test_should_list_tools(self): + client = CopilotClient({"cli_path": CLI_PATH, "use_stdio": True}) + + try: + await client.start() + + tools = await client.list_tools() + assert isinstance(tools, list) + assert len(tools) > 0 + if len(tools) > 0: + tool = tools[0] + assert hasattr(tool, "name") + assert tool.name != "" + assert hasattr(tool, "description") + assert tool.description != "" + + await client.stop() + finally: + await client.force_stop()