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()