From bf343943fe4681a1a6c5f76455e55c4e63d57e59 Mon Sep 17 00:00:00 2001 From: Belal Taher Date: Sat, 27 Jun 2026 23:37:38 -0400 Subject: [PATCH 1/3] Add opaque _meta passthrough to SDK tool definitions Add an optional `_meta` bag to the `Tool` definition (and `defineTool`) and forward it verbatim over the session.create/resume RPC. This lets hosts attach namespaced, opaque metadata to tools without expanding the typed public contract; unknown keys are round-tripped untouched. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- nodejs/src/client.ts | 2 ++ nodejs/src/types.ts | 10 ++++++++++ 2 files changed, 12 insertions(+) diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index 53686a6ca..7ba6d8ed5 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -1391,6 +1391,7 @@ export class CopilotClient { overridesBuiltInTool: tool.overridesBuiltInTool, skipPermission: tool.skipPermission, defer: tool.defer, + _meta: tool._meta, })), canvases: config.canvases?.map((canvas) => canvas.declaration), requestCanvasRenderer: config.requestCanvasRenderer, @@ -1591,6 +1592,7 @@ export class CopilotClient { overridesBuiltInTool: tool.overridesBuiltInTool, skipPermission: tool.skipPermission, defer: tool.defer, + _meta: tool._meta, })), canvases: config.canvases?.map((canvas) => canvas.declaration), requestCanvasRenderer: config.requestCanvasRenderer, diff --git a/nodejs/src/types.ts b/nodejs/src/types.ts index e354bd821..9777261a9 100644 --- a/nodejs/src/types.ts +++ b/nodejs/src/types.ts @@ -560,6 +560,15 @@ export interface Tool { * Optional; defaults to `"auto"`. */ defer?: "auto" | "never"; + /** + * Opaque, host-defined metadata associated with the tool definition. + * + * Keys are namespaced and are not part of the stable public API. The SDK + * does not interpret these values; it forwards them verbatim to the runtime, + * which may recognize specific namespaced keys to inform host-specific + * behavior. Unknown keys are preserved and round-tripped untouched. + */ + _meta?: Record; } /** @@ -575,6 +584,7 @@ export function defineTool( overridesBuiltInTool?: boolean; skipPermission?: boolean; defer?: "auto" | "never"; + _meta?: Record; } ): Tool { return { name, ...config }; From d3f999b1159d5d5e6a1ce8c0468e7b50836763d7 Mon Sep 17 00:00:00 2001 From: Belal Taher Date: Tue, 30 Jun 2026 21:46:35 -0400 Subject: [PATCH 2/3] Add tests for tool _meta wire passthrough Assert the opaque `_meta` bag on a tool definition is forwarded verbatim in session.create and session.resume payloads, and omitted when unset. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- nodejs/test/client.test.ts | 63 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/nodejs/test/client.test.ts b/nodejs/test/client.test.ts index 96d7da30c..fc49a9823 100644 --- a/nodejs/test/client.test.ts +++ b/nodejs/test/client.test.ts @@ -188,6 +188,69 @@ describe("CopilotClient", () => { expect(resumePayload.contextTier).toBe("default"); }); + it("forwards tool _meta verbatim in session.create and session.resume", async () => { + const client = new CopilotClient(); + await client.start(); + onTestFinished(() => client.forceStop()); + + const spy = vi + .spyOn((client as any).connection!, "sendRequest") + .mockImplementation(async (method: string, params: any) => { + if (method === "session.create") return { sessionId: params.sessionId }; + if (method === "session.resume") return { sessionId: params.sessionId }; + throw new Error(`Unexpected method: ${method}`); + }); + + const meta = { "github.com/copilot:safeForTelemetry": { name: true, inputsNames: false } }; + const tool = { + name: "my_tool", + description: "a tool", + parameters: { type: "object", properties: {} }, + _meta: meta, + }; + + const session = await client.createSession({ + onPermissionRequest: approveAll, + tools: [tool], + }); + await client.resumeSession(session.sessionId, { + onPermissionRequest: approveAll, + tools: [tool], + }); + + const createPayload = spy.mock.calls.find( + ([method]) => method === "session.create" + )![1] as any; + const resumePayload = spy.mock.calls.find( + ([method]) => method === "session.resume" + )![1] as any; + expect(createPayload.tools[0]._meta).toEqual(meta); + expect(resumePayload.tools[0]._meta).toEqual(meta); + }); + + it("omits tool _meta from session.create when unset", async () => { + const client = new CopilotClient(); + await client.start(); + onTestFinished(() => client.forceStop()); + + const spy = vi + .spyOn((client as any).connection!, "sendRequest") + .mockImplementation(async (method: string, params: any) => { + if (method === "session.create") return { sessionId: params.sessionId }; + throw new Error(`Unexpected method: ${method}`); + }); + + await client.createSession({ + onPermissionRequest: approveAll, + tools: [{ name: "my_tool", description: "a tool" }], + }); + + const createPayload = spy.mock.calls.find( + ([method]) => method === "session.create" + )![1] as any; + expect(createPayload.tools[0]._meta).toBeUndefined(); + }); + it("forwards expAssignments in session.create and session.resume", async () => { const client = new CopilotClient(); await client.start(); From 500bb3d3d7fa0244673d318783ef9d233cacd5ab Mon Sep 17 00:00:00 2001 From: Belal Taher Date: Mon, 6 Jul 2026 15:08:26 -0400 Subject: [PATCH 3/3] Rename tool _meta to metadata and add across all SDK languages Address review feedback: drop the borrowed MCP _meta naming in favor of a plain, opaque metadata bag on tool definitions, and provide parity across all SDK languages. - nodejs: rename Tool._meta -> Tool.metadata (type + defineTool + wire). - python: add Tool.metadata + define_tool param, forward on create/resume. - go: add Tool.Metadata (json:"metadata"). - rust: add Tool.metadata + with_metadata builder. - java: add ToolDefinition.metadata record component + createWithMetadata. - dotnet: add CopilotToolOptions.Metadata + wire ToolDefinition.Metadata. Keys are namespaced and not part of the stable public API; the runtime may recognize specific keys to inform host-specific behavior. Tests added per language for wire/serialization forwarding and omission when unset. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- dotnet/src/Client.cs | 7 ++- dotnet/src/CopilotTool.cs | 20 +++++++- dotnet/test/Unit/CopilotToolTests.cs | 28 +++++++++++ go/client_test.go | 47 +++++++++++++++++++ go/types.go | 6 +++ .../github/copilot/rpc/ToolDefinition.java | 41 ++++++++++++++-- .../copilot/tool/CopilotToolProcessor.java | 3 +- .../github/copilot/ToolDefinitionTest.java | 23 +++++++++ .../ErgonomicTestTools$$CopilotToolMeta.java | 4 +- .../ArgCoercionTools$$CopilotToolMeta.java | 2 +- .../DateTimeTools$$CopilotToolMeta.java | 2 +- .../DefaultValueTools$$CopilotToolMeta.java | 2 +- .../MultiReturnTools$$CopilotToolMeta.java | 6 +-- .../OptionalParamTools$$CopilotToolMeta.java | 8 ++-- .../OverrideTools$$CopilotToolMeta.java | 2 +- .../SimpleTools$$CopilotToolMeta.java | 4 +- .../StaticTools$$CopilotToolMeta.java | 2 +- nodejs/src/client.ts | 4 +- nodejs/src/types.ts | 4 +- nodejs/test/client.test.ts | 16 ++++--- python/copilot/client.py | 4 ++ python/copilot/tools.py | 11 +++++ python/test_client.py | 41 ++++++++++++++++ rust/src/types.rs | 42 +++++++++++++++++ 24 files changed, 293 insertions(+), 36 deletions(-) diff --git a/dotnet/src/Client.cs b/dotnet/src/Client.cs index a67eb9681..72e24f5a5 100644 --- a/dotnet/src/Client.cs +++ b/dotnet/src/Client.cs @@ -2485,17 +2485,20 @@ internal record ToolDefinition( JsonElement Parameters, /* JSON schema */ bool? OverridesBuiltInTool = null, bool? SkipPermission = null, - CopilotToolDefer? Defer = null) + CopilotToolDefer? Defer = null, + [property: JsonPropertyName("metadata")] IDictionary? Metadata = null) { public static ToolDefinition FromAIFunction(AIFunctionDeclaration function) { var overrides = function.AdditionalProperties.TryGetValue(CopilotTool.OverridesBuiltInToolKey, out var val) && val is true; var skipPerm = function.AdditionalProperties.TryGetValue(CopilotTool.SkipPermissionKey, out var skipVal) && skipVal is true; var defer = function.AdditionalProperties.TryGetValue(CopilotTool.DeferKey, out var deferVal) && deferVal is CopilotToolDefer d ? d : (CopilotToolDefer?)null; + var metadata = function.AdditionalProperties.TryGetValue(CopilotTool.MetadataKey, out var metaVal) && metaVal is IDictionary m ? m : null; return new ToolDefinition(function.Name, function.Description, function.JsonSchema, overrides ? true : null, skipPerm ? true : null, - defer); + defer, + metadata); } } diff --git a/dotnet/src/CopilotTool.cs b/dotnet/src/CopilotTool.cs index d6dc0351e..5aab1da35 100644 --- a/dotnet/src/CopilotTool.cs +++ b/dotnet/src/CopilotTool.cs @@ -20,6 +20,9 @@ public static class CopilotTool /// The key used in to carry the tool's deferral mode. internal const string DeferKey = "defer"; + /// The key used in to carry the tool's opaque host-defined metadata. + internal const string MetadataKey = "metadata"; + /// /// Defines a tool for use in a . /// @@ -87,7 +90,7 @@ static void ApplyToolInvocationBinding(AIFunctionFactoryOptions factoryOptions) static void ApplyToolOptions(AIFunctionFactoryOptions factoryOptions, CopilotToolOptions? toolOptions) { - if (toolOptions is not null && (toolOptions.OverridesBuiltInTool || toolOptions.SkipPermission || toolOptions.Defer is not null)) + if (toolOptions is not null && (toolOptions.OverridesBuiltInTool || toolOptions.SkipPermission || toolOptions.Defer is not null || toolOptions.Metadata is not null)) { Dictionary additionalProperties = new(StringComparer.Ordinal); if (factoryOptions.AdditionalProperties is not null) @@ -113,6 +116,11 @@ static void ApplyToolOptions(AIFunctionFactoryOptions factoryOptions, CopilotToo additionalProperties[DeferKey] = defer; } + if (toolOptions.Metadata is { } metadata) + { + additionalProperties[MetadataKey] = metadata; + } + factoryOptions.AdditionalProperties = additionalProperties; } } @@ -151,6 +159,16 @@ public sealed class CopilotToolOptions /// SDK forwards it to the CLI as the tool's defer mode. Defaults to "auto". /// public CopilotToolDefer? Defer { get; set; } + + /// + /// Gets or sets opaque, host-defined metadata associated with the tool definition. + /// + /// + /// Keys are namespaced and not part of the stable public API. When set, the SDK forwards + /// the metadata verbatim to the CLI as the tool's metadata object, which the runtime + /// may recognize to inform host-specific behavior. Unknown keys are preserved. + /// + public IDictionary? Metadata { get; set; } } /// diff --git a/dotnet/test/Unit/CopilotToolTests.cs b/dotnet/test/Unit/CopilotToolTests.cs index 9c9e2a93b..900b25d3e 100644 --- a/dotnet/test/Unit/CopilotToolTests.cs +++ b/dotnet/test/Unit/CopilotToolTests.cs @@ -43,6 +43,34 @@ public void DefineTool_Omits_Copilot_Metadata_When_Flags_Are_False() Assert.False(function.AdditionalProperties.ContainsKey("defer")); } + [Fact] + public void DefineTool_Sets_Metadata_In_Additional_Properties() + { + var metadata = new Dictionary + { + ["github.com/copilot:safeForTelemetry"] = new Dictionary + { + ["name"] = true, + ["inputsNames"] = false + } + }; + + var function = CopilotTool.DefineTool( + ReturnsOk, + new CopilotToolOptions { Metadata = metadata }); + + Assert.True(function.AdditionalProperties.TryGetValue("metadata", out var value)); + Assert.Same(metadata, value); + } + + [Fact] + public void DefineTool_Omits_Metadata_When_Unset() + { + var function = CopilotTool.DefineTool(ReturnsOk); + + Assert.False(function.AdditionalProperties.ContainsKey("metadata")); + } + [Fact] public void DefineTool_Accepts_Lambda_Handlers_Without_Casts() { diff --git a/go/client_test.go b/go/client_test.go index d59c71c6f..4cf80c87f 100644 --- a/go/client_test.go +++ b/go/client_test.go @@ -1080,6 +1080,53 @@ func TestToolDefer(t *testing.T) { }) } +func TestToolMetadata(t *testing.T) { + t.Run("Metadata is serialized in tool definition", func(t *testing.T) { + tool := Tool{ + Name: "my_tool", + Description: "A custom tool", + Metadata: map[string]any{ + "github.com/copilot:safeForTelemetry": map[string]any{"name": true, "inputsNames": false}, + }, + Handler: func(_ ToolInvocation) (ToolResult, error) { return ToolResult{}, nil }, + } + data, err := json.Marshal(tool) + if err != nil { + t.Fatalf("failed to marshal: %v", err) + } + var m map[string]any + if err := json.Unmarshal(data, &m); err != nil { + t.Fatalf("failed to unmarshal: %v", err) + } + meta, ok := m["metadata"].(map[string]any) + if !ok { + t.Fatalf("expected metadata object, got %v", m) + } + if _, ok := meta["github.com/copilot:safeForTelemetry"]; !ok { + t.Errorf("expected namespaced key preserved, got %v", meta) + } + }) + + t.Run("Metadata omitted when unset", func(t *testing.T) { + tool := Tool{ + Name: "custom_tool", + Description: "A custom tool", + Handler: func(_ ToolInvocation) (ToolResult, error) { return ToolResult{}, nil }, + } + data, err := json.Marshal(tool) + if err != nil { + t.Fatalf("failed to marshal: %v", err) + } + var m map[string]any + if err := json.Unmarshal(data, &m); err != nil { + t.Fatalf("failed to unmarshal: %v", err) + } + if _, ok := m["metadata"]; ok { + t.Errorf("expected metadata to be omitted, got %v", m) + } + }) +} + func TestClient_CreateSession_AllowsMissingPermissionHandler(t *testing.T) { t.Run("accepts nil config before connection validation", func(t *testing.T) { client := NewClient(&ClientOptions{Connection: StdioConnection{Path: "/__nonexistent_copilot_binary__"}}) diff --git a/go/types.go b/go/types.go index 8a7df3c46..a5850146f 100644 --- a/go/types.go +++ b/go/types.go @@ -1181,6 +1181,12 @@ type Tool struct { // Defer controls whether the tool may be deferred (loaded lazily via tool // search) rather than always pre-loaded. When empty, the runtime decides. Defer ToolDefer `json:"defer,omitempty"` + // Metadata is opaque, host-defined metadata associated with the tool + // definition. Keys are namespaced and not part of the stable public API; + // the SDK forwards them verbatim to the runtime, which may recognize + // specific keys to inform host-specific behavior. Unknown keys are + // preserved and round-tripped untouched. + Metadata map[string]any `json:"metadata,omitempty"` // Handler is optional. When nil, the SDK exposes the tool declaration but does // not automatically invoke it. Handler ToolHandler `json:"-"` diff --git a/java/src/main/java/com/github/copilot/rpc/ToolDefinition.java b/java/src/main/java/com/github/copilot/rpc/ToolDefinition.java index b3fa2bc53..5081906a8 100644 --- a/java/src/main/java/com/github/copilot/rpc/ToolDefinition.java +++ b/java/src/main/java/com/github/copilot/rpc/ToolDefinition.java @@ -70,6 +70,10 @@ * controls whether the tool may be deferred (loaded lazily via tool * search) rather than always pre-loaded; {@code null} lets the * runtime decide + * @param metadata + * opaque, host-defined metadata forwarded verbatim to the runtime; + * keys are namespaced and not part of the stable public API; + * {@code null} when unset * @see SessionConfig#setTools(java.util.List) * @see ToolHandler * @since 1.0.0 @@ -78,7 +82,8 @@ public record ToolDefinition(@JsonProperty("name") String name, @JsonProperty("description") String description, @JsonProperty("parameters") Object parameters, @JsonIgnore ToolHandler handler, @JsonProperty("overridesBuiltInTool") Boolean overridesBuiltInTool, - @JsonProperty("skipPermission") Boolean skipPermission, @JsonProperty("defer") ToolDefer defer) { + @JsonProperty("skipPermission") Boolean skipPermission, @JsonProperty("defer") ToolDefer defer, + @JsonProperty("metadata") Map metadata) { /** * Creates a tool definition with a JSON schema for parameters. @@ -98,7 +103,7 @@ public record ToolDefinition(@JsonProperty("name") String name, @JsonProperty("d */ public static ToolDefinition create(String name, String description, Map schema, ToolHandler handler) { - return new ToolDefinition(name, description, schema, handler, null, null, null); + return new ToolDefinition(name, description, schema, handler, null, null, null, null); } /** @@ -122,7 +127,7 @@ public static ToolDefinition create(String name, String description, Map schema, ToolHandler handler) { - return new ToolDefinition(name, description, schema, handler, true, null, null); + return new ToolDefinition(name, description, schema, handler, true, null, null, null); } /** @@ -145,7 +150,7 @@ public static ToolDefinition createOverride(String name, String description, Map */ public static ToolDefinition createSkipPermission(String name, String description, Map schema, ToolHandler handler) { - return new ToolDefinition(name, description, schema, handler, null, true, null); + return new ToolDefinition(name, description, schema, handler, null, true, null, null); } /** @@ -171,7 +176,33 @@ public static ToolDefinition createSkipPermission(String name, String descriptio */ public static ToolDefinition createWithDefer(String name, String description, Map schema, ToolHandler handler, ToolDefer defer) { - return new ToolDefinition(name, description, schema, handler, null, null, defer); + return new ToolDefinition(name, description, schema, handler, null, null, defer, null); + } + + /** + * Creates a tool definition with opaque, host-defined metadata. + *

+ * Use this factory method to attach namespaced metadata that the SDK + * forwards verbatim to the runtime. The keys are not part of the stable + * public API; the runtime may recognize specific keys to inform + * host-specific behavior. + * + * @param name + * the unique name of the tool + * @param description + * a description of what the tool does + * @param schema + * the JSON Schema as a {@code Map} + * @param handler + * the handler function to execute when invoked + * @param metadata + * the opaque metadata map forwarded to the runtime + * @return a new tool definition with the metadata set + * @since 1.0.0 + */ + public static ToolDefinition createWithMetadata(String name, String description, Map schema, + ToolHandler handler, Map metadata) { + return new ToolDefinition(name, description, schema, handler, null, null, null, metadata); } /** diff --git a/java/src/main/java/com/github/copilot/tool/CopilotToolProcessor.java b/java/src/main/java/com/github/copilot/tool/CopilotToolProcessor.java index 08a16bf39..8aa5de705 100644 --- a/java/src/main/java/com/github/copilot/tool/CopilotToolProcessor.java +++ b/java/src/main/java/com/github/copilot/tool/CopilotToolProcessor.java @@ -257,7 +257,8 @@ private void writeToolDefinition(PrintWriter out, ExecutableElement method) { out.println(" },"); out.println(" " + overridesArg + ","); out.println(" " + skipPermArg + ","); - out.println(" " + deferArg); + out.println(" " + deferArg + ","); + out.println(" null"); out.print(" )"); } diff --git a/java/src/test/java/com/github/copilot/ToolDefinitionTest.java b/java/src/test/java/com/github/copilot/ToolDefinitionTest.java index 614e6ab4f..f24429352 100644 --- a/java/src/test/java/com/github/copilot/ToolDefinitionTest.java +++ b/java/src/test/java/com/github/copilot/ToolDefinitionTest.java @@ -59,4 +59,27 @@ void testDeferNeverIsSerialized() throws Exception { assertEquals("never", json.get("defer").asText()); } + + @Test + void testMetadataIsSerialized() throws Exception { + Map metadata = Map.of("github.com/copilot:safeForTelemetry", + Map.of("name", true, "inputsNames", false)); + ToolDefinition tool = ToolDefinition.createWithMetadata("my_tool", "A tool", schema(), + invocation -> CompletableFuture.completedFuture("ok"), metadata); + + ObjectNode json = (ObjectNode) MAPPER.readTree(MAPPER.writeValueAsString(tool)); + + assertTrue(json.has("metadata")); + assertTrue(json.get("metadata").has("github.com/copilot:safeForTelemetry")); + } + + @Test + void testMetadataOmittedWhenNull() throws Exception { + ToolDefinition tool = ToolDefinition.create("my_tool", "A tool", schema(), + invocation -> CompletableFuture.completedFuture("ok")); + + ObjectNode json = (ObjectNode) MAPPER.readTree(MAPPER.writeValueAsString(tool)); + + assertFalse(json.has("metadata")); + } } diff --git a/java/src/test/java/com/github/copilot/e2e/ErgonomicTestTools$$CopilotToolMeta.java b/java/src/test/java/com/github/copilot/e2e/ErgonomicTestTools$$CopilotToolMeta.java index 703a6b010..c9af12143 100644 --- a/java/src/test/java/com/github/copilot/e2e/ErgonomicTestTools$$CopilotToolMeta.java +++ b/java/src/test/java/com/github/copilot/e2e/ErgonomicTestTools$$CopilotToolMeta.java @@ -32,7 +32,7 @@ public List definitions(ErgonomicTestTools instance, ObjectMappe Map args = invocation.getArguments(); String phase = (String) args.get("phase"); return CompletableFuture.completedFuture(instance.setCurrentPhase(phase)); - }, null, null, null), + }, null, null, null, null), new ToolDefinition( "search_items", "Search for items by keyword", Map .of("type", "object", "properties", @@ -44,6 +44,6 @@ public List definitions(ErgonomicTestTools instance, ObjectMappe Map args = invocation.getArguments(); String keyword = (String) args.get("keyword"); return CompletableFuture.completedFuture(instance.searchItems(keyword)); - }, null, null, null)); + }, null, null, null, null)); } } diff --git a/java/src/test/java/com/github/copilot/rpc/fixtures/ArgCoercionTools$$CopilotToolMeta.java b/java/src/test/java/com/github/copilot/rpc/fixtures/ArgCoercionTools$$CopilotToolMeta.java index 882c0555f..5cc5ee87a 100644 --- a/java/src/test/java/com/github/copilot/rpc/fixtures/ArgCoercionTools$$CopilotToolMeta.java +++ b/java/src/test/java/com/github/copilot/rpc/fixtures/ArgCoercionTools$$CopilotToolMeta.java @@ -45,6 +45,6 @@ public List definitions(ArgCoercionTools instance, ObjectMapper boolean flag = (Boolean) args.get("flag"); ArgCoercionTools.Color color = ArgCoercionTools.Color.valueOf((String) args.get("color")); return CompletableFuture.completedFuture(instance.mixedArgs(text, count, flag, color)); - }, null, null, null)); + }, null, null, null, null)); } } diff --git a/java/src/test/java/com/github/copilot/rpc/fixtures/DateTimeTools$$CopilotToolMeta.java b/java/src/test/java/com/github/copilot/rpc/fixtures/DateTimeTools$$CopilotToolMeta.java index 700133650..0c2b1f07e 100644 --- a/java/src/test/java/com/github/copilot/rpc/fixtures/DateTimeTools$$CopilotToolMeta.java +++ b/java/src/test/java/com/github/copilot/rpc/fixtures/DateTimeTools$$CopilotToolMeta.java @@ -33,6 +33,6 @@ public List definitions(DateTimeTools instance, ObjectMapper map Map args = invocation.getArguments(); LocalDateTime when = mapper.convertValue(args.get("when"), LocalDateTime.class); return CompletableFuture.completedFuture(instance.scheduleEvent(when)); - }, null, null, null)); + }, null, null, null, null)); } } diff --git a/java/src/test/java/com/github/copilot/rpc/fixtures/DefaultValueTools$$CopilotToolMeta.java b/java/src/test/java/com/github/copilot/rpc/fixtures/DefaultValueTools$$CopilotToolMeta.java index ee5369b84..6cef2e03a 100644 --- a/java/src/test/java/com/github/copilot/rpc/fixtures/DefaultValueTools$$CopilotToolMeta.java +++ b/java/src/test/java/com/github/copilot/rpc/fixtures/DefaultValueTools$$CopilotToolMeta.java @@ -40,6 +40,6 @@ public List definitions(DefaultValueTools instance, ObjectMapper Object countRaw = args.containsKey("count") ? args.get("count") : 42; int count = ((Number) countRaw).intValue(); return CompletableFuture.completedFuture(instance.withDefault(label, count)); - }, null, null, null)); + }, null, null, null, null)); } } diff --git a/java/src/test/java/com/github/copilot/rpc/fixtures/MultiReturnTools$$CopilotToolMeta.java b/java/src/test/java/com/github/copilot/rpc/fixtures/MultiReturnTools$$CopilotToolMeta.java index e1ac5c38d..571db8e7c 100644 --- a/java/src/test/java/com/github/copilot/rpc/fixtures/MultiReturnTools$$CopilotToolMeta.java +++ b/java/src/test/java/com/github/copilot/rpc/fixtures/MultiReturnTools$$CopilotToolMeta.java @@ -16,14 +16,14 @@ public List definitions(MultiReturnTools instance, ObjectMapper return List.of(new ToolDefinition("string_method", "Returns a string", Map.of("type", "object", "properties", Map.of(), "required", List.of()), invocation -> { return CompletableFuture.completedFuture(instance.stringMethod()); - }, null, null, null), new ToolDefinition("void_method", "Void method", + }, null, null, null, null), new ToolDefinition("void_method", "Void method", Map.of("type", "object", "properties", Map.of(), "required", List.of()), invocation -> { instance.voidMethod(); return CompletableFuture.completedFuture("Success"); - }, null, null, null), + }, null, null, null, null), new ToolDefinition("async_method", "Async method", Map.of("type", "object", "properties", Map.of(), "required", List.of()), invocation -> { return instance.asyncMethod().thenApply(r -> (Object) r); - }, null, null, null)); + }, null, null, null, null)); } } diff --git a/java/src/test/java/com/github/copilot/rpc/fixtures/OptionalParamTools$$CopilotToolMeta.java b/java/src/test/java/com/github/copilot/rpc/fixtures/OptionalParamTools$$CopilotToolMeta.java index df6c39fd6..75fde6bb3 100644 --- a/java/src/test/java/com/github/copilot/rpc/fixtures/OptionalParamTools$$CopilotToolMeta.java +++ b/java/src/test/java/com/github/copilot/rpc/fixtures/OptionalParamTools$$CopilotToolMeta.java @@ -39,7 +39,7 @@ public List definitions(OptionalParamTools instance, ObjectMappe Object titleRaw = args.get("title"); Optional title = titleRaw != null ? Optional.of((String) titleRaw) : Optional.empty(); return CompletableFuture.completedFuture(instance.greetWithTitle(name, title)); - }, null, null, null), + }, null, null, null, null), new ToolDefinition("multiply", "Multiply with optional factor", Map.of("type", "object", "properties", Map.ofEntries( @@ -58,7 +58,7 @@ public List definitions(OptionalParamTools instance, ObjectMappe ? OptionalInt.of(((Number) factorRaw).intValue()) : OptionalInt.empty(); return CompletableFuture.completedFuture(instance.multiply(base, factor)); - }, null, null, null), + }, null, null, null, null), new ToolDefinition("scale", "Scale with optional ratio", Map.of("type", "object", "properties", Map.ofEntries( @@ -77,7 +77,7 @@ public List definitions(OptionalParamTools instance, ObjectMappe ? OptionalDouble.of(((Number) ratioRaw).doubleValue()) : OptionalDouble.empty(); return CompletableFuture.completedFuture(instance.scale(value, ratio)); - }, null, null, null), + }, null, null, null, null), new ToolDefinition("offset", "Offset with optional delta", Map.of("type", "object", "properties", Map.ofEntries( @@ -96,6 +96,6 @@ public List definitions(OptionalParamTools instance, ObjectMappe ? OptionalLong.of(((Number) deltaRaw).longValue()) : OptionalLong.empty(); return CompletableFuture.completedFuture(instance.offset(base, delta)); - }, null, null, null)); + }, null, null, null, null)); } } diff --git a/java/src/test/java/com/github/copilot/rpc/fixtures/OverrideTools$$CopilotToolMeta.java b/java/src/test/java/com/github/copilot/rpc/fixtures/OverrideTools$$CopilotToolMeta.java index 127dc922b..2d37204f8 100644 --- a/java/src/test/java/com/github/copilot/rpc/fixtures/OverrideTools$$CopilotToolMeta.java +++ b/java/src/test/java/com/github/copilot/rpc/fixtures/OverrideTools$$CopilotToolMeta.java @@ -34,6 +34,6 @@ public List definitions(OverrideTools instance, ObjectMapper map Map args = invocation.getArguments(); String pattern = (String) args.get("pattern"); return CompletableFuture.completedFuture(instance.customGrep(pattern)); - }, Boolean.TRUE, null, null)); + }, Boolean.TRUE, null, null, null)); } } diff --git a/java/src/test/java/com/github/copilot/rpc/fixtures/SimpleTools$$CopilotToolMeta.java b/java/src/test/java/com/github/copilot/rpc/fixtures/SimpleTools$$CopilotToolMeta.java index 0b52bd1ef..4e84cb094 100644 --- a/java/src/test/java/com/github/copilot/rpc/fixtures/SimpleTools$$CopilotToolMeta.java +++ b/java/src/test/java/com/github/copilot/rpc/fixtures/SimpleTools$$CopilotToolMeta.java @@ -30,7 +30,7 @@ public List definitions(SimpleTools instance, ObjectMapper mappe Map args = invocation.getArguments(); String name = (String) args.get("name"); return CompletableFuture.completedFuture(instance.greetUser(name)); - }, null, null, null), + }, null, null, null, null), new ToolDefinition("add_numbers", "Adds two numbers together", Map.of("type", "object", "properties", Map.ofEntries( @@ -46,6 +46,6 @@ public List definitions(SimpleTools instance, ObjectMapper mappe int a = ((Number) args.get("a")).intValue(); int b = ((Number) args.get("b")).intValue(); return CompletableFuture.completedFuture(instance.addNumbers(a, b)); - }, null, null, null)); + }, null, null, null, null)); } } diff --git a/java/src/test/java/com/github/copilot/rpc/fixtures/StaticTools$$CopilotToolMeta.java b/java/src/test/java/com/github/copilot/rpc/fixtures/StaticTools$$CopilotToolMeta.java index 842547b68..a0c6e6685 100644 --- a/java/src/test/java/com/github/copilot/rpc/fixtures/StaticTools$$CopilotToolMeta.java +++ b/java/src/test/java/com/github/copilot/rpc/fixtures/StaticTools$$CopilotToolMeta.java @@ -32,6 +32,6 @@ public List definitions(StaticTools instance, ObjectMapper mappe // Mimics what the processor now generates for static methods: // QualifiedClassName.method(...) instead of instance.method(...) return CompletableFuture.completedFuture(StaticTools.greet(name)); - }, null, null, null)); + }, null, null, null, null)); } } diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index 7ba6d8ed5..82181062a 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -1391,7 +1391,7 @@ export class CopilotClient { overridesBuiltInTool: tool.overridesBuiltInTool, skipPermission: tool.skipPermission, defer: tool.defer, - _meta: tool._meta, + metadata: tool.metadata, })), canvases: config.canvases?.map((canvas) => canvas.declaration), requestCanvasRenderer: config.requestCanvasRenderer, @@ -1592,7 +1592,7 @@ export class CopilotClient { overridesBuiltInTool: tool.overridesBuiltInTool, skipPermission: tool.skipPermission, defer: tool.defer, - _meta: tool._meta, + metadata: tool.metadata, })), canvases: config.canvases?.map((canvas) => canvas.declaration), requestCanvasRenderer: config.requestCanvasRenderer, diff --git a/nodejs/src/types.ts b/nodejs/src/types.ts index 9777261a9..c3d08a410 100644 --- a/nodejs/src/types.ts +++ b/nodejs/src/types.ts @@ -568,7 +568,7 @@ export interface Tool { * which may recognize specific namespaced keys to inform host-specific * behavior. Unknown keys are preserved and round-tripped untouched. */ - _meta?: Record; + metadata?: Record; } /** @@ -584,7 +584,7 @@ export function defineTool( overridesBuiltInTool?: boolean; skipPermission?: boolean; defer?: "auto" | "never"; - _meta?: Record; + metadata?: Record; } ): Tool { return { name, ...config }; diff --git a/nodejs/test/client.test.ts b/nodejs/test/client.test.ts index fc49a9823..5020bc505 100644 --- a/nodejs/test/client.test.ts +++ b/nodejs/test/client.test.ts @@ -188,7 +188,7 @@ describe("CopilotClient", () => { expect(resumePayload.contextTier).toBe("default"); }); - it("forwards tool _meta verbatim in session.create and session.resume", async () => { + it("forwards tool metadata verbatim in session.create and session.resume", async () => { const client = new CopilotClient(); await client.start(); onTestFinished(() => client.forceStop()); @@ -201,12 +201,14 @@ describe("CopilotClient", () => { throw new Error(`Unexpected method: ${method}`); }); - const meta = { "github.com/copilot:safeForTelemetry": { name: true, inputsNames: false } }; + const metadata = { + "github.com/copilot:safeForTelemetry": { name: true, inputsNames: false }, + }; const tool = { name: "my_tool", description: "a tool", parameters: { type: "object", properties: {} }, - _meta: meta, + metadata, }; const session = await client.createSession({ @@ -224,11 +226,11 @@ describe("CopilotClient", () => { const resumePayload = spy.mock.calls.find( ([method]) => method === "session.resume" )![1] as any; - expect(createPayload.tools[0]._meta).toEqual(meta); - expect(resumePayload.tools[0]._meta).toEqual(meta); + expect(createPayload.tools[0].metadata).toEqual(metadata); + expect(resumePayload.tools[0].metadata).toEqual(metadata); }); - it("omits tool _meta from session.create when unset", async () => { + it("omits tool metadata from session.create when unset", async () => { const client = new CopilotClient(); await client.start(); onTestFinished(() => client.forceStop()); @@ -248,7 +250,7 @@ describe("CopilotClient", () => { const createPayload = spy.mock.calls.find( ([method]) => method === "session.create" )![1] as any; - expect(createPayload.tools[0]._meta).toBeUndefined(); + expect(createPayload.tools[0].metadata).toBeUndefined(); }); it("forwards expAssignments in session.create and session.resume", async () => { diff --git a/python/copilot/client.py b/python/copilot/client.py index c7d11d12b..aea30f652 100644 --- a/python/copilot/client.py +++ b/python/copilot/client.py @@ -1873,6 +1873,8 @@ async def create_session( definition["skipPermission"] = True if tool.defer is not None: definition["defer"] = tool.defer + if tool.metadata is not None: + definition["metadata"] = tool.metadata tool_defs.append(definition) # Empty-mode validation and normalization @@ -2499,6 +2501,8 @@ async def resume_session( definition["skipPermission"] = True if tool.defer is not None: definition["defer"] = tool.defer + if tool.metadata is not None: + definition["metadata"] = tool.metadata tool_defs.append(definition) # Empty-mode validation and normalization diff --git a/python/copilot/tools.py b/python/copilot/tools.py index a82a48b1e..6cbec0511 100644 --- a/python/copilot/tools.py +++ b/python/copilot/tools.py @@ -63,6 +63,7 @@ class Tool: overrides_built_in_tool: bool = False skip_permission: bool = False defer: Literal["auto", "never"] | None = None + metadata: dict[str, Any] | None = None T = TypeVar("T", bound=BaseModel) @@ -77,6 +78,7 @@ def define_tool( overrides_built_in_tool: bool = False, skip_permission: bool = False, defer: Literal["auto", "never"] | None = None, + metadata: dict[str, Any] | None = None, ) -> Callable[[Callable[..., Any]], Tool]: pass @@ -91,6 +93,7 @@ def define_tool( overrides_built_in_tool: bool = False, skip_permission: bool = False, defer: Literal["auto", "never"] | None = None, + metadata: dict[str, Any] | None = None, ) -> Tool: pass @@ -105,6 +108,7 @@ def define_tool( overrides_built_in_tool: bool = False, skip_permission: bool = False, defer: Literal["auto", "never"] | None = None, + metadata: dict[str, Any] | None = None, ) -> Tool: pass @@ -118,6 +122,7 @@ def define_tool( overrides_built_in_tool: bool = False, skip_permission: bool = False, defer: Literal["auto", "never"] | None = None, + metadata: dict[str, Any] | None = None, ) -> Tool | Callable[[Callable[[Any, ToolInvocation], Any]], Tool]: """ Define a tool with automatic JSON schema generation from Pydantic models. @@ -166,6 +171,10 @@ def lookup_issue(params: LookupIssueParams) -> str: rather than always pre-loaded. When "auto", the tool can be deferred and surfaced through tool search. When "never", the tool is always pre-loaded. Optional; defaults to "auto". + metadata: Opaque, host-defined metadata associated with the tool definition. + Keys are namespaced and not part of the stable public API; the SDK + forwards them verbatim to the runtime, which may recognize specific + keys to inform host-specific behavior. Unknown keys are preserved. Returns: A Tool instance @@ -246,6 +255,7 @@ async def wrapped_handler(invocation: ToolInvocation) -> ToolResult: overrides_built_in_tool=overrides_built_in_tool, skip_permission=skip_permission, defer=defer, + metadata=metadata, ) # If handler is provided, call decorator immediately @@ -265,6 +275,7 @@ async def wrapped_handler(invocation: ToolInvocation) -> ToolResult: overrides_built_in_tool=overrides_built_in_tool, skip_permission=skip_permission, defer=defer, + metadata=metadata, ) # Otherwise return decorator for @define_tool(...) usage diff --git a/python/test_client.py b/python/test_client.py index f3f46c4d8..16c6427c6 100644 --- a/python/test_client.py +++ b/python/test_client.py @@ -18,6 +18,7 @@ StdioRuntimeConnection, define_tool, ) +from copilot.tools import Tool from copilot.client import ( CloudSessionOptions, CloudSessionRepository, @@ -246,6 +247,46 @@ async def mock_request(method, params, **kwargs): finally: await client.force_stop() + @pytest.mark.asyncio + async def test_create_and_resume_session_forward_tool_metadata(self): + client = CopilotClient(connection=RuntimeConnection.for_stdio(path=CLI_PATH)) + await client.start() + try: + captured = {} + + async def mock_request(method, params, **kwargs): + captured[method] = params + if method in ("session.create", "session.resume"): + result = {"sessionId": params.get("sessionId") or "session-1"} + callback = kwargs.get("on_response_inline") + if callback is not None: + callback(result) + return result + return {} + + client._client.request = mock_request + metadata = {"github.com/copilot:safeForTelemetry": {"name": True, "inputsNames": False}} + tool = Tool(name="my_tool", description="a tool", metadata=metadata) + plain_tool = Tool(name="plain_tool", description="a tool") + + session = await client.create_session( + on_permission_request=PermissionHandler.approve_all, + tools=[tool, plain_tool], + ) + await client.resume_session( + session.session_id, + on_permission_request=PermissionHandler.approve_all, + tools=[tool], + ) + + create_tools = captured["session.create"]["tools"] + assert create_tools[0]["metadata"] == metadata + # Omitted when unset. + assert "metadata" not in create_tools[1] + assert captured["session.resume"]["tools"][0]["metadata"] == metadata + finally: + await client.force_stop() + @pytest.mark.asyncio async def test_create_and_resume_session_forward_capi_options(self): client = CopilotClient(connection=RuntimeConnection.for_stdio(path=CLI_PATH)) diff --git a/rust/src/types.rs b/rust/src/types.rs index 75408db02..73bfb0744 100644 --- a/rust/src/types.rs +++ b/rust/src/types.rs @@ -351,6 +351,13 @@ pub struct Tool { /// runtime decide. #[serde(default, skip_serializing_if = "Option::is_none")] pub defer: Option, + /// Opaque, host-defined metadata associated with the tool definition. + /// Keys are namespaced and not part of the stable public API; the SDK + /// forwards them verbatim to the runtime, which may recognize specific + /// keys to inform host-specific behavior. Unknown keys are preserved and + /// round-tripped untouched. + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub metadata: HashMap, /// Optional runtime implementation. When `Some`, the SDK dispatches /// matching `external_tool.requested` broadcasts to this handler. /// When `None`, the tool is declaration-only. @@ -470,6 +477,14 @@ impl Tool { self } + /// Set opaque, host-defined metadata forwarded verbatim to the runtime. + /// Keys are namespaced and not part of the stable public API. Replaces any + /// previously-set metadata. + pub fn with_metadata(mut self, metadata: HashMap) -> Self { + self.metadata = metadata; + self + } + /// Attach a runtime implementation. The SDK will dispatch matching /// `external_tool.requested` broadcasts to `handler` for this tool's /// name. Without a handler the tool is declaration-only. @@ -498,6 +513,7 @@ impl std::fmt::Debug for Tool { .field("overrides_built_in_tool", &self.overrides_built_in_tool) .field("skip_permission", &self.skip_permission) .field("defer", &self.defer) + .field("metadata", &self.metadata) .field( "handler", &self.handler.as_ref().map(|_| "").unwrap_or("None"), @@ -4900,6 +4916,32 @@ mod tests { assert!(value.get("defer").is_none()); } + #[test] + fn tool_metadata_serialization() { + use std::collections::HashMap; + + let mut metadata = HashMap::new(); + metadata.insert( + "github.com/copilot:safeForTelemetry".to_string(), + json!({ "name": true, "inputsNames": false }), + ); + let tool = Tool::new("lookup").with_metadata(metadata); + let value = serde_json::to_value(&tool).unwrap(); + assert_eq!( + value + .get("metadata") + .unwrap() + .get("github.com/copilot:safeForTelemetry") + .unwrap(), + &json!({ "name": true, "inputsNames": false }) + ); + + // Empty metadata is omitted on the wire. + let plain = Tool::new("plain"); + let value = serde_json::to_value(&plain).unwrap(); + assert!(value.get("metadata").is_none()); + } + #[test] fn custom_agent_config_builder_with_model() { let agent = CustomAgentConfig::new("my-agent", "You are helpful.")