diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/DelegatingAIFunctionDeclaration.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/DelegatingAIFunctionDeclaration.cs
index 38ebcf0ffd9..727e29d766b 100644
--- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/DelegatingAIFunctionDeclaration.cs
+++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/DelegatingAIFunctionDeclaration.cs
@@ -3,7 +3,9 @@
using System;
using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
+using Microsoft.Shared.DiagnosticIds;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Extensions.AI;
@@ -11,7 +13,8 @@ namespace Microsoft.Extensions.AI;
///
/// Provides an optional base class for an that passes through calls to another instance.
///
-internal class DelegatingAIFunctionDeclaration : AIFunctionDeclaration // could be made public in the future if there's demand
+[Experimental(DiagnosticIds.Experiments.AIToolSearch, UrlFormat = DiagnosticIds.UrlFormat)]
+public class DelegatingAIFunctionDeclaration : AIFunctionDeclaration
{
///
/// Initializes a new instance of the class as a wrapper around .
diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/SearchableAIFunctionDeclaration.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/SearchableAIFunctionDeclaration.cs
new file mode 100644
index 00000000000..c49b723e5bf
--- /dev/null
+++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/SearchableAIFunctionDeclaration.cs
@@ -0,0 +1,62 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
+using Microsoft.Shared.DiagnosticIds;
+using Microsoft.Shared.Diagnostics;
+
+namespace Microsoft.Extensions.AI;
+
+///
+/// Represents an that signals to supporting AI services that deferred
+/// loading should be used when tool search is enabled. Only the function's name and description are sent initially;
+/// the full JSON schema is loaded on demand by the service when the model selects this tool.
+///
+///
+/// This class is a marker/decorator that signals to a supporting provider that the function should be
+/// sent with deferred loading (only name and description upfront). Use to create
+/// a complete tool list including a and wrapped functions.
+///
+[Experimental(DiagnosticIds.Experiments.AIToolSearch, UrlFormat = DiagnosticIds.UrlFormat)]
+public sealed class SearchableAIFunctionDeclaration : DelegatingAIFunctionDeclaration
+{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The represented by this instance.
+ /// An optional namespace for grouping related tools in the tool search index.
+ /// is .
+ public SearchableAIFunctionDeclaration(AIFunctionDeclaration innerFunction, string? namespaceName = null)
+ : base(innerFunction)
+ {
+ Namespace = namespaceName;
+ }
+
+ /// Gets the optional namespace this function belongs to, for grouping related tools in the tool search index.
+ public string? Namespace { get; }
+
+ ///
+ /// Creates a complete tool list with a and the given functions wrapped as .
+ ///
+ /// The functions to include as searchable tools.
+ /// An optional namespace for grouping related tools.
+ /// Any additional properties to pass to the .
+ /// A list of instances ready for use in .
+ /// is .
+ public static IList CreateToolSet(
+ IEnumerable functions,
+ string? namespaceName = null,
+ IReadOnlyDictionary? toolSearchProperties = null)
+ {
+ _ = Throw.IfNull(functions);
+
+ var tools = new List { new HostedToolSearchTool(toolSearchProperties) };
+ foreach (var fn in functions)
+ {
+ tools.Add(new SearchableAIFunctionDeclaration(fn, namespaceName));
+ }
+
+ return tools;
+ }
+}
diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Tools/HostedMcpServerTool.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Tools/HostedMcpServerTool.cs
index d96698776db..c3433dcfff6 100644
--- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Tools/HostedMcpServerTool.cs
+++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Tools/HostedMcpServerTool.cs
@@ -3,6 +3,8 @@
using System;
using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
+using Microsoft.Shared.DiagnosticIds;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Extensions.AI;
@@ -143,4 +145,20 @@ private static string ValidateUrl(Uri serverAddress)
///
///
public IDictionary? Headers { get; set; }
+
+ ///
+ /// Gets or sets a value indicating whether tools from this MCP server should use deferred loading when tool search is enabled.
+ ///
+ ///
+ ///
+ /// When set to and a is present in ,
+ /// the provider will signal to the AI service that tools from this MCP server should be deferred — only their names and
+ /// descriptions are sent initially, and full schemas are loaded on demand when the model selects them.
+ ///
+ ///
+ /// The default value is .
+ ///
+ ///
+ [Experimental(DiagnosticIds.Experiments.AIToolSearch, UrlFormat = DiagnosticIds.UrlFormat)]
+ public bool DeferLoadingTools { get; set; }
}
diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Tools/HostedToolSearchTool.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Tools/HostedToolSearchTool.cs
new file mode 100644
index 00000000000..4fd90e06449
--- /dev/null
+++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Tools/HostedToolSearchTool.cs
@@ -0,0 +1,38 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
+using Microsoft.Shared.DiagnosticIds;
+
+namespace Microsoft.Extensions.AI;
+
+/// Represents a hosted tool that can be specified to an AI service to enable it to search for and selectively load tool definitions on demand.
+///
+/// This tool does not itself implement tool search. It is a marker that can be used to inform a service
+/// that tool search should be enabled, reducing token usage by deferring full tool schema loading until the model requests it.
+///
+[Experimental(DiagnosticIds.Experiments.AIToolSearch, UrlFormat = DiagnosticIds.UrlFormat)]
+public class HostedToolSearchTool : AITool
+{
+ /// Any additional properties associated with the tool.
+ private IReadOnlyDictionary? _additionalProperties;
+
+ /// Initializes a new instance of the class.
+ public HostedToolSearchTool()
+ {
+ }
+
+ /// Initializes a new instance of the class.
+ /// Any additional properties associated with the tool.
+ public HostedToolSearchTool(IReadOnlyDictionary? additionalProperties)
+ {
+ _additionalProperties = additionalProperties;
+ }
+
+ ///
+ public override string Name => "tool_search";
+
+ ///
+ public override IReadOnlyDictionary AdditionalProperties => _additionalProperties ?? base.AdditionalProperties;
+}
diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs
index ccc4fbcb230..c9464778dc6 100644
--- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs
+++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs
@@ -18,6 +18,7 @@
using System.Threading.Tasks;
using Microsoft.Shared.DiagnosticIds;
using Microsoft.Shared.Diagnostics;
+using OpenAI;
using OpenAI.Responses;
#pragma warning disable S1226 // Method parameters, caught exceptions and foreach variables' initial values should not be ignored
@@ -702,7 +703,22 @@ private static bool IsStoredOutputDisabled(CreateResponseOptions? options, Respo
return rtat.Tool;
case AIFunctionDeclaration aiFunction:
- return ToResponseTool(aiFunction, options);
+ var functionTool = ToResponseTool(aiFunction, options);
+ if (tool.GetService() is { } searchable)
+ {
+ functionTool.Patch.Set("$.defer_loading"u8, "true"u8);
+ if (searchable.Namespace is { } ns)
+ {
+ functionTool.Patch.Set("$.namespace"u8, JsonSerializer.SerializeToUtf8Bytes(ns, OpenAIJsonContext.Default.String).AsSpan());
+ }
+ }
+
+ return functionTool;
+
+ case HostedToolSearchTool:
+ // Workaround: The OpenAI .NET SDK doesn't yet expose a ToolSearchTool type.
+ // See https://github.com/openai/openai-dotnet/issues/1053
+ return ModelReaderWriter.Read(BinaryData.FromString("""{"type": "tool_search"}"""), ModelReaderWriterOptions.Json, OpenAIContext.Default)!;
case HostedWebSearchTool webSearchTool:
return new WebSearchTool
@@ -814,6 +830,11 @@ private static bool IsStoredOutputDisabled(CreateResponseOptions? options, Respo
break;
}
+ if (mcpTool.DeferLoadingTools)
+ {
+ responsesMcpTool.Patch.Set("$.defer_loading"u8, "true"u8);
+ }
+
return responsesMcpTool;
default:
diff --git a/src/Shared/DiagnosticIds/DiagnosticIds.cs b/src/Shared/DiagnosticIds/DiagnosticIds.cs
index 94cc1a1f04a..c780f357a04 100644
--- a/src/Shared/DiagnosticIds/DiagnosticIds.cs
+++ b/src/Shared/DiagnosticIds/DiagnosticIds.cs
@@ -58,6 +58,7 @@ internal static class Experiments
internal const string AIResponseContinuations = AIExperiments;
internal const string AICodeInterpreter = AIExperiments;
internal const string AIWebSearch = AIExperiments;
+ internal const string AIToolSearch = AIExperiments;
internal const string AIRealTime = AIExperiments;
internal const string AIFiles = AIExperiments;
diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Functions/SearchableAIFunctionDeclarationTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Functions/SearchableAIFunctionDeclarationTests.cs
new file mode 100644
index 00000000000..a708ceee119
--- /dev/null
+++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Functions/SearchableAIFunctionDeclarationTests.cs
@@ -0,0 +1,108 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System;
+using System.Collections.Generic;
+using Xunit;
+
+namespace Microsoft.Extensions.AI.Functions;
+
+public class SearchableAIFunctionDeclarationTests
+{
+ [Fact]
+ public void Constructor_NullFunction_ThrowsArgumentNullException()
+ {
+ Assert.Throws("innerFunction", () => new SearchableAIFunctionDeclaration(null!));
+ }
+
+ [Fact]
+ public void Constructor_DelegatesToInnerFunction_Properties()
+ {
+ var inner = AIFunctionFactory.Create(() => 42, "MyFunc", "My description");
+ var wrapper = new SearchableAIFunctionDeclaration(inner);
+
+ Assert.Equal(inner.Name, wrapper.Name);
+ Assert.Equal(inner.Description, wrapper.Description);
+ Assert.Equal(inner.JsonSchema, wrapper.JsonSchema);
+ Assert.Equal(inner.ReturnJsonSchema, wrapper.ReturnJsonSchema);
+ Assert.Same(inner.AdditionalProperties, wrapper.AdditionalProperties);
+ Assert.Equal(inner.ToString(), wrapper.ToString());
+ }
+
+ [Fact]
+ public void Namespace_DefaultIsNull()
+ {
+ var inner = AIFunctionFactory.Create(() => 42);
+ var wrapper = new SearchableAIFunctionDeclaration(inner);
+
+ Assert.Null(wrapper.Namespace);
+ }
+
+ [Fact]
+ public void Namespace_Roundtrips()
+ {
+ var inner = AIFunctionFactory.Create(() => 42);
+ var wrapper = new SearchableAIFunctionDeclaration(inner, namespaceName: "myNamespace");
+
+ Assert.Equal("myNamespace", wrapper.Namespace);
+ }
+
+ [Fact]
+ public void GetService_ReturnsSelf()
+ {
+ var inner = AIFunctionFactory.Create(() => 42);
+ var wrapper = new SearchableAIFunctionDeclaration(inner);
+
+ Assert.Same(wrapper, wrapper.GetService());
+ }
+
+ [Fact]
+ public void CreateToolSet_NullFunctions_Throws()
+ {
+ Assert.Throws("functions", () => SearchableAIFunctionDeclaration.CreateToolSet(null!));
+ }
+
+ [Fact]
+ public void CreateToolSet_ReturnsHostedToolSearchToolFirst_ThenWrappedFunctions()
+ {
+ var f1 = AIFunctionFactory.Create(() => 1, "F1");
+ var f2 = AIFunctionFactory.Create(() => 2, "F2");
+
+ var tools = SearchableAIFunctionDeclaration.CreateToolSet([f1, f2]);
+
+ Assert.Equal(3, tools.Count);
+ Assert.IsType(tools[0]);
+ Assert.Empty(tools[0].AdditionalProperties);
+
+ var s1 = Assert.IsType(tools[1]);
+ Assert.Equal("F1", s1.Name);
+ Assert.Null(s1.Namespace);
+
+ var s2 = Assert.IsType(tools[2]);
+ Assert.Equal("F2", s2.Name);
+ Assert.Null(s2.Namespace);
+ }
+
+ [Fact]
+ public void CreateToolSet_WithNamespace_AppliesNamespaceToAll()
+ {
+ var f1 = AIFunctionFactory.Create(() => 1, "F1");
+
+ var tools = SearchableAIFunctionDeclaration.CreateToolSet([f1], namespaceName: "ns");
+
+ var s1 = Assert.IsType(tools[1]);
+ Assert.Equal("ns", s1.Namespace);
+ }
+
+ [Fact]
+ public void CreateToolSet_WithAdditionalProperties_PassesToHostedToolSearchTool()
+ {
+ var props = new Dictionary { ["key"] = "value" };
+ var f1 = AIFunctionFactory.Create(() => 1, "F1");
+
+ var tools = SearchableAIFunctionDeclaration.CreateToolSet([f1], toolSearchProperties: props);
+
+ var toolSearch = Assert.IsType(tools[0]);
+ Assert.Same(props, toolSearch.AdditionalProperties);
+ }
+}
diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Tools/HostedMcpServerToolTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Tools/HostedMcpServerToolTests.cs
index 454fd74a731..23da7e1ae20 100644
--- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Tools/HostedMcpServerToolTests.cs
+++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Tools/HostedMcpServerToolTests.cs
@@ -24,6 +24,7 @@ public void Constructor_PropsDefault()
Assert.Null(tool.AllowedTools);
Assert.Null(tool.ApprovalMode);
Assert.Null(tool.Headers);
+ Assert.False(tool.DeferLoadingTools);
}
[Fact]
@@ -96,6 +97,12 @@ public void Constructor_Roundtrips()
Assert.NotNull(tool.Headers);
Assert.Single(tool.Headers);
Assert.Equal("value1", tool.Headers["X-Custom-Header"]);
+
+ Assert.False(tool.DeferLoadingTools);
+ tool.DeferLoadingTools = true;
+ Assert.True(tool.DeferLoadingTools);
+ tool.DeferLoadingTools = false;
+ Assert.False(tool.DeferLoadingTools);
}
[Fact]
diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Tools/HostedToolSearchToolTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Tools/HostedToolSearchToolTests.cs
new file mode 100644
index 00000000000..f3a32dc8c84
--- /dev/null
+++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Tools/HostedToolSearchToolTests.cs
@@ -0,0 +1,38 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Collections.Generic;
+using Xunit;
+
+namespace Microsoft.Extensions.AI;
+
+public class HostedToolSearchToolTests
+{
+ [Fact]
+ public void Constructor_Roundtrips()
+ {
+ var tool = new HostedToolSearchTool();
+ Assert.Equal("tool_search", tool.Name);
+ Assert.Empty(tool.Description);
+ Assert.Empty(tool.AdditionalProperties);
+ Assert.Equal(tool.Name, tool.ToString());
+ }
+
+ [Fact]
+ public void Constructor_AdditionalProperties_Roundtrips()
+ {
+ var props = new Dictionary { ["key"] = "value" };
+ var tool = new HostedToolSearchTool(props);
+
+ Assert.Equal("tool_search", tool.Name);
+ Assert.Same(props, tool.AdditionalProperties);
+ }
+
+ [Fact]
+ public void Constructor_NullAdditionalProperties_UsesEmpty()
+ {
+ var tool = new HostedToolSearchTool(null);
+
+ Assert.Empty(tool.AdditionalProperties);
+ }
+}
diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIConversionTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIConversionTests.cs
index 7074ef51623..ed5af99e231 100644
--- a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIConversionTests.cs
+++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIConversionTests.cs
@@ -578,6 +578,39 @@ public void AsOpenAIResponseTool_WithHostedMcpServerToolConnector_ExtractsAuthTo
Assert.Empty(tool.Headers);
}
+ [Fact]
+ public void AsOpenAIResponseTool_WithHostedMcpServerToolDeferLoadingTrue_PatchesDeferLoading()
+ {
+ var mcpTool = new HostedMcpServerTool("test-server", "http://localhost:8000")
+ {
+ DeferLoadingTools = true
+ };
+
+ var result = mcpTool.AsOpenAIResponseTool();
+
+ Assert.NotNull(result);
+ var tool = Assert.IsType(result);
+ var json = ModelReaderWriter.Write(tool, ModelReaderWriterOptions.Json).ToString();
+ Assert.Contains("defer_loading", json);
+ Assert.Contains("true", json);
+ }
+
+ [Fact]
+ public void AsOpenAIResponseTool_WithHostedMcpServerToolDeferLoadingFalse_NoDeferLoading()
+ {
+ var mcpTool = new HostedMcpServerTool("test-server", "http://localhost:8000")
+ {
+ DeferLoadingTools = false
+ };
+
+ var result = mcpTool.AsOpenAIResponseTool();
+
+ Assert.NotNull(result);
+ var tool = Assert.IsType(result);
+ var json = ModelReaderWriter.Write(tool, ModelReaderWriterOptions.Json).ToString();
+ Assert.DoesNotContain("defer_loading", json);
+ }
+
[Fact]
public void AsOpenAIResponseTool_WithUnknownToolType_ReturnsNull()
{
@@ -588,6 +621,73 @@ public void AsOpenAIResponseTool_WithUnknownToolType_ReturnsNull()
Assert.Null(result);
}
+ [Fact]
+ public void AsOpenAIResponseTool_WithHostedToolSearchTool_ProducesValidToolSearchTool()
+ {
+ var toolSearchTool = new HostedToolSearchTool();
+
+ var result = toolSearchTool.AsOpenAIResponseTool();
+
+ Assert.NotNull(result);
+ var json = ModelReaderWriter.Write(result, ModelReaderWriterOptions.Json).ToString();
+ Assert.Contains("\"type\"", json);
+ Assert.Contains("tool_search", json);
+ }
+
+ [Fact]
+ public void AsOpenAIResponseTool_WithHostedToolSearchTool_ProducesNewInstanceEachTime()
+ {
+ var result1 = new HostedToolSearchTool().AsOpenAIResponseTool();
+ var result2 = new HostedToolSearchTool().AsOpenAIResponseTool();
+
+ Assert.NotNull(result1);
+ Assert.NotNull(result2);
+ Assert.NotSame(result1, result2);
+ }
+
+ [Fact]
+ public void AsOpenAIResponseTool_WithSearchableAIFunctionDeclaration_PatchesDeferLoading()
+ {
+ var inner = AIFunctionFactory.Create(() => 42, "MyFunc", "My description");
+ var searchable = new SearchableAIFunctionDeclaration(inner);
+
+ var result = ((AITool)searchable).AsOpenAIResponseTool();
+
+ Assert.NotNull(result);
+ var functionTool = Assert.IsType(result);
+ var json = ModelReaderWriter.Write(functionTool, ModelReaderWriterOptions.Json).ToString();
+ Assert.Contains("defer_loading", json);
+ Assert.Contains("true", json);
+ }
+
+ [Fact]
+ public void AsOpenAIResponseTool_WithSearchableAIFunctionDeclarationWithNamespace_PatchesNamespace()
+ {
+ var inner = AIFunctionFactory.Create(() => 42, "MyFunc", "My description");
+ var searchable = new SearchableAIFunctionDeclaration(inner, namespaceName: "myNamespace");
+
+ var result = ((AITool)searchable).AsOpenAIResponseTool();
+
+ Assert.NotNull(result);
+ var functionTool = Assert.IsType(result);
+ var json = ModelReaderWriter.Write(functionTool, ModelReaderWriterOptions.Json).ToString();
+ Assert.Contains("namespace", json);
+ Assert.Contains("myNamespace", json);
+ }
+
+ [Fact]
+ public void AsOpenAIResponseTool_WithPlainAIFunction_NoDeferLoading()
+ {
+ var func = AIFunctionFactory.Create(() => 42, "MyFunc", "My description");
+
+ var result = ((AITool)func).AsOpenAIResponseTool();
+
+ Assert.NotNull(result);
+ var functionTool = Assert.IsType(result);
+ var json = ModelReaderWriter.Write(functionTool, ModelReaderWriterOptions.Json).ToString();
+ Assert.DoesNotContain("defer_loading", json);
+ }
+
[Fact]
public void AsOpenAIResponseTool_WithNullTool_ThrowsArgumentNullException()
{
diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientIntegrationTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientIntegrationTests.cs
index c98197e0b65..0435bb21a15 100644
--- a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientIntegrationTests.cs
+++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientIntegrationTests.cs
@@ -256,6 +256,39 @@ await client.GetStreamingResponseAsync(input, chatOptions).ToChatResponseAsync()
}
}
+ [ConditionalFact]
+ public async Task RemoteMCP_DeferLoadingTools()
+ {
+ SkipIfNotEnabled();
+
+ if (TestRunnerConfiguration.Instance["OpenAI:ChatModel"]?.StartsWith("gpt-5.4", StringComparison.OrdinalIgnoreCase) is not true)
+ {
+ throw new SkipTestException("Tool search requires gpt-5.4 or later.");
+ }
+
+ ChatOptions chatOptions = new()
+ {
+ Tools =
+ [
+ new HostedToolSearchTool(),
+ new HostedMcpServerTool("deepwiki", new Uri("https://mcp.deepwiki.com/mcp"))
+ {
+ ApprovalMode = HostedMcpServerToolApprovalMode.NeverRequire,
+ DeferLoadingTools = true,
+ },
+ ],
+ };
+
+ ChatResponse response = await ChatClient.GetResponseAsync(
+ "Tell me the path to the README.md file for Microsoft.Extensions.AI.Abstractions in the dotnet/extensions repository",
+ chatOptions);
+
+ Assert.NotNull(response);
+ Assert.Contains("src/Libraries/Microsoft.Extensions.AI.Abstractions/README.md", response.Text);
+ Assert.NotEmpty(response.Messages.SelectMany(m => m.Contents).OfType());
+ Assert.NotEmpty(response.Messages.SelectMany(m => m.Contents).OfType());
+ }
+
[ConditionalFact]
public async Task GetResponseAsync_BackgroundResponses()
{
@@ -754,4 +787,33 @@ public async Task ReasoningContent_Streaming_RoundtripsEncryptedContent()
});
Assert.Contains("encrypted", ex.Message, StringComparison.OrdinalIgnoreCase);
}
+
+ [ConditionalFact]
+ public async Task UseToolSearch_WithDeferredFunctions()
+ {
+ SkipIfNotEnabled();
+
+ if (TestRunnerConfiguration.Instance["OpenAI:ChatModel"]?.StartsWith("gpt-5.4", StringComparison.OrdinalIgnoreCase) is not true)
+ {
+ throw new SkipTestException("Tool search requires gpt-5.4 or later.");
+ }
+
+ AIFunction getWeather = AIFunctionFactory.Create(() => "Sunny, 72°F", "GetWeather", "Gets the current weather.");
+ AIFunction getTime = AIFunctionFactory.Create(() => "3:00 PM", "GetTime", "Gets the current time.");
+
+ var response = await ChatClient.GetResponseAsync(
+ "What's the weather like? Just respond with the weather info, nothing else.",
+ new()
+ {
+ Tools =
+ [
+ new HostedToolSearchTool(),
+ new SearchableAIFunctionDeclaration(getWeather),
+ new SearchableAIFunctionDeclaration(getTime),
+ ],
+ });
+
+ Assert.NotNull(response);
+ Assert.NotEmpty(response.Text);
+ }
}
diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientTests.cs
index e62b0ecde57..bec6c3d47e5 100644
--- a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientTests.cs
+++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientTests.cs
@@ -7105,5 +7105,383 @@ public async Task WebSearchTool_Streaming()
var textContent = message.Contents.OfType().Single();
Assert.Equal(".NET 10 was officially released.", textContent.Text);
}
-}
+ [Fact]
+ public async Task ToolSearchTool_OnlyToolSearch_NonStreaming()
+ {
+ const string Input = """
+ {
+ "model": "gpt-4o-mini",
+ "input": [
+ {
+ "type": "message",
+ "role": "user",
+ "content": [
+ {
+ "type": "input_text",
+ "text": "hello"
+ }
+ ]
+ }
+ ],
+ "tools": [
+ {
+ "type": "tool_search"
+ }
+ ]
+ }
+ """;
+
+ const string Output = """
+ {
+ "id": "resp_001",
+ "object": "response",
+ "created_at": 1741892091,
+ "status": "completed",
+ "model": "gpt-4o-mini",
+ "output": [
+ {
+ "type": "message",
+ "id": "msg_001",
+ "status": "completed",
+ "role": "assistant",
+ "content": [{"type": "output_text", "text": "Hello!", "annotations": []}]
+ }
+ ]
+ }
+ """;
+
+ using VerbatimHttpHandler handler = new(Input, Output);
+ using HttpClient httpClient = new(handler);
+ using IChatClient client = CreateResponseClient(httpClient, "gpt-4o-mini");
+
+ var response = await client.GetResponseAsync("hello", new()
+ {
+ Tools = [new HostedToolSearchTool()],
+ });
+
+ Assert.NotNull(response);
+ Assert.Equal("Hello!", response.Text);
+ }
+
+ [Fact]
+ public async Task ToolSearchTool_SearchableFunctionsDeferred_NonStreaming()
+ {
+ const string Input = """
+ {
+ "model": "gpt-4o-mini",
+ "input": [
+ {
+ "type": "message",
+ "role": "user",
+ "content": [
+ {
+ "type": "input_text",
+ "text": "hello"
+ }
+ ]
+ }
+ ],
+ "tools": [
+ {
+ "type": "tool_search"
+ },
+ {
+ "type": "function",
+ "name": "GetWeather",
+ "description": "Gets the weather.",
+ "parameters": {
+ "type": "object",
+ "required": [],
+ "properties": {},
+ "additionalProperties": false
+ },
+ "strict": true,
+ "defer_loading": true
+ },
+ {
+ "type": "function",
+ "name": "GetForecast",
+ "description": "Gets the forecast.",
+ "parameters": {
+ "type": "object",
+ "required": [],
+ "properties": {},
+ "additionalProperties": false
+ },
+ "strict": true,
+ "defer_loading": true
+ }
+ ]
+ }
+ """;
+
+ const string Output = """
+ {
+ "id": "resp_001",
+ "object": "response",
+ "created_at": 1741892091,
+ "status": "completed",
+ "model": "gpt-4o-mini",
+ "output": [
+ {
+ "type": "message",
+ "id": "msg_001",
+ "status": "completed",
+ "role": "assistant",
+ "content": [{"type": "output_text", "text": "Hello!", "annotations": []}]
+ }
+ ]
+ }
+ """;
+
+ using VerbatimHttpHandler handler = new(Input, Output);
+ using HttpClient httpClient = new(handler);
+ using IChatClient client = CreateResponseClient(httpClient, "gpt-4o-mini");
+
+ var getWeather = AIFunctionFactory.Create(() => 42, "GetWeather", "Gets the weather.");
+ var getForecast = AIFunctionFactory.Create(() => 42, "GetForecast", "Gets the forecast.");
+
+ var response = await client.GetResponseAsync("hello", new()
+ {
+ Tools =
+ [
+ new HostedToolSearchTool(),
+ new SearchableAIFunctionDeclaration(getWeather),
+ new SearchableAIFunctionDeclaration(getForecast),
+ ],
+ AdditionalProperties = new() { ["strict"] = true },
+ });
+
+ Assert.NotNull(response);
+ Assert.Equal("Hello!", response.Text);
+ }
+
+ [Fact]
+ public async Task ToolSearchTool_MixedSearchableAndPlainFunctions_NonStreaming()
+ {
+ const string Input = """
+ {
+ "model": "gpt-4o-mini",
+ "input": [
+ {
+ "type": "message",
+ "role": "user",
+ "content": [
+ {
+ "type": "input_text",
+ "text": "hello"
+ }
+ ]
+ }
+ ],
+ "tools": [
+ {
+ "type": "tool_search"
+ },
+ {
+ "type": "function",
+ "name": "GetWeather",
+ "description": "Gets the weather.",
+ "parameters": {
+ "type": "object",
+ "required": [],
+ "properties": {},
+ "additionalProperties": false
+ },
+ "strict": true,
+ "defer_loading": true
+ },
+ {
+ "type": "function",
+ "name": "ImportantTool",
+ "description": "An important tool.",
+ "parameters": {
+ "type": "object",
+ "required": [],
+ "properties": {},
+ "additionalProperties": false
+ },
+ "strict": true
+ }
+ ]
+ }
+ """;
+
+ const string Output = """
+ {
+ "id": "resp_001",
+ "object": "response",
+ "created_at": 1741892091,
+ "status": "completed",
+ "model": "gpt-4o-mini",
+ "output": [
+ {
+ "type": "message",
+ "id": "msg_001",
+ "status": "completed",
+ "role": "assistant",
+ "content": [{"type": "output_text", "text": "Hello!", "annotations": []}]
+ }
+ ]
+ }
+ """;
+
+ using VerbatimHttpHandler handler = new(Input, Output);
+ using HttpClient httpClient = new(handler);
+ using IChatClient client = CreateResponseClient(httpClient, "gpt-4o-mini");
+
+ var getWeather = AIFunctionFactory.Create(() => 42, "GetWeather", "Gets the weather.");
+ var importantTool = AIFunctionFactory.Create(() => 42, "ImportantTool", "An important tool.");
+
+ var response = await client.GetResponseAsync("hello", new()
+ {
+ Tools =
+ [
+ new HostedToolSearchTool(),
+ new SearchableAIFunctionDeclaration(getWeather),
+ importantTool,
+ ],
+ AdditionalProperties = new() { ["strict"] = true },
+ });
+
+ Assert.NotNull(response);
+ Assert.Equal("Hello!", response.Text);
+ }
+
+ [Fact]
+ public async Task ToolSearchTool_NoFunctionTools_NonStreaming()
+ {
+ const string Input = """
+ {
+ "model": "gpt-4o-mini",
+ "input": [
+ {
+ "type": "message",
+ "role": "user",
+ "content": [
+ {
+ "type": "input_text",
+ "text": "hello"
+ }
+ ]
+ }
+ ],
+ "tools": [
+ {
+ "type": "tool_search"
+ },
+ {
+ "type": "web_search"
+ }
+ ]
+ }
+ """;
+
+ const string Output = """
+ {
+ "id": "resp_001",
+ "object": "response",
+ "created_at": 1741892091,
+ "status": "completed",
+ "model": "gpt-4o-mini",
+ "output": [
+ {
+ "type": "message",
+ "id": "msg_001",
+ "status": "completed",
+ "role": "assistant",
+ "content": [{"type": "output_text", "text": "Hello!", "annotations": []}]
+ }
+ ]
+ }
+ """;
+
+ using VerbatimHttpHandler handler = new(Input, Output);
+ using HttpClient httpClient = new(handler);
+ using IChatClient client = CreateResponseClient(httpClient, "gpt-4o-mini");
+
+ var response = await client.GetResponseAsync("hello", new()
+ {
+ Tools =
+ [
+ new HostedToolSearchTool(),
+ new HostedWebSearchTool(),
+ ],
+ });
+
+ Assert.NotNull(response);
+ Assert.Equal("Hello!", response.Text);
+ }
+
+ [Fact]
+ public async Task McpTool_DeferLoadingTools_NonStreaming()
+ {
+ const string Input = """
+ {
+ "model": "gpt-4o-mini",
+ "input": [
+ {
+ "type": "message",
+ "role": "user",
+ "content": [
+ {
+ "type": "input_text",
+ "text": "hello"
+ }
+ ]
+ }
+ ],
+ "tools": [
+ {
+ "type": "tool_search"
+ },
+ {
+ "type": "mcp",
+ "server_label": "deepwiki",
+ "server_url": "https://mcp.deepwiki.com/mcp",
+ "defer_loading": true
+ }
+ ]
+ }
+ """;
+
+ const string Output = """
+ {
+ "id": "resp_001",
+ "object": "response",
+ "created_at": 1741892091,
+ "status": "completed",
+ "model": "gpt-4o-mini",
+ "output": [
+ {
+ "type": "message",
+ "id": "msg_001",
+ "status": "completed",
+ "role": "assistant",
+ "content": [{"type": "output_text", "text": "Hello!", "annotations": []}]
+ }
+ ]
+ }
+ """;
+
+ using VerbatimHttpHandler handler = new(Input, Output);
+ using HttpClient httpClient = new(handler);
+ using IChatClient client = CreateResponseClient(httpClient, "gpt-4o-mini");
+
+ var response = await client.GetResponseAsync("hello", new()
+ {
+ Tools =
+ [
+ new HostedToolSearchTool(),
+ new HostedMcpServerTool("deepwiki", new Uri("https://mcp.deepwiki.com/mcp"))
+ {
+ DeferLoadingTools = true,
+ },
+ ],
+ });
+
+ Assert.NotNull(response);
+ Assert.Equal("Hello!", response.Text);
+ }
+}