diff --git a/eng/Version.Details.xml b/eng/Version.Details.xml index 3a746194f1b..cb6ead9fa88 100644 --- a/eng/Version.Details.xml +++ b/eng/Version.Details.xml @@ -1,196 +1,196 @@ - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 3875b54e7b10b10606b105340199946d0b877754 + 3c298d9f00936d651cc47d221762474e25277672 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 3875b54e7b10b10606b105340199946d0b877754 + 3c298d9f00936d651cc47d221762474e25277672 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 3875b54e7b10b10606b105340199946d0b877754 + 3c298d9f00936d651cc47d221762474e25277672 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 3875b54e7b10b10606b105340199946d0b877754 + 3c298d9f00936d651cc47d221762474e25277672 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 3875b54e7b10b10606b105340199946d0b877754 + 3c298d9f00936d651cc47d221762474e25277672 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 3875b54e7b10b10606b105340199946d0b877754 + 3c298d9f00936d651cc47d221762474e25277672 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 3875b54e7b10b10606b105340199946d0b877754 + 3c298d9f00936d651cc47d221762474e25277672 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 3875b54e7b10b10606b105340199946d0b877754 + 3c298d9f00936d651cc47d221762474e25277672 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 3875b54e7b10b10606b105340199946d0b877754 + 3c298d9f00936d651cc47d221762474e25277672 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 3875b54e7b10b10606b105340199946d0b877754 + 3c298d9f00936d651cc47d221762474e25277672 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 3875b54e7b10b10606b105340199946d0b877754 + 3c298d9f00936d651cc47d221762474e25277672 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 3875b54e7b10b10606b105340199946d0b877754 + 3c298d9f00936d651cc47d221762474e25277672 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 3875b54e7b10b10606b105340199946d0b877754 + 3c298d9f00936d651cc47d221762474e25277672 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 3875b54e7b10b10606b105340199946d0b877754 + 3c298d9f00936d651cc47d221762474e25277672 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 3875b54e7b10b10606b105340199946d0b877754 + 3c298d9f00936d651cc47d221762474e25277672 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 3875b54e7b10b10606b105340199946d0b877754 + 3c298d9f00936d651cc47d221762474e25277672 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 3875b54e7b10b10606b105340199946d0b877754 + 3c298d9f00936d651cc47d221762474e25277672 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 3875b54e7b10b10606b105340199946d0b877754 + 3c298d9f00936d651cc47d221762474e25277672 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 3875b54e7b10b10606b105340199946d0b877754 + 3c298d9f00936d651cc47d221762474e25277672 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 3875b54e7b10b10606b105340199946d0b877754 + 3c298d9f00936d651cc47d221762474e25277672 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 3875b54e7b10b10606b105340199946d0b877754 + 3c298d9f00936d651cc47d221762474e25277672 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 3875b54e7b10b10606b105340199946d0b877754 + 3c298d9f00936d651cc47d221762474e25277672 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 3875b54e7b10b10606b105340199946d0b877754 + 3c298d9f00936d651cc47d221762474e25277672 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 3875b54e7b10b10606b105340199946d0b877754 + 3c298d9f00936d651cc47d221762474e25277672 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 3875b54e7b10b10606b105340199946d0b877754 + 3c298d9f00936d651cc47d221762474e25277672 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 3875b54e7b10b10606b105340199946d0b877754 + 3c298d9f00936d651cc47d221762474e25277672 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 3875b54e7b10b10606b105340199946d0b877754 + 3c298d9f00936d651cc47d221762474e25277672 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 3875b54e7b10b10606b105340199946d0b877754 + 3c298d9f00936d651cc47d221762474e25277672 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 3875b54e7b10b10606b105340199946d0b877754 + 3c298d9f00936d651cc47d221762474e25277672 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 3875b54e7b10b10606b105340199946d0b877754 + 3c298d9f00936d651cc47d221762474e25277672 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 3875b54e7b10b10606b105340199946d0b877754 + 3c298d9f00936d651cc47d221762474e25277672 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 3875b54e7b10b10606b105340199946d0b877754 + 3c298d9f00936d651cc47d221762474e25277672 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 3875b54e7b10b10606b105340199946d0b877754 + 3c298d9f00936d651cc47d221762474e25277672 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 3875b54e7b10b10606b105340199946d0b877754 + 3c298d9f00936d651cc47d221762474e25277672 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 3875b54e7b10b10606b105340199946d0b877754 + 3c298d9f00936d651cc47d221762474e25277672 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 3875b54e7b10b10606b105340199946d0b877754 + 3c298d9f00936d651cc47d221762474e25277672 - + https://dev.azure.com/dnceng/internal/_git/dotnet-runtime - 3875b54e7b10b10606b105340199946d0b877754 + 3c298d9f00936d651cc47d221762474e25277672 - + https://dev.azure.com/dnceng/internal/_git/dotnet-aspnetcore - 379bfc7b2559e7cc9f42f997a497b2f2dd8e12d2 + f6b3a5da75eb405046889a5447ec9b14cc29d285 - + https://dev.azure.com/dnceng/internal/_git/dotnet-aspnetcore - 379bfc7b2559e7cc9f42f997a497b2f2dd8e12d2 + f6b3a5da75eb405046889a5447ec9b14cc29d285 - + https://dev.azure.com/dnceng/internal/_git/dotnet-aspnetcore - 379bfc7b2559e7cc9f42f997a497b2f2dd8e12d2 + f6b3a5da75eb405046889a5447ec9b14cc29d285 - + https://dev.azure.com/dnceng/internal/_git/dotnet-aspnetcore - 379bfc7b2559e7cc9f42f997a497b2f2dd8e12d2 + f6b3a5da75eb405046889a5447ec9b14cc29d285 - + https://dev.azure.com/dnceng/internal/_git/dotnet-aspnetcore - 379bfc7b2559e7cc9f42f997a497b2f2dd8e12d2 + f6b3a5da75eb405046889a5447ec9b14cc29d285 - + https://dev.azure.com/dnceng/internal/_git/dotnet-aspnetcore - 379bfc7b2559e7cc9f42f997a497b2f2dd8e12d2 + f6b3a5da75eb405046889a5447ec9b14cc29d285 - + https://dev.azure.com/dnceng/internal/_git/dotnet-aspnetcore - 379bfc7b2559e7cc9f42f997a497b2f2dd8e12d2 + f6b3a5da75eb405046889a5447ec9b14cc29d285 - + https://dev.azure.com/dnceng/internal/_git/dotnet-aspnetcore - 379bfc7b2559e7cc9f42f997a497b2f2dd8e12d2 + f6b3a5da75eb405046889a5447ec9b14cc29d285 - + https://dev.azure.com/dnceng/internal/_git/dotnet-aspnetcore - 379bfc7b2559e7cc9f42f997a497b2f2dd8e12d2 + f6b3a5da75eb405046889a5447ec9b14cc29d285 - + https://dev.azure.com/dnceng/internal/_git/dotnet-efcore - 8751e6d519fda94d5154187358765311ed4a4e84 + 67d253c17619e6ba325e5390905ea2a13cc7f532 diff --git a/eng/Versions.props b/eng/Versions.props index 03fa0e24fd4..ac0cc576af8 100644 --- a/eng/Versions.props +++ b/eng/Versions.props @@ -11,14 +11,14 @@ - false + true - + release true @@ -34,55 +34,55 @@ --> - 9.0.6 - 9.0.6 - 9.0.6 - 9.0.6 - 9.0.6 - 9.0.6 - 9.0.6 - 9.0.6 - 9.0.6 - 9.0.6 - 9.0.6 - 9.0.6 - 9.0.6 - 9.0.6 - 9.0.6 - 9.0.6 - 9.0.6 - 9.0.6 - 9.0.6 - 9.0.6 - 9.0.6 - 9.0.6 - 9.0.6 - 9.0.6 - 9.0.6 - 9.0.6 - 9.0.6 - 9.0.6 - 9.0.6 - 9.0.6 - 9.0.6 - 9.0.6 - 9.0.6 - 9.0.6 - 9.0.6 - 9.0.6 - 9.0.6 + 9.0.7 + 9.0.7 + 9.0.7 + 9.0.7 + 9.0.7 + 9.0.7 + 9.0.7 + 9.0.7 + 9.0.7 + 9.0.7 + 9.0.7 + 9.0.7 + 9.0.7 + 9.0.7 + 9.0.7 + 9.0.7 + 9.0.7 + 9.0.7 + 9.0.7 + 9.0.7 + 9.0.7 + 9.0.7 + 9.0.7 + 9.0.7 + 9.0.7 + 9.0.7 + 9.0.7 + 9.0.7 + 9.0.7 + 9.0.7 + 9.0.7 + 9.0.7 + 9.0.7 + 9.0.7 + 9.0.7 + 9.0.7 + 9.0.7 - 9.0.6 - 9.0.6 - 9.0.6 - 9.0.6 - 9.0.6 - 9.0.6 - 9.0.6 - 9.0.6 - 9.0.6 + 9.0.7 + 9.0.7 + 9.0.7 + 9.0.7 + 9.0.7 + 9.0.7 + 9.0.7 + 9.0.7 + 9.0.7 - 9.0.6 + 9.0.7 9.0.0-beta.25325.4 @@ -108,8 +108,8 @@ 8.0.1 8.0.0 8.0.2 - 8.0.17 - 8.0.17 + 8.0.18 + 8.0.18 8.0.0 8.0.1 8.0.1 @@ -123,20 +123,20 @@ 8.0.2 8.0.0 8.0.0 - 8.0.5 + 8.0.6 8.0.0 - 8.0.17 - 8.0.17 - 8.0.17 - 8.0.17 - 8.0.17 - 8.0.17 - 8.0.17 - 8.0.17 - 8.0.17 + 8.0.18 + 8.0.18 + 8.0.18 + 8.0.18 + 8.0.18 + 8.0.18 + 8.0.18 + 8.0.18 + 8.0.18 - 8.0.17 + 8.0.18 - $(Version) - $(Version) - $(Version) + 9.7.0 + 9.7.0-preview.1.25356.2 + 9.7.0 @@ -34,9 +35,11 @@ 1.14.0 11.6.0 9.4.1-beta.291 + 10.0.0-preview.5.25277.114 9.3.0 1.53.0 1.53.0-preview + 0.3.0-preview.2 5.1.18 1.12.0 0.1.10 @@ -64,9 +67,11 @@ TemplatePackageVersion_AzureIdentity=$(TemplatePackageVersion_AzureIdentity); TemplatePackageVersion_AzureSearchDocuments=$(TemplatePackageVersion_AzureSearchDocuments); TemplatePackageVersion_CommunityToolkitAspire=$(TemplatePackageVersion_CommunityToolkitAspire); + TemplatePackageVersion_MicrosoftExtensionsHosting=$(TemplatePackageVersion_MicrosoftExtensionsHosting); TemplatePackageVersion_MicrosoftExtensionsServiceDiscovery=$(TemplatePackageVersion_MicrosoftExtensionsServiceDiscovery); TemplatePackageVersion_MicrosoftSemanticKernel=$(TemplatePackageVersion_MicrosoftSemanticKernel); TemplatePackageVersion_MicrosoftSemanticKernel_Preview=$(TemplatePackageVersion_MicrosoftSemanticKernel_Preview); + TemplatePackageVersion_ModelContextProtocol=$(TemplatePackageVersion_ModelContextProtocol); TemplatePackageVersion_OllamaSharp=$(TemplatePackageVersion_OllamaSharp); TemplatePackageVersion_OpenTelemetry=$(TemplatePackageVersion_OpenTelemetry); TemplatePackageVersion_PdfPig=$(TemplatePackageVersion_PdfPig); @@ -100,6 +105,9 @@ + <_GeneratedContentEnablingJustBuiltPackages diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/Microsoft.Extensions.AI.Templates.csproj b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/Microsoft.Extensions.AI.Templates.csproj index 2827734c794..7784747028e 100644 --- a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/Microsoft.Extensions.AI.Templates.csproj +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/Microsoft.Extensions.AI.Templates.csproj @@ -61,6 +61,16 @@ **\NuGet.config; **\Directory.Build.targets; **\Directory.Build.props;" /> + + + diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/McpServer/McpServer-CSharp/.mcp/server.json b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/McpServer/McpServer-CSharp/.mcp/server.json new file mode 100644 index 00000000000..d4b9d0edf5b --- /dev/null +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/McpServer/McpServer-CSharp/.mcp/server.json @@ -0,0 +1,20 @@ +{ + "description": "", + "name": "io.github./", + "packages": [ + { + "registry_name": "nuget", + "name": "", + "version": "0.1.0-beta", + "package_arguments": [], + "environment_variables": [] + } + ], + "repository": { + "url": "https://github.com//", + "source": "github" + }, + "version_detail": { + "version": "0.1.0-beta" + } +} diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/McpServer/McpServer-CSharp/.template.config/dotnetcli.host.json b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/McpServer/McpServer-CSharp/.template.config/dotnetcli.host.json new file mode 100644 index 00000000000..5be51dd6357 --- /dev/null +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/McpServer/McpServer-CSharp/.template.config/dotnetcli.host.json @@ -0,0 +1,7 @@ +{ + "$schema": "https://json.schemastore.org/dotnetcli.host", + "symbolInfo": {}, + "usageExamples": [ + "" + ] +} \ No newline at end of file diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/McpServer/McpServer-CSharp/.template.config/ide.host.json b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/McpServer/McpServer-CSharp/.template.config/ide.host.json new file mode 100644 index 00000000000..5edf447bbd4 --- /dev/null +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/McpServer/McpServer-CSharp/.template.config/ide.host.json @@ -0,0 +1,6 @@ +{ + "$schema": "https://json.schemastore.org/ide.host", + "order": 0, + "icon": "ide/icon.ico", + "symbolInfo": [] +} diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/McpServer/McpServer-CSharp/.template.config/ide/icon.ico b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/McpServer/McpServer-CSharp/.template.config/ide/icon.ico new file mode 100644 index 00000000000..954709ffd6b Binary files /dev/null and b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/McpServer/McpServer-CSharp/.template.config/ide/icon.ico differ diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/McpServer/McpServer-CSharp/.template.config/template.json b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/McpServer/McpServer-CSharp/.template.config/template.json new file mode 100644 index 00000000000..1fdc9128e81 --- /dev/null +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/McpServer/McpServer-CSharp/.template.config/template.json @@ -0,0 +1,46 @@ +{ + "$schema": "http://json.schemastore.org/template", + "author": "Microsoft", + "classifications": [ + "Common", + "AI", + "MCP" + ], + "identity": "Microsoft.Extensions.AI.Templates.McpServer.CSharp", + "name": "Local MCP Server Console App", + "description": "A project template for creating a Model Context Protocol (MCP) server using C# and the ModelContextProtocol package.", + "shortName": "mcpserver", + "defaultName": "McpServer", + "sourceName": "McpServer-CSharp", + "preferNameDirectory": true, + "tags": { + "language": "C#", + "type": "project" + }, + "symbols": { + "hostIdentifier": { + "type": "bind", + "binding": "HostIdentifier" + } + }, + "primaryOutputs": [ + { + "path": "./README.md" + }, + { + "path": "./McpServer-CSharp.csproj" + } + ], + "postActions": [ + { + "condition": "(hostIdentifier != \"dotnetcli\" && hostIdentifier != \"dotnetcli-preview\")", + "description": "Opens README file in the editor", + "manualInstructions": [], + "actionId": "84C0DA21-51C8-4541-9940-6CA19AF04EE6", + "args": { + "files": "0" + }, + "continueOnError": true + } + ] +} diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/McpServer/McpServer-CSharp/McpServer-CSharp.csproj.in b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/McpServer/McpServer-CSharp/McpServer-CSharp.csproj.in new file mode 100644 index 00000000000..d47952229d9 --- /dev/null +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/McpServer/McpServer-CSharp/McpServer-CSharp.csproj.in @@ -0,0 +1,32 @@ + + + + net10.0 + Exe + enable + enable + + + true + McpServer + + + README.md + SampleMcpServer + 0.1.0-beta + AI; MCP; server; stdio + An MCP server using the MCP C# SDK. + + + + + + + + + + + + + + diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/McpServer/McpServer-CSharp/Program.cs b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/McpServer/McpServer-CSharp/Program.cs new file mode 100644 index 00000000000..f320c93fd88 --- /dev/null +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/McpServer/McpServer-CSharp/Program.cs @@ -0,0 +1,16 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +var builder = Host.CreateApplicationBuilder(args); + +// Configure all logs to go to stderr (stdout is used for the MCP protocol messages). +builder.Logging.AddConsole(o => o.LogToStandardErrorThreshold = LogLevel.Trace); + +// Add the MCP services: the transport to use (stdio) and the tools to register. +builder.Services + .AddMcpServer() + .WithStdioServerTransport() + .WithTools(); + +await builder.Build().RunAsync(); diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/McpServer/McpServer-CSharp/README.md b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/McpServer/McpServer-CSharp/README.md new file mode 100644 index 00000000000..50091888ad8 --- /dev/null +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/McpServer/McpServer-CSharp/README.md @@ -0,0 +1,80 @@ +# MCP Server + +This README was created using the C# MCP server template project. It demonstrates how you can easily create an MCP server using C# and then package it in a NuGet package. + +See [aka.ms/nuget/mcp/guide](https://aka.ms/nuget/mcp/guide) for the full guide. + +## Checklist before publishing to NuGet.org + +- Test the MCP server locally using the steps below. +- Update the package metadata in the .csproj file, in particular the ``. +- Update `.mcp/server.json` to declare your MCP server's inputs. + - See [configuring inputs](https://aka.ms/nuget/mcp/guide/configuring-inputs) for more details. +- Pack the project using `dotnet pack`. + +The `bin/Release` directory will contain the package file (.nupkg), which can be [published to NuGet.org](https://learn.microsoft.com/nuget/nuget-org/publish-a-package). + +## Using the MCP Server in VS Code + +Once the MCP server package is published to NuGet.org, you can use the following VS Code user configuration to download and install the MCP server package. See [Use MCP servers in VS Code (Preview)](https://code.visualstudio.com/docs/copilot/chat/mcp-servers) for more information about using MCP servers in VS Code. + +```json +{ + "mcp": { + "servers": { + "McpServer-CSharp": { + "type": "stdio", + "command": "dnx", + "args": [ + "", + "--version", + "", + "--yes" + ] + } + } + } +} +``` + +Now you can ask Copilot Chat for a random number, for example, `Give me 3 random numbers`. It should prompt you to use the `get_random_number` tool on the `McpServer-CSharp` MCP server and show you the results. + +## Developing locally in VS Code + +To test this MCP server from source code (locally) without using a built MCP server package, create a `.vscode/mcp.json` file (a VS Code workspace settings file) in your project directory and add the following configuration: + +```json +{ + "servers": { + "McpServer-CSharp": { + "type": "stdio", + "command": "dotnet", + "args": [ + "run", + "--project", + "" + ] + } + } +} +``` + +Alternatively, you can configure your VS Code user settings to use your local project: + +```json +{ + "mcp": { + "servers": { + "McpServer-CSharp": { + "type": "stdio", + "command": "dotnet", + "args": [ + "run", + "--project", + "" + ] + } + } + } +} +``` diff --git a/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/McpServer/McpServer-CSharp/Tools/RandomNumberTools.cs b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/McpServer/McpServer-CSharp/Tools/RandomNumberTools.cs new file mode 100644 index 00000000000..568574f47d9 --- /dev/null +++ b/src/ProjectTemplates/Microsoft.Extensions.AI.Templates/src/McpServer/McpServer-CSharp/Tools/RandomNumberTools.cs @@ -0,0 +1,18 @@ +using System.ComponentModel; +using ModelContextProtocol.Server; + +/// +/// Sample MCP tools for demonstration purposes. +/// These tools can be invoked by MCP clients to perform various operations. +/// +internal class RandomNumberTools +{ + [McpServerTool] + [Description("Generates a random number between the specified minimum and maximum values.")] + public int GetRandomNumber( + [Description("Minimum value (inclusive)")] int min = 0, + [Description("Maximum value (exclusive)")] int max = 100) + { + return Random.Shared.Next(min, max); + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Functions/DelegatingAIFunctionTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Functions/DelegatingAIFunctionTests.cs new file mode 100644 index 00000000000..cfad15efdc0 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Functions/DelegatingAIFunctionTests.cs @@ -0,0 +1,92 @@ +// 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.Reflection; +using System.Threading; +using System.Threading.Tasks; +using Xunit; + +namespace Microsoft.Extensions.AI; + +public class DelegatingAIFunctionTests +{ + [Fact] + public void Constructor_NullInnerFunction_ThrowsArgumentNullException() + { + Assert.Throws("innerFunction", () => new DerivedFunction(null!)); + } + + [Fact] + public void DefaultOverrides_DelegateToInnerFunction() + { + AIFunction expected = AIFunctionFactory.Create(() => 42); + DerivedFunction actual = new(expected); + + Assert.Same(expected, actual.InnerFunction); + Assert.Equal(expected.Name, actual.Name); + Assert.Equal(expected.Description, actual.Description); + Assert.Equal(expected.JsonSchema, actual.JsonSchema); + Assert.Equal(expected.ReturnJsonSchema, actual.ReturnJsonSchema); + Assert.Same(expected.JsonSerializerOptions, actual.JsonSerializerOptions); + Assert.Same(expected.UnderlyingMethod, actual.UnderlyingMethod); + Assert.Same(expected.AdditionalProperties, actual.AdditionalProperties); + Assert.Equal(expected.ToString(), actual.ToString()); + } + + private sealed class DerivedFunction(AIFunction innerFunction) : DelegatingAIFunction(innerFunction) + { + public new AIFunction InnerFunction => base.InnerFunction; + } + + [Fact] + public void Virtuals_AllOverridden() + { + Assert.All(typeof(DelegatingAIFunction).GetMembers(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance), m => + { + switch (m) + { + case MethodInfo methodInfo when methodInfo.IsVirtual && methodInfo.Name is not ("Finalize" or "Equals" or "GetHashCode"): + Assert.True(methodInfo.DeclaringType == typeof(DelegatingAIFunction), $"{methodInfo.Name} not overridden"); + break; + + case PropertyInfo propertyInfo when propertyInfo.GetMethod?.IsVirtual is true: + Assert.True(propertyInfo.DeclaringType == typeof(DelegatingAIFunction), $"{propertyInfo.Name} not overridden"); + break; + } + }); + } + + [Fact] + public async Task OverriddenInvocation_SuccessfullyInvoked() + { + bool innerInvoked = false; + AIFunction inner = AIFunctionFactory.Create(int () => + { + innerInvoked = true; + throw new Exception("uh oh"); + }, "TestFunction", "A test function for DelegatingAIFunction"); + + AIFunction actual = new OverridesInvocation(inner, (args, ct) => new ValueTask(84)); + + Assert.Equal(inner.Name, actual.Name); + Assert.Equal(inner.Description, actual.Description); + Assert.Equal(inner.JsonSchema, actual.JsonSchema); + Assert.Equal(inner.ReturnJsonSchema, actual.ReturnJsonSchema); + Assert.Same(inner.JsonSerializerOptions, actual.JsonSerializerOptions); + Assert.Same(inner.UnderlyingMethod, actual.UnderlyingMethod); + Assert.Same(inner.AdditionalProperties, actual.AdditionalProperties); + Assert.Equal(inner.ToString(), actual.ToString()); + + object? result = await actual.InvokeAsync(new(), CancellationToken.None); + Assert.Contains("84", result?.ToString()); + + Assert.False(innerInvoked); + } + + private sealed class OverridesInvocation(AIFunction innerFunction, Func> invokeAsync) : DelegatingAIFunction(innerFunction) + { + protected override ValueTask InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) => + invokeAsync(arguments, cancellationToken); + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Evaluation.Integration.Tests/Microsoft.Extensions.AI.Evaluation.Integration.Tests.csproj b/test/Libraries/Microsoft.Extensions.AI.Evaluation.Integration.Tests/Microsoft.Extensions.AI.Evaluation.Integration.Tests.csproj index c08667ff421..6e3332ebca6 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Evaluation.Integration.Tests/Microsoft.Extensions.AI.Evaluation.Integration.Tests.csproj +++ b/test/Libraries/Microsoft.Extensions.AI.Evaluation.Integration.Tests/Microsoft.Extensions.AI.Evaluation.Integration.Tests.csproj @@ -28,6 +28,7 @@ + diff --git a/test/Libraries/Microsoft.Extensions.AI.Evaluation.Integration.Tests/NLPEvaluatorTests.cs b/test/Libraries/Microsoft.Extensions.AI.Evaluation.Integration.Tests/NLPEvaluatorTests.cs new file mode 100644 index 00000000000..a4f3b75045a --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Evaluation.Integration.Tests/NLPEvaluatorTests.cs @@ -0,0 +1,162 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#pragma warning disable CA2016 // Forward the 'CancellationToken' parameter to methods that take it. +#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. + +using System; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Extensions.AI.Evaluation.NLP; +using Microsoft.Extensions.AI.Evaluation.Reporting; +using Microsoft.Extensions.AI.Evaluation.Reporting.Storage; +using Microsoft.TestUtilities; +using Xunit; + +namespace Microsoft.Extensions.AI.Evaluation.Integration.Tests; + +[Experimental("AIEVAL001")] +public class NLPEvaluatorTests +{ + private static readonly ReportingConfiguration? _nlpReportingConfiguration; + + static NLPEvaluatorTests() + { + if (Settings.Current.Configured) + { + string version = $"Product Version: {Constants.Version}"; + string date = $"Date: {DateTime.UtcNow:dddd, dd MMMM yyyy}"; + string projectName = $"Project: Integration Tests"; + string testClass = $"Test Class: {nameof(NLPEvaluatorTests)}"; + string usesContext = $"Feature: Context"; + + IEvaluator bleuEvaluator = new BLEUEvaluator(); + IEvaluator gleuEvaluator = new GLEUEvaluator(); + IEvaluator f1Evaluator = new F1Evaluator(); + + _nlpReportingConfiguration = + DiskBasedReportingConfiguration.Create( + storageRootPath: Settings.Current.StorageRootPath, + evaluators: [bleuEvaluator, gleuEvaluator, f1Evaluator], + executionName: Constants.Version, + tags: [version, date, projectName, testClass, usesContext]); + } + } + + [ConditionalFact] + public async Task ExactMatch() + { + SkipIfNotConfigured(); + + await using ScenarioRun scenarioRun = + await _nlpReportingConfiguration.CreateScenarioRunAsync( + scenarioName: $"Microsoft.Extensions.AI.Evaluation.Integration.Tests.{nameof(NLPEvaluatorTests)}.{nameof(ExactMatch)}"); + + var referenceText = "The quick brown fox jumps over the lazy dog."; + var bleuContext = new BLEUEvaluatorContext(referenceText); + var gleuContext = new GLEUEvaluatorContext(referenceText); + var f1Context = new F1EvaluatorContext(referenceText); + + EvaluationResult result = await scenarioRun.EvaluateAsync(referenceText, [bleuContext, gleuContext, f1Context]); + + Assert.False( + result.ContainsDiagnostics(d => d.Severity >= EvaluationDiagnosticSeverity.Warning), + string.Join("\r\n\r\n", result.Metrics.Values.SelectMany(m => m.Diagnostics ?? []).Select(d => d.ToString()))); + + Assert.Equal(3, result.Metrics.Count); + Assert.True(result.TryGet(BLEUEvaluator.BLEUMetricName, out NumericMetric? _)); + Assert.True(result.TryGet(GLEUEvaluator.GLEUMetricName, out NumericMetric? _)); + Assert.True(result.TryGet(F1Evaluator.F1MetricName, out NumericMetric? _)); + } + + [ConditionalFact] + public async Task PartialMatch() + { + SkipIfNotConfigured(); + + await using ScenarioRun scenarioRun = + await _nlpReportingConfiguration.CreateScenarioRunAsync( + scenarioName: $"Microsoft.Extensions.AI.Evaluation.Integration.Tests.{nameof(NLPEvaluatorTests)}.{nameof(PartialMatch)}"); + + var referenceText = "The quick brown fox jumps over the lazy dog."; + var bleuContext = new BLEUEvaluatorContext(referenceText); + var gleuContext = new GLEUEvaluatorContext(referenceText); + var f1Context = new F1EvaluatorContext(referenceText); + + var similarText = "The brown fox quickly jumps over a lazy dog."; + EvaluationResult result = await scenarioRun.EvaluateAsync(similarText, [bleuContext, gleuContext, f1Context]); + + Assert.False( + result.ContainsDiagnostics(d => d.Severity >= EvaluationDiagnosticSeverity.Warning), + string.Join("\r\n\r\n", result.Metrics.Values.SelectMany(m => m.Diagnostics ?? []).Select(d => d.ToString()))); + + Assert.Equal(3, result.Metrics.Count); + Assert.True(result.TryGet(BLEUEvaluator.BLEUMetricName, out NumericMetric? _)); + Assert.True(result.TryGet(GLEUEvaluator.GLEUMetricName, out NumericMetric? _)); + Assert.True(result.TryGet(F1Evaluator.F1MetricName, out NumericMetric? _)); + } + + [ConditionalFact] + public async Task Unmatched() + { + SkipIfNotConfigured(); + + await using ScenarioRun scenarioRun = + await _nlpReportingConfiguration.CreateScenarioRunAsync( + scenarioName: $"Microsoft.Extensions.AI.Evaluation.Integration.Tests.{nameof(NLPEvaluatorTests)}.{nameof(Unmatched)}"); + + var referenceText = "The quick brown fox jumps over the lazy dog."; + var bleuContext = new BLEUEvaluatorContext(referenceText); + var gleuContext = new GLEUEvaluatorContext(referenceText); + var f1Context = new F1EvaluatorContext(referenceText); + + EvaluationResult result = await scenarioRun.EvaluateAsync("What is life's meaning?", [bleuContext, gleuContext, f1Context]); + + Assert.False( + result.ContainsDiagnostics(d => d.Severity >= EvaluationDiagnosticSeverity.Warning), + string.Join("\r\n\r\n", result.Metrics.Values.SelectMany(m => m.Diagnostics ?? []).Select(d => d.ToString()))); + + Assert.Equal(3, result.Metrics.Count); + Assert.True(result.TryGet(BLEUEvaluator.BLEUMetricName, out NumericMetric? _)); + Assert.True(result.TryGet(GLEUEvaluator.GLEUMetricName, out NumericMetric? _)); + Assert.True(result.TryGet(F1Evaluator.F1MetricName, out NumericMetric? _)); + } + + [ConditionalFact] + public async Task AdditionalContextIsNotPassed() + { + SkipIfNotConfigured(); + + await using ScenarioRun scenarioRun = + await _nlpReportingConfiguration.CreateScenarioRunAsync( + scenarioName: $"Microsoft.Extensions.AI.Evaluation.Integration.Tests.{nameof(NLPEvaluatorTests)}.{nameof(AdditionalContextIsNotPassed)}"); + + EvaluationResult result = await scenarioRun.EvaluateAsync("What is the meaning of life?"); + + Assert.True( + result.Metrics.Values.All(m => m.ContainsDiagnostics(d => d.Severity is EvaluationDiagnosticSeverity.Error)), + string.Join("\r\n\r\n", result.Metrics.Values.SelectMany(m => m.Diagnostics ?? []).Select(d => d.ToString()))); + + Assert.Equal(3, result.Metrics.Count); + Assert.True(result.TryGet(BLEUEvaluator.BLEUMetricName, out NumericMetric? bleu)); + Assert.True(result.TryGet(GLEUEvaluator.GLEUMetricName, out NumericMetric? gleu)); + Assert.True(result.TryGet(F1Evaluator.F1MetricName, out NumericMetric? f1)); + + Assert.Null(bleu.Context); + Assert.Null(gleu.Context); + Assert.Null(f1.Context); + + } + + [MemberNotNull(nameof(_nlpReportingConfiguration))] + private static void SkipIfNotConfigured() + { + if (!Settings.Current.Configured) + { + throw new SkipTestException("Test is not configured"); + } + + Assert.NotNull(_nlpReportingConfiguration); + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/ChatClientIntegrationTests.cs b/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/ChatClientIntegrationTests.cs index d84d767fd4c..ffa94f64531 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/ChatClientIntegrationTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/ChatClientIntegrationTests.cs @@ -341,6 +341,39 @@ public virtual async Task FunctionInvocation_NestedParameters() AssertUsageAgainstActivities(response, activities); } + [ConditionalFact] + public virtual async Task FunctionInvocation_ArrayParameter() + { + SkipIfNotEnabled(); + + var sourceName = Guid.NewGuid().ToString(); + var activities = new List(); + using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder() + .AddSource(sourceName) + .AddInMemoryExporter(activities) + .Build(); + + using var chatClient = new FunctionInvokingChatClient( + new OpenTelemetryChatClient(_chatClient, sourceName: sourceName)); + + List messages = + [ + new(ChatRole.User, "Can you add bacon, lettuce, and tomatoes to Peter's shopping cart?") + ]; + + string? shopperName = null; + List shoppingCart = []; + AIFunction func = AIFunctionFactory.Create((string[] items, string shopperId) => { shoppingCart.AddRange(items); shopperName = shopperId; }, "AddItemsToShoppingCart"); + var response = await chatClient.GetResponseAsync(messages, new() + { + Tools = [func] + }); + + Assert.Equal("Peter", shopperName); + Assert.Equal(["bacon", "lettuce", "tomatoes"], shoppingCart); + AssertUsageAgainstActivities(response, activities); + } + private static void AssertUsageAgainstActivities(ChatResponse response, List activities) { // If the underlying IChatClient provides usage data, function invocation should aggregate the diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/Microsoft.Extensions.AI.OpenAI.Tests.csproj b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/Microsoft.Extensions.AI.OpenAI.Tests.csproj index 60b6f8c84ab..536c250cb47 100644 --- a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/Microsoft.Extensions.AI.OpenAI.Tests.csproj +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/Microsoft.Extensions.AI.OpenAI.Tests.csproj @@ -2,7 +2,8 @@ Microsoft.Extensions.AI Unit tests for Microsoft.Extensions.AI.OpenAI - $(NoWarn);OPENAI002;MEAI001;S104 + $(NoWarn);S104 + $(NoWarn);OPENAI001;MEAI001 diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIAIFunctionConversionTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIAIFunctionConversionTests.cs index f2f0c9d8a3f..ce458473c59 100644 --- a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIAIFunctionConversionTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIAIFunctionConversionTests.cs @@ -6,12 +6,10 @@ using System.Text.Json; using OpenAI.Assistants; using OpenAI.Chat; -using OpenAI.RealtimeConversation; +using OpenAI.Realtime; using OpenAI.Responses; using Xunit; -#pragma warning disable OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. - namespace Microsoft.Extensions.AI; public class OpenAIAIFunctionConversionTests @@ -24,7 +22,7 @@ public class OpenAIAIFunctionConversionTests [Fact] public void AsOpenAIChatTool_ProducesValidInstance() { - ChatTool tool = _testFunction.AsOpenAIChatTool(); + var tool = _testFunction.AsOpenAIChatTool(); Assert.NotNull(tool); Assert.Equal("test_function", tool.FunctionName); @@ -35,7 +33,7 @@ public void AsOpenAIChatTool_ProducesValidInstance() [Fact] public void AsOpenAIResponseTool_ProducesValidInstance() { - ResponseTool tool = _testFunction.AsOpenAIResponseTool(); + var tool = _testFunction.AsOpenAIResponseTool(); Assert.NotNull(tool); } @@ -43,7 +41,7 @@ public void AsOpenAIResponseTool_ProducesValidInstance() [Fact] public void AsOpenAIConversationFunctionTool_ProducesValidInstance() { - ConversationFunctionTool tool = _testFunction.AsOpenAIConversationFunctionTool(); + var tool = _testFunction.AsOpenAIConversationFunctionTool(); Assert.NotNull(tool); Assert.Equal("test_function", tool.Name); @@ -54,7 +52,7 @@ public void AsOpenAIConversationFunctionTool_ProducesValidInstance() [Fact] public void AsOpenAIAssistantsFunctionToolDefinition_ProducesValidInstance() { - FunctionToolDefinition tool = _testFunction.AsOpenAIAssistantsFunctionToolDefinition(); + var tool = _testFunction.AsOpenAIAssistantsFunctionToolDefinition(); Assert.NotNull(tool); Assert.Equal("test_function", tool.FunctionName); @@ -62,7 +60,7 @@ public void AsOpenAIAssistantsFunctionToolDefinition_ProducesValidInstance() ValidateSchemaParameters(tool.Parameters); } - /// Helper method to validate function parameters match our schema + /// Helper method to validate function parameters match our schema. private static void ValidateSchemaParameters(BinaryData parameters) { Assert.NotNull(parameters); diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIAssistantChatClientIntegrationTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIAssistantChatClientIntegrationTests.cs index e616d5fb87b..90bcf9f2632 100644 --- a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIAssistantChatClientIntegrationTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIAssistantChatClientIntegrationTests.cs @@ -1,7 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -#pragma warning disable OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. #pragma warning disable CA1822 // Mark members as static #pragma warning disable CA2000 // Dispose objects before losing scope #pragma warning disable S1135 // Track uses of "TODO" tags @@ -62,7 +61,7 @@ public async Task DeleteAllThreads() client.DefaultRequestHeaders.Add("openai-organization", "org-ENTERYOURORGID"); client.DefaultRequestHeaders.Add("openai-project", "proj_ENTERYOURPROJECTID"); - AssistantClient ac = new AssistantClient(Environment.GetEnvironmentVariable("AI:OpenAI:ApiKey")!); + AssistantClient ac = new(Environment.GetEnvironmentVariable("AI:OpenAI:ApiKey")!); while (true) { string listing = await client.GetStringAsync("https://api.openai.com/v1/threads?limit=100"); diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIAssistantChatClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIAssistantChatClientTests.cs index 6d3a02a08ec..3b084b5ec8a 100644 --- a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIAssistantChatClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIAssistantChatClientTests.cs @@ -3,7 +3,6 @@ using System; using System.ClientModel; -using Azure.AI.OpenAI; using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.Caching.Memory; using OpenAI; @@ -11,7 +10,6 @@ using Xunit; #pragma warning disable S103 // Lines should not be too long -#pragma warning disable OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. namespace Microsoft.Extensions.AI; @@ -24,16 +22,12 @@ public void AsIChatClient_InvalidArgs_Throws() Assert.Throws("assistantId", () => new AssistantClient("ignored").AsIChatClient(null!)); } - [Theory] - [InlineData(false)] - [InlineData(true)] - public void AsIChatClient_OpenAIClient_ProducesExpectedMetadata(bool useAzureOpenAI) + [Fact] + public void AsIChatClient_OpenAIClient_ProducesExpectedMetadata() { Uri endpoint = new("http://localhost/some/endpoint"); - var client = useAzureOpenAI ? - new AzureOpenAIClient(endpoint, new ApiKeyCredential("key")) : - new OpenAIClient(new ApiKeyCredential("key"), new OpenAIClientOptions { Endpoint = endpoint }); + var client = new OpenAIClient(new ApiKeyCredential("key"), new OpenAIClientOptions { Endpoint = endpoint }); IChatClient[] clients = [ diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIChatClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIChatClientTests.cs index 30d03b6eee3..d06d8f520be 100644 --- a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIChatClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIChatClientTests.cs @@ -11,7 +11,6 @@ using System.Text.Json; using System.Text.Json.Serialization; using System.Threading.Tasks; -using Azure.AI.OpenAI; using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.Caching.Memory; using OpenAI; @@ -30,17 +29,13 @@ public void AsIChatClient_InvalidArgs_Throws() Assert.Throws("chatClient", () => ((ChatClient)null!).AsIChatClient()); } - [Theory] - [InlineData(false)] - [InlineData(true)] - public void AsIChatClient_OpenAIClient_ProducesExpectedMetadata(bool useAzureOpenAI) + [Fact] + public void AsIChatClient_OpenAIClient_ProducesExpectedMetadata() { Uri endpoint = new("http://localhost/some/endpoint"); string model = "amazingModel"; - var client = useAzureOpenAI ? - new AzureOpenAIClient(endpoint, new ApiKeyCredential("key")) : - new OpenAIClient(new ApiKeyCredential("key"), new OpenAIClientOptions { Endpoint = endpoint }); + var client = new OpenAIClient(new ApiKeyCredential("key"), new OpenAIClientOptions { Endpoint = endpoint }); IChatClient chatClient = client.GetChatClient(model).AsIChatClient(); var metadata = chatClient.GetService(); @@ -276,6 +271,74 @@ public async Task BasicRequestResponse_Streaming() }, usage.Details.AdditionalCounts); } + [Fact] + public async Task ChatOptions_StrictRespected() + { + const string Input = """ + { + "tools": [ + { + "function": { + "description": "Gets the age of the specified person.", + "name": "GetPersonAge", + "strict": true, + "parameters": { + "type": "object", + "required": [], + "properties": {}, + "additionalProperties": false + } + }, + "type": "function" + } + ], + "messages": [ + { + "role": "user", + "content": "hello" + } + ], + "model": "gpt-4o-mini", + "tool_choice": "auto" + } + """; + + const string Output = """ + { + "id": "chatcmpl-ADx3PvAnCwJg0woha4pYsBTi3ZpOI", + "object": "chat.completion", + "created": 1727888631, + "model": "gpt-4o-mini-2024-07-18", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "Hello! How can I assist you today?", + "refusal": null + }, + "logprobs": null, + "finish_reason": "stop" + } + ] + } + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateChatClient(httpClient, "gpt-4o-mini"); + + var response = await client.GetResponseAsync("hello", new() + { + Tools = [AIFunctionFactory.Create(() => 42, "GetPersonAge", "Gets the age of the specified person.")], + AdditionalProperties = new() + { + ["strictJsonSchema"] = true, + }, + }); + Assert.NotNull(response); + } + [Fact] public async Task ChatOptions_DoNotOverwrite_NotNullPropertiesInRawRepresentation_NonStreaming() { @@ -330,14 +393,12 @@ public async Task ChatOptions_DoNotOverwrite_NotNullPropertiesInRawRepresentatio TopP = 0.5f, PresencePenalty = 0.5f, Temperature = 0.5f, -#pragma warning disable OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Seed = 42, -#pragma warning restore OPENAI001 ToolChoice = ChatToolChoice.CreateAutoChoice(), ResponseFormat = OpenAI.Chat.ChatResponseFormat.CreateTextFormat() }; openAIOptions.StopSequences.Add("hello"); - openAIOptions.Tools.Add(ToOpenAIChatTool(tool)); + openAIOptions.Tools.Add(OpenAIClientExtensions.AsOpenAIChatTool(tool)); return openAIOptions; }, ModelId = null, @@ -409,14 +470,12 @@ public async Task ChatOptions_DoNotOverwrite_NotNullPropertiesInRawRepresentatio TopP = 0.5f, PresencePenalty = 0.5f, Temperature = 0.5f, -#pragma warning disable OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Seed = 42, -#pragma warning restore OPENAI001 ToolChoice = ChatToolChoice.CreateAutoChoice(), ResponseFormat = OpenAI.Chat.ChatResponseFormat.CreateTextFormat() }; openAIOptions.StopSequences.Add("hello"); - openAIOptions.Tools.Add(ToOpenAIChatTool(tool)); + openAIOptions.Tools.Add(OpenAIClientExtensions.AsOpenAIChatTool(tool)); return openAIOptions; }, ModelId = null, // has no effect, you cannot change the model of an OpenAI's ChatClient. @@ -493,9 +552,7 @@ public async Task ChatOptions_Overwrite_NullPropertiesInRawRepresentation_NonStr Assert.Null(openAIOptions.TopP); Assert.Null(openAIOptions.PresencePenalty); Assert.Null(openAIOptions.Temperature); -#pragma warning disable OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Assert.Null(openAIOptions.Seed); -#pragma warning restore OPENAI001 Assert.Empty(openAIOptions.StopSequences); Assert.Empty(openAIOptions.Tools); Assert.Null(openAIOptions.ToolChoice); @@ -569,9 +626,7 @@ public async Task ChatOptions_Overwrite_NullPropertiesInRawRepresentation_Stream Assert.Null(openAIOptions.TopP); Assert.Null(openAIOptions.PresencePenalty); Assert.Null(openAIOptions.Temperature); -#pragma warning disable OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Assert.Null(openAIOptions.Seed); -#pragma warning restore OPENAI001 Assert.Empty(openAIOptions.StopSequences); Assert.Empty(openAIOptions.Tools); Assert.Null(openAIOptions.ToolChoice); @@ -600,20 +655,6 @@ public async Task ChatOptions_Overwrite_NullPropertiesInRawRepresentation_Stream Assert.Equal("Hello! How can I assist you today?", responseText); } - /// Converts an Extensions function to an OpenAI chat tool. - private static ChatTool ToOpenAIChatTool(AIFunction aiFunction) - { - bool? strict = - aiFunction.AdditionalProperties.TryGetValue("strictJsonSchema", out object? strictObj) && - strictObj is bool strictValue ? - strictValue : null; - - // Map to an intermediate model so that redundant properties are skipped. - var tool = JsonSerializer.Deserialize(aiFunction.JsonSchema)!; - var functionParameters = BinaryData.FromBytes(JsonSerializer.SerializeToUtf8Bytes(tool)); - return ChatTool.CreateFunctionTool(aiFunction.Name, aiFunction.Description, functionParameters, strict); - } - /// Used to create the JSON payload for an OpenAI chat tool description. internal sealed class ChatToolJson { diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIEmbeddingGeneratorTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIEmbeddingGeneratorTests.cs index 9d8a1219ea7..43112fa88e3 100644 --- a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIEmbeddingGeneratorTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIEmbeddingGeneratorTests.cs @@ -6,7 +6,6 @@ using System.ClientModel.Primitives; using System.Net.Http; using System.Threading.Tasks; -using Azure.AI.OpenAI; using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.Caching.Memory; using OpenAI; @@ -25,17 +24,13 @@ public void AsIEmbeddingGenerator_InvalidArgs_Throws() Assert.Throws("embeddingClient", () => ((EmbeddingClient)null!).AsIEmbeddingGenerator()); } - [Theory] - [InlineData(false)] - [InlineData(true)] - public void AsIEmbeddingGenerator_OpenAIClient_ProducesExpectedMetadata(bool useAzureOpenAI) + [Fact] + public void AsIEmbeddingGenerator_OpenAIClient_ProducesExpectedMetadata() { Uri endpoint = new("http://localhost/some/endpoint"); string model = "amazingModel"; - var client = useAzureOpenAI ? - new AzureOpenAIClient(endpoint, new ApiKeyCredential("key")) : - new OpenAIClient(new ApiKeyCredential("key"), new OpenAIClientOptions { Endpoint = endpoint }); + var client = new OpenAIClient(new ApiKeyCredential("key"), new OpenAIClientOptions { Endpoint = endpoint }); IEmbeddingGenerator> embeddingGenerator = client.GetEmbeddingClient(model).AsIEmbeddingGenerator(); var metadata = embeddingGenerator.GetService(); diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientTests.cs index 8b27cd918a7..b98eb89197f 100644 --- a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientTests.cs @@ -10,7 +10,6 @@ using System.Net.Http; using System.Text.Json; using System.Threading.Tasks; -using Azure.AI.OpenAI; using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.Caching.Memory; using OpenAI; @@ -29,17 +28,13 @@ public void AsIChatClient_InvalidArgs_Throws() Assert.Throws("responseClient", () => ((OpenAIResponseClient)null!).AsIChatClient()); } - [Theory] - [InlineData(false)] - [InlineData(true)] - public void AsIChatClient_ProducesExpectedMetadata(bool useAzureOpenAI) + [Fact] + public void AsIChatClient_ProducesExpectedMetadata() { Uri endpoint = new("http://localhost/some/endpoint"); string model = "amazingModel"; - var client = useAzureOpenAI ? - new AzureOpenAIClient(endpoint, new ApiKeyCredential("key")) : - new OpenAIClient(new ApiKeyCredential("key"), new OpenAIClientOptions { Endpoint = endpoint }); + var client = new OpenAIClient(new ApiKeyCredential("key"), new OpenAIClientOptions { Endpoint = endpoint }); IChatClient chatClient = client.GetOpenAIResponseClient(model).AsIChatClient(); var metadata = chatClient.GetService(); @@ -288,6 +283,81 @@ public async Task BasicRequestResponse_Streaming() Assert.Equal(36, usage.Details.TotalTokenCount); } + [Fact] + public async Task ChatOptions_StrictRespected() + { + const string Input = """ + { + "model": "gpt-4o-mini", + "input": [ + { + "type": "message", + "role": "user", + "content": [ + { + "type": "input_text", + "text": "hello" + } + ] + } + ], + "tool_choice": "auto", + "tools": [ + { + "type": "function", + "name": "GetPersonAge", + "description": "Gets the age of the specified person.", + "parameters": { + "type": "object", + "required": [], + "properties": {}, + "additionalProperties": false + }, + "strict": true + } + ] + } + """; + + const string Output = """ + { + "id": "resp_67d327649b288191aeb46a824e49dc40058a5e08c46a181d", + "object": "response", + "status": "completed", + "model": "gpt-4o-mini-2024-07-18", + "output": [ + { + "type": "message", + "id": "msg_67d32764fcdc8191bcf2e444d4088804058a5e08c46a181d", + "status": "completed", + "role": "assistant", + "content": [ + { + "type": "output_text", + "text": "Hello! How can I assist you today?", + "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 = [AIFunctionFactory.Create(() => 42, "GetPersonAge", "Gets the age of the specified person.")], + AdditionalProperties = new() + { + ["strictJsonSchema"] = true, + }, + }); + Assert.NotNull(response); + } + [Fact] public async Task ChatOptions_DoNotOverwrite_NotNullPropertiesInRawRepresentation_NonStreaming() { diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAISpeechToTextClientIntegrationTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAISpeechToTextClientIntegrationTests.cs index c80b37c865e..bb721806ff6 100644 --- a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAISpeechToTextClientIntegrationTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAISpeechToTextClientIntegrationTests.cs @@ -7,6 +7,6 @@ public class OpenAISpeechToTextClientIntegrationTests : SpeechToTextClientIntegr { protected override ISpeechToTextClient? CreateClient() => IntegrationTestHelpers.GetOpenAIClient()? - .GetAudioClient(TestRunnerConfiguration.Instance["OpenAI:AudioTranscriptionModel"] ?? "whisper-1") + .GetAudioClient(TestRunnerConfiguration.Instance["OpenAI:AudioTranscriptionModel"] ?? "gpt-4o-mini-transcribe") .AsISpeechToTextClient(); } diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAISpeechToTextClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAISpeechToTextClientTests.cs index c92d9627968..1252a20741b 100644 --- a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAISpeechToTextClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAISpeechToTextClientTests.cs @@ -8,7 +8,6 @@ using System.Net.Http; using System.Threading; using System.Threading.Tasks; -using Azure.AI.OpenAI; using Microsoft.Extensions.Logging; using OpenAI; using OpenAI.Audio; @@ -26,17 +25,13 @@ public void AsISpeechToTextClient_InvalidArgs_Throws() Assert.Throws("audioClient", () => ((AudioClient)null!).AsISpeechToTextClient()); } - [Theory] - [InlineData(false)] - [InlineData(true)] - public void AsISpeechToTextClient_AudioClient_ProducesExpectedMetadata(bool useAzureOpenAI) + [Fact] + public void AsISpeechToTextClient_AudioClient_ProducesExpectedMetadata() { Uri endpoint = new("http://localhost/some/endpoint"); string model = "amazingModel"; - var client = useAzureOpenAI ? - new AzureOpenAIClient(endpoint, new ApiKeyCredential("key")) : - new OpenAIClient(new ApiKeyCredential("key"), new OpenAIClientOptions { Endpoint = endpoint }); + var client = new OpenAIClient(new ApiKeyCredential("key"), new OpenAIClientOptions { Endpoint = endpoint }); ISpeechToTextClient speechToTextClient = client.GetAudioClient(model).AsISpeechToTextClient(); var metadata = speechToTextClient.GetService(); @@ -148,7 +143,8 @@ public async Task GetStreamingTextAsync_BasicRequestResponse(string? speechLangu string input = $$""" { "model": "whisper-1", - "language": "{{speechLanguage}}" + "language": "{{speechLanguage}}", + "stream":true } """; diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientTests.cs index 26554946dca..1379cef8bf0 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientTests.cs @@ -6,6 +6,7 @@ using System.Collections.Generic; using System.Diagnostics; using System.Linq; +using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.DependencyInjection; @@ -37,6 +38,35 @@ public void Ctor_HasExpectedDefaults() Assert.False(client.IncludeDetailedErrors); Assert.Equal(10, client.MaximumIterationsPerRequest); Assert.Equal(3, client.MaximumConsecutiveErrorsPerRequest); + Assert.Null(client.FunctionInvoker); + } + + [Fact] + public void Properties_Roundtrip() + { + using TestChatClient innerClient = new(); + using FunctionInvokingChatClient client = new(innerClient); + + Assert.False(client.AllowConcurrentInvocation); + client.AllowConcurrentInvocation = true; + Assert.True(client.AllowConcurrentInvocation); + + Assert.False(client.IncludeDetailedErrors); + client.IncludeDetailedErrors = true; + Assert.True(client.IncludeDetailedErrors); + + Assert.Equal(10, client.MaximumIterationsPerRequest); + client.MaximumIterationsPerRequest = 5; + Assert.Equal(5, client.MaximumIterationsPerRequest); + + Assert.Equal(3, client.MaximumConsecutiveErrorsPerRequest); + client.MaximumConsecutiveErrorsPerRequest = 1; + Assert.Equal(1, client.MaximumConsecutiveErrorsPerRequest); + + Assert.Null(client.FunctionInvoker); + Func> invoker = (ctx, ct) => new ValueTask("test"); + client.FunctionInvoker = invoker; + Assert.Same(invoker, client.FunctionInvoker); } [Fact] @@ -208,6 +238,49 @@ public async Task ConcurrentInvocationOfParallelCallsDisabledByDefaultAsync() await InvokeAndAssertStreamingAsync(options, plan); } + [Fact] + public async Task FunctionInvokerDelegateOverridesHandlingAsync() + { + var options = new ChatOptions + { + Tools = + [ + AIFunctionFactory.Create(() => "Result 1", "Func1"), + AIFunctionFactory.Create((int i) => $"Result 2: {i}", "Func2"), + AIFunctionFactory.Create((int i) => { }, "VoidReturn"), + ] + }; + + List plan = + [ + new ChatMessage(ChatRole.User, "hello"), + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1")]), + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId1", result: "Result 1 from delegate")]), + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })]), + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId2", result: "Result 2: 42 from delegate")]), + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId3", "VoidReturn", arguments: new Dictionary { { "i", 43 } })]), + new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId3", result: "Success: Function completed.")]), + new ChatMessage(ChatRole.Assistant, "world"), + ]; + + Func configure = b => b.Use( + s => new FunctionInvokingChatClient(s) + { + FunctionInvoker = async (ctx, cancellationToken) => + { + Assert.NotNull(ctx); + var result = await ctx.Function.InvokeAsync(ctx.Arguments, cancellationToken); + return result is JsonElement e ? + JsonSerializer.SerializeToElement($"{e.GetString()} from delegate", AIJsonUtilities.DefaultOptions) : + result; + } + }); + + await InvokeAndAssertAsync(options, plan, configurePipeline: configure); + + await InvokeAndAssertStreamingAsync(options, plan, configurePipeline: configure); + } + [Fact] public async Task ContinuesWithSuccessfulCallsUntilMaximumIterations() { diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/Functions/AIFunctionFactoryTest.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/Functions/AIFunctionFactoryTest.cs index 84298788e8c..afced22038f 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Tests/Functions/AIFunctionFactoryTest.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/Functions/AIFunctionFactoryTest.cs @@ -6,6 +6,7 @@ using System.ComponentModel; using System.Reflection; using System.Text.Json; +using System.Text.Json.Nodes; using System.Text.Json.Serialization; using System.Threading; using System.Threading.Tasks; @@ -75,6 +76,69 @@ public async Task Parameters_MissingRequiredParametersFail_Async() } } + [Fact] + public async Task Parameters_ToleratesJsonEncodedParameters() + { + AIFunction func = AIFunctionFactory.Create((int x, int y, int z, int w, int u) => x + y + z + w + u); + + var result = await func.InvokeAsync(new() + { + ["x"] = "1", + ["y"] = JsonNode.Parse("2"), + ["z"] = JsonDocument.Parse("3"), + ["w"] = JsonDocument.Parse("4").RootElement, + ["u"] = 5M, // boxed decimal cannot be cast to int, requires conversion + }); + + AssertExtensions.EqualFunctionCallResults(15, result); + } + + [Theory] + [InlineData(" null")] + [InlineData(" false ")] + [InlineData("true ")] + [InlineData("42")] + [InlineData("0.0")] + [InlineData("-1e15")] + [InlineData(" \"I am a string!\" ")] + [InlineData(" {}")] + [InlineData("[]")] + [InlineData("// single-line comment\r\nnull")] + [InlineData("/* multi-line\r\ncomment */\r\nnull")] + public async Task Parameters_ToleratesJsonStringParameters(string jsonStringParam) + { + JsonSerializerOptions options = new(AIJsonUtilities.DefaultOptions) { ReadCommentHandling = JsonCommentHandling.Skip }; + AIFunction func = AIFunctionFactory.Create((JsonElement param) => param, serializerOptions: options); + JsonElement expectedResult = JsonDocument.Parse(jsonStringParam, new() { CommentHandling = JsonCommentHandling.Skip }).RootElement; + + var result = await func.InvokeAsync(new() + { + ["param"] = jsonStringParam + }); + + AssertExtensions.EqualFunctionCallResults(expectedResult, result); + } + + [Theory] + [InlineData("")] + [InlineData(" \r\n")] + [InlineData("I am a string!")] + [InlineData("/* Code snippet */ int main(void) { return 0; }")] + [InlineData("let rec Y F x = F (Y F) x")] + [InlineData("+3")] + public async Task Parameters_ToleratesInvalidJsonStringParameters(string invalidJsonParam) + { + AIFunction func = AIFunctionFactory.Create((JsonElement param) => param); + JsonElement expectedResult = JsonDocument.Parse(JsonSerializer.Serialize(invalidJsonParam, JsonContext.Default.String)).RootElement; + + var result = await func.InvokeAsync(new() + { + ["param"] = invalidJsonParam + }); + + AssertExtensions.EqualFunctionCallResults(expectedResult, result); + } + [Fact] public async Task Parameters_MappedByType_Async() { diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/McpServerSnapshotTests.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/McpServerSnapshotTests.cs new file mode 100644 index 00000000000..a3f3dedd1b5 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/McpServerSnapshotTests.cs @@ -0,0 +1,95 @@ +// 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 System.IO; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Extensions.AI.Templates.Tests; +using Microsoft.Extensions.Logging; +using Microsoft.TemplateEngine.Authoring.TemplateVerifier; +using Microsoft.TemplateEngine.TestHelper; +using Xunit; +using Xunit.Abstractions; + +namespace Microsoft.Extensions.AI.Templates.Tests; + +public class McpServerSnapshotTests +{ + // Keep the exclude patterns below in sync with those in Microsoft.Extensions.AI.Templates.csproj. + private static readonly string[] _verificationExcludePatterns = [ + "**/bin/**", + "**/obj/**", + "**/.vs/**", + "**/*.sln", + "**/*.in", + ]; + + private readonly ILogger _log; + + public McpServerSnapshotTests(ITestOutputHelper log) + { +#pragma warning disable CA2000 // Dispose objects before losing scope + _log = new XunitLoggerProvider(log).CreateLogger("TestRun"); +#pragma warning restore CA2000 // Dispose objects before losing scope + } + + [Fact] + public async Task BasicTest() + { + await TestTemplateCoreAsync(scenarioName: "Basic"); + } + + private async Task TestTemplateCoreAsync(string scenarioName, IEnumerable? templateArgs = null) + { + string workingDir = TestUtils.CreateTemporaryFolder(); + string templateShortName = "mcpserver"; + + // Get the template location + string templateLocation = Path.Combine(WellKnownPaths.TemplateFeedLocation, "Microsoft.Extensions.AI.Templates", "src", "McpServer"); + + var verificationExcludePatterns = Path.DirectorySeparatorChar is '/' + ? _verificationExcludePatterns + : _verificationExcludePatterns.Select(p => p.Replace('/', Path.DirectorySeparatorChar)).ToArray(); + + TemplateVerifierOptions options = new TemplateVerifierOptions(templateName: templateShortName) + { + TemplatePath = templateLocation, + TemplateSpecificArgs = templateArgs, + SnapshotsDirectory = "Snapshots", + OutputDirectory = workingDir, + DoNotPrependCallerMethodNameToScenarioName = true, + DoNotAppendTemplateArgsToScenarioName = true, + ScenarioName = scenarioName, + VerificationExcludePatterns = verificationExcludePatterns, + } + .WithCustomScrubbers( + ScrubbersDefinition.Empty.AddScrubber((path, content) => + { + string filePath = path.UnixifyDirSeparators(); + + if (filePath.EndsWith(".csproj")) + { + // Scrub references to just-built packages and remove the suffix, if it exists. + // This allows the snapshots to remain the same regardless of where the repo is built (e.g., locally, public CI, internal CI). + var pattern = @"(?<=)"; + content.ScrubByRegex(pattern, replacement: "$1"); + } + })); + + VerificationEngine engine = new VerificationEngine(_log); + await engine.Execute(options); + +#pragma warning disable CA1031 // Do not catch general exception types + try + { + Directory.Delete(workingDir, recursive: true); + } + catch + { + /* don't care */ + } +#pragma warning restore CA1031 // Do not catch general exception types + } +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.Basic.verified/mcpserver/.mcp/server.json b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.Basic.verified/mcpserver/.mcp/server.json new file mode 100644 index 00000000000..ab997541e52 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.Basic.verified/mcpserver/.mcp/server.json @@ -0,0 +1,20 @@ +{ + "description": "", + "name": "io.github./", + "packages": [ + { + "registry_name": "nuget", + "name": "", + "version": "0.1.0-beta", + "package_arguments": [], + "environment_variables": [] + } + ], + "repository": { + "url": "https://github.com//", + "source": "github" + }, + "version_detail": { + "version": "0.1.0-beta" + } +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.Basic.verified/mcpserver/Program.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.Basic.verified/mcpserver/Program.cs new file mode 100644 index 00000000000..73b72d35a46 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.Basic.verified/mcpserver/Program.cs @@ -0,0 +1,16 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +var builder = Host.CreateApplicationBuilder(args); + +// Configure all logs to go to stderr (stdout is used for the MCP protocol messages). +builder.Logging.AddConsole(o => o.LogToStandardErrorThreshold = LogLevel.Trace); + +// Add the MCP services: the transport to use (stdio) and the tools to register. +builder.Services + .AddMcpServer() + .WithStdioServerTransport() + .WithTools(); + +await builder.Build().RunAsync(); diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.Basic.verified/mcpserver/README.md b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.Basic.verified/mcpserver/README.md new file mode 100644 index 00000000000..5c00a3bf669 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.Basic.verified/mcpserver/README.md @@ -0,0 +1,80 @@ +# MCP Server + +This README was created using the C# MCP server template project. It demonstrates how you can easily create an MCP server using C# and then package it in a NuGet package. + +See [aka.ms/nuget/mcp/guide](https://aka.ms/nuget/mcp/guide) for the full guide. + +## Checklist before publishing to NuGet.org + +- Test the MCP server locally using the steps below. +- Update the package metadata in the .csproj file, in particular the ``. +- Update `.mcp/server.json` to declare your MCP server's inputs. + - See [configuring inputs](https://aka.ms/nuget/mcp/guide/configuring-inputs) for more details. +- Pack the project using `dotnet pack`. + +The `bin/Release` directory will contain the package file (.nupkg), which can be [published to NuGet.org](https://learn.microsoft.com/nuget/nuget-org/publish-a-package). + +## Using the MCP Server in VS Code + +Once the MCP server package is published to NuGet.org, you can use the following VS Code user configuration to download and install the MCP server package. See [Use MCP servers in VS Code (Preview)](https://code.visualstudio.com/docs/copilot/chat/mcp-servers) for more information about using MCP servers in VS Code. + +```json +{ + "mcp": { + "servers": { + "mcpserver": { + "type": "stdio", + "command": "dnx", + "args": [ + "", + "--version", + "", + "--yes" + ] + } + } + } +} +``` + +Now you can ask Copilot Chat for a random number, for example, `Give me 3 random numbers`. It should prompt you to use the `get_random_number` tool on the `mcpserver` MCP server and show you the results. + +## Developing locally in VS Code + +To test this MCP server from source code (locally) without using a built MCP server package, create a `.vscode/mcp.json` file (a VS Code workspace settings file) in your project directory and add the following configuration: + +```json +{ + "servers": { + "mcpserver": { + "type": "stdio", + "command": "dotnet", + "args": [ + "run", + "--project", + "" + ] + } + } +} +``` + +Alternatively, you can configure your VS Code user settings to use your local project: + +```json +{ + "mcp": { + "servers": { + "mcpserver": { + "type": "stdio", + "command": "dotnet", + "args": [ + "run", + "--project", + "" + ] + } + } + } +} +``` diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.Basic.verified/mcpserver/Tools/RandomNumberTools.cs b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.Basic.verified/mcpserver/Tools/RandomNumberTools.cs new file mode 100644 index 00000000000..611745f4129 --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.Basic.verified/mcpserver/Tools/RandomNumberTools.cs @@ -0,0 +1,18 @@ +using System.ComponentModel; +using ModelContextProtocol.Server; + +/// +/// Sample MCP tools for demonstration purposes. +/// These tools can be invoked by MCP clients to perform various operations. +/// +internal class RandomNumberTools +{ + [McpServerTool] + [Description("Generates a random number between the specified minimum and maximum values.")] + public int GetRandomNumber( + [Description("Minimum value (inclusive)")] int min = 0, + [Description("Maximum value (exclusive)")] int max = 100) + { + return Random.Shared.Next(min, max); + } +} diff --git a/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.Basic.verified/mcpserver/mcpserver.csproj b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.Basic.verified/mcpserver/mcpserver.csproj new file mode 100644 index 00000000000..e959c64702f --- /dev/null +++ b/test/ProjectTemplates/Microsoft.Extensions.AI.Templates.IntegrationTests/Snapshots/mcpserver.Basic.verified/mcpserver/mcpserver.csproj @@ -0,0 +1,32 @@ + + + + net10.0 + Exe + enable + enable + + + true + McpServer + + + README.md + SampleMcpServer + 0.1.0-beta + AI; MCP; server; stdio + An MCP server using the MCP C# SDK. + + + + + + + + + + + + + +