From 5fc37cab8e4870a0fbce780c1d6bc23128f21537 Mon Sep 17 00:00:00 2001 From: Chris Ayers Date: Wed, 8 Jul 2026 20:04:26 -0400 Subject: [PATCH 1/6] Update to .NET 10 and latest package versions Upgrade the entire solution from .NET 9 / Aspire 9.5.2 to .NET 10 / Aspire 13.4.6 and bump all packages to their latest versions. Framework & tooling: - global.json SDK -> 10.0.0; all projects -> net10.0 - launch.json, devcontainer image (dotnet:10.0), README prereq -> .NET 10 Key package updates: - Aspire hosting/testing -> 13.4.6 (AI previews 13.4.6-preview) - MCP SDK -> 1.4.0; Agent Framework -> 1.13.0; Extensions.AI -> 10.7.0 - EF Core / ASP.NET / STJ -> 10.0.9; Http.Resilience/ServiceDiscovery -> 10.7.0 - OpenTelemetry -> 1.16.0; bUnit -> 2.7.2 (dropped merged bunit.web) - Scalar, Azure.Identity, Foundry.Local, CommunityToolkit, Markdig, AngleSharp, coverlet, Test.Sdk Breaking-change fixes: - ChatClientAgentOptions.Instructions removed -> use ChatClientAgent (client, instructions:, name:) constructor - .WithOpenApi() deprecated in .NET 10 (ASPDEPR002) -> removed all calls - Extensions.AI 10.7 middleware options now nullable -> null guard (CS8602) - Foundry.Local 1.2.3 forces a RID -> portable RuntimeIdentifier on ApiService and its test project Hardening & cleanup: - Pin transitive Microsoft.OpenApi -> 2.10.0 (fixes GHSA-v5pm-xwqc-g5wc) - Remove now-redundant framework refs shipped in-box on net10 (NU1510) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .devcontainer/.net/devcontainer.json | 4 +- .vscode/launch.json | 8 ++-- README.md | 2 +- global.json | 2 +- .../BuildWithAspire.Abstractions.csproj | 4 +- .../BuildWithAspire.ApiService.csproj | 39 +++++++++++-------- .../Endpoints/ChatEndpoints.cs | 14 ++----- .../Endpoints/McpEndpoints.cs | 7 +--- .../Endpoints/WeatherEndpoints.cs | 8 +--- .../Services/ChatService.cs | 13 +++---- .../BuildWithAspire.AppHost.csproj | 23 ++++++----- .../BuildWithAspire.MCPServer.csproj | 16 ++++---- src/BuildWithAspire.MCPServer/Program.cs | 9 ++--- .../BuildWithAspire.ServiceDefaults.csproj | 16 ++++---- .../BuildWithAspire.Web.csproj | 7 ++-- ...uildWithAspire.ApiService.UnitTests.csproj | 18 +++++---- .../BuildWithAspire.AppHost.UnitTests.csproj | 8 ++-- .../BuildWithAspire.Web.UnitTests.csproj | 13 +++---- 18 files changed, 98 insertions(+), 113 deletions(-) diff --git a/.devcontainer/.net/devcontainer.json b/.devcontainer/.net/devcontainer.json index 3ded3c2..a349455 100644 --- a/.devcontainer/.net/devcontainer.json +++ b/.devcontainer/.net/devcontainer.json @@ -1,7 +1,7 @@ { "name": ".NET Aspire", - // Base .NET 9 image. Alternatively a Dockerfile/compose could be used. - "image": "mcr.microsoft.com/devcontainers/dotnet:9.0-bookworm", + // Base .NET 10 image. Alternatively a Dockerfile/compose could be used. + "image": "mcr.microsoft.com/devcontainers/dotnet:10.0-bookworm", "features": { "ghcr.io/devcontainers/features/docker-in-docker:3": {}, "ghcr.io/devcontainers/features/powershell:2": {}, diff --git a/.vscode/launch.json b/.vscode/launch.json index d16d4e2..c35f75b 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -12,7 +12,7 @@ "type": "coreclr", "request": "launch", "preLaunchTask": "build", // Name of your build task in tasks.json - "program": "${workspaceFolder}/src/BuildWithAspire.AppHost/bin/Debug/net9.0/BuildWithAspire.AppHost.dll", + "program": "${workspaceFolder}/src/BuildWithAspire.AppHost/bin/Debug/net10.0/BuildWithAspire.AppHost.dll", "args": ["--environment", "Ollama"], "cwd": "${workspaceFolder}/src/BuildWithAspire.AppHost", "stopAtEntry": false, @@ -23,7 +23,7 @@ "type": "coreclr", "request": "launch", "preLaunchTask": "build", // Name of your build task in tasks.json - "program": "${workspaceFolder}/src/BuildWithAspire.AppHost/bin/Debug/net9.0/BuildWithAspire.AppHost.dll", + "program": "${workspaceFolder}/src/BuildWithAspire.AppHost/bin/Debug/net10.0/BuildWithAspire.AppHost.dll", "args": ["--environment", "GitHubModels"], "cwd": "${workspaceFolder}/src/BuildWithAspire.AppHost", "stopAtEntry": false, @@ -34,7 +34,7 @@ "type": "coreclr", "request": "launch", "preLaunchTask": "build", // Name of your build task in tasks.json - "program": "${workspaceFolder}/src/BuildWithAspire.AppHost/bin/Debug/net9.0/BuildWithAspire.AppHost.dll", + "program": "${workspaceFolder}/src/BuildWithAspire.AppHost/bin/Debug/net10.0/BuildWithAspire.AppHost.dll", "args": ["--environment", "FoundryLocal"], "cwd": "${workspaceFolder}/src/BuildWithAspire.AppHost", "stopAtEntry": false, @@ -45,7 +45,7 @@ "type": "coreclr", "request": "launch", "preLaunchTask": "build", // Name of your build task in tasks.json - "program": "${workspaceFolder}/src/BuildWithAspire.AppHost/bin/Debug/net9.0/BuildWithAspire.AppHost.dll", + "program": "${workspaceFolder}/src/BuildWithAspire.AppHost/bin/Debug/net10.0/BuildWithAspire.AppHost.dll", "args": ["--environment", "AzureOpenAI"], "cwd": "${workspaceFolder}/src/BuildWithAspire.AppHost", "stopAtEntry": false, diff --git a/README.md b/README.md index e683e1a..f2c19af 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ Chat Agent WeatherTools ### Prerequisites -- .NET 9.0 SDK or later +- .NET 10.0 SDK or later - Docker Desktop (for Aspire) - Visual Studio Code or Visual Studio 2022 diff --git a/global.json b/global.json index a27a2b8..9a523dc 100644 --- a/global.json +++ b/global.json @@ -1,6 +1,6 @@ { "sdk": { - "version": "9.0.0", + "version": "10.0.0", "rollForward": "latestMajor", "allowPrerelease": false } diff --git a/src/BuildWithAspire.Abstractions/BuildWithAspire.Abstractions.csproj b/src/BuildWithAspire.Abstractions/BuildWithAspire.Abstractions.csproj index f87e644..2eeb9e0 100644 --- a/src/BuildWithAspire.Abstractions/BuildWithAspire.Abstractions.csproj +++ b/src/BuildWithAspire.Abstractions/BuildWithAspire.Abstractions.csproj @@ -1,11 +1,11 @@ - net9.0 + net10.0 enable enable - + diff --git a/src/BuildWithAspire.ApiService/BuildWithAspire.ApiService.csproj b/src/BuildWithAspire.ApiService/BuildWithAspire.ApiService.csproj index 5937d09..024408e 100644 --- a/src/BuildWithAspire.ApiService/BuildWithAspire.ApiService.csproj +++ b/src/BuildWithAspire.ApiService/BuildWithAspire.ApiService.csproj @@ -1,33 +1,38 @@  - net9.0 + net10.0 enable enable 02f7f1fc-3cf9-47fd-bb88-3a9b566a5bbe + + $(NETCoreSdkRuntimeIdentifier) - - - - - - - + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive all - - + + - - - - - - - + + + + + + diff --git a/src/BuildWithAspire.ApiService/Endpoints/ChatEndpoints.cs b/src/BuildWithAspire.ApiService/Endpoints/ChatEndpoints.cs index dacea68..4e1c6b5 100644 --- a/src/BuildWithAspire.ApiService/Endpoints/ChatEndpoints.cs +++ b/src/BuildWithAspire.ApiService/Endpoints/ChatEndpoints.cs @@ -16,31 +16,25 @@ public static RouteGroupBuilder MapChatEndpoints(this IEndpointRouteBuilder rout var group = routes.MapGroup("/conversations"); group.MapGet("/", GetConversations) - .WithName("GetConversations") - .WithOpenApi(); + .WithName("GetConversations"); group.MapGet("/{id}", GetConversation) - .WithName("GetConversation") - .WithOpenApi(); + .WithName("GetConversation"); group.MapPost("/", CreateConversation) - .WithName("CreateConversation") - .WithOpenApi(); + .WithName("CreateConversation"); group.MapPost("/{id}/messages", SendMessage) .WithName("SendMessage") - .WithOpenApi() .RequireRateLimiting("chat"); group.MapDelete("/{id}", DeleteConversation) - .WithName("DeleteConversation") - .WithOpenApi(); + .WithName("DeleteConversation"); // Legacy endpoint for backward compatibility routes.MapGet("/chat", async (ChatService chatService, string message) => await chatService.ProcessMessage(message).ConfigureAwait(false)) .WithName("GetChat") - .WithOpenApi() .RequireRateLimiting("chat"); return group; diff --git a/src/BuildWithAspire.ApiService/Endpoints/McpEndpoints.cs b/src/BuildWithAspire.ApiService/Endpoints/McpEndpoints.cs index 6fb84d5..722930f 100644 --- a/src/BuildWithAspire.ApiService/Endpoints/McpEndpoints.cs +++ b/src/BuildWithAspire.ApiService/Endpoints/McpEndpoints.cs @@ -14,16 +14,13 @@ public static RouteGroupBuilder MapMcpEndpoints(this IEndpointRouteBuilder route group.MapPost("/call/{toolName}", CallTool) .WithName("CallMcpTool") - .WithOpenApi() .RequireRateLimiting("weather"); group.MapGet("/tools", ListTools) - .WithName("ListMcpTools") - .WithOpenApi(); + .WithName("ListMcpTools"); group.MapGet("/health", HealthCheck) - .WithName("McpHealthCheck") - .WithOpenApi(); + .WithName("McpHealthCheck"); return group; } diff --git a/src/BuildWithAspire.ApiService/Endpoints/WeatherEndpoints.cs b/src/BuildWithAspire.ApiService/Endpoints/WeatherEndpoints.cs index 5b2c2dc..25365ad 100644 --- a/src/BuildWithAspire.ApiService/Endpoints/WeatherEndpoints.cs +++ b/src/BuildWithAspire.ApiService/Endpoints/WeatherEndpoints.cs @@ -14,7 +14,6 @@ public static RouteGroupBuilder MapWeatherEndpoints(this IEndpointRouteBuilder r group.MapGet("/", GetWeatherForecast) .WithName("GetWeatherForecast") - .WithOpenApi() .RequireRateLimiting("weather"); return group; @@ -28,11 +27,8 @@ private static IAsyncEnumerable GetWeatherForecast( var logger = loggerFactory.CreateLogger("WeatherForecast"); var weatherAgent = new Microsoft.Agents.AI.ChatClientAgent( client, - new Microsoft.Agents.AI.ChatClientAgentOptions - { - Name = "WeatherAssistant", - Instructions = "You are a helpful assistant that provides a description of the weather in one word based on the temperature." - }); + instructions: "You are a helpful assistant that provides a description of the weather in one word based on the temperature.", + name: "WeatherAssistant"); return GetForecasts(weatherAgent, logger, settings); } diff --git a/src/BuildWithAspire.ApiService/Services/ChatService.cs b/src/BuildWithAspire.ApiService/Services/ChatService.cs index d1dc684..2d79afb 100644 --- a/src/BuildWithAspire.ApiService/Services/ChatService.cs +++ b/src/BuildWithAspire.ApiService/Services/ChatService.cs @@ -51,13 +51,13 @@ private async Task EnsureInitializedAsync(CancellationToken cancellationToken = .Use((chatMessages, options, next, cancellationToken) => { // Inject tools into ChatOptions for every request - if (options.Tools?.Count is null or 0) + if (options is not null && options.Tools?.Count is null or 0) { options.Tools = _tools?.Select(t => (AITool)t).ToList(); _logger.LogInformation("Middleware: Injected {ToolCount} tools into ChatOptions", options.Tools?.Count ?? 0); } - var toolCount = options.Tools?.Count ?? 0; + var toolCount = options?.Tools?.Count ?? 0; _logger.LogInformation("Middleware: Sending request to model with {ToolCount} tools available", toolCount); var result = next(chatMessages, options, cancellationToken); _logger.LogInformation("Middleware: Received response from model"); @@ -69,16 +69,13 @@ private async Task EnsureInitializedAsync(CancellationToken cancellationToken = // Create an AI Agent using the Microsoft Agent Framework with dynamic tools _agent = new ChatClientAgent( toolEnabledClient, - new ChatClientAgentOptions - { - Name = "ChatAssistant", - Instructions = @"You are an AI demonstration application. + instructions: @"You are an AI demonstration application. You are a helpful chatbot with access to various tools dynamically discovered from the MCP server. Use the available tools when appropriate to provide accurate information. When a user asks for something that a tool can provide (like a random number, weather, calculations, etc.), USE THE TOOL instead of making up an answer. Respond to the user's input responsibly. - All responses should be safe for work." - }); + All responses should be safe for work.", + name: "ChatAssistant"); _isInitialized = true; _logger.LogInformation("AI Agent initialized with {ToolCount} MCP tools available", _tools.Count()); diff --git a/src/BuildWithAspire.AppHost/BuildWithAspire.AppHost.csproj b/src/BuildWithAspire.AppHost/BuildWithAspire.AppHost.csproj index c9b74f8..37ff24c 100644 --- a/src/BuildWithAspire.AppHost/BuildWithAspire.AppHost.csproj +++ b/src/BuildWithAspire.AppHost/BuildWithAspire.AppHost.csproj @@ -1,8 +1,8 @@ - + Exe - net9.0 + net10.0 enable enable true @@ -10,17 +10,16 @@ - - - - - - - + + + + + + + - - - + + diff --git a/src/BuildWithAspire.MCPServer/BuildWithAspire.MCPServer.csproj b/src/BuildWithAspire.MCPServer/BuildWithAspire.MCPServer.csproj index f5eaf7d..f1cb130 100644 --- a/src/BuildWithAspire.MCPServer/BuildWithAspire.MCPServer.csproj +++ b/src/BuildWithAspire.MCPServer/BuildWithAspire.MCPServer.csproj @@ -1,7 +1,7 @@ - net9.0 + net10.0 Major Exe enable @@ -24,13 +24,13 @@ - - - - - - - + + + + + + + diff --git a/src/BuildWithAspire.MCPServer/Program.cs b/src/BuildWithAspire.MCPServer/Program.cs index ab30ae7..92f5875 100644 --- a/src/BuildWithAspire.MCPServer/Program.cs +++ b/src/BuildWithAspire.MCPServer/Program.cs @@ -73,8 +73,7 @@ sessionSupport = "Yes - via Mcp-Session-Id header" }); }) -.WithName("HealthCheck") -.WithOpenApi(); +.WithName("HealthCheck"); // Add diagnostic endpoint to show session and transport information app.MapGet("/debug/info", () => @@ -99,8 +98,7 @@ note = "Session IDs are managed automatically by the SDK's StatefulSessionManager" }); }) -.WithName("DebugInfo") -.WithOpenApi(); +.WithName("DebugInfo"); // Add diagnostic endpoint to list registered tools (for debugging) app.MapGet("/debug/tools", () => @@ -134,8 +132,7 @@ } }); }) -.WithName("DebugTools") -.WithOpenApi(); +.WithName("DebugTools"); // Add OpenAPI for development if (app.Environment.IsDevelopment()) diff --git a/src/BuildWithAspire.ServiceDefaults/BuildWithAspire.ServiceDefaults.csproj b/src/BuildWithAspire.ServiceDefaults/BuildWithAspire.ServiceDefaults.csproj index 96158ee..bb37d29 100644 --- a/src/BuildWithAspire.ServiceDefaults/BuildWithAspire.ServiceDefaults.csproj +++ b/src/BuildWithAspire.ServiceDefaults/BuildWithAspire.ServiceDefaults.csproj @@ -1,7 +1,7 @@ - net9.0 + net10.0 enable enable true @@ -10,13 +10,13 @@ - - - - - - - + + + + + + + diff --git a/src/BuildWithAspire.Web/BuildWithAspire.Web.csproj b/src/BuildWithAspire.Web/BuildWithAspire.Web.csproj index 4831bd3..c6588db 100644 --- a/src/BuildWithAspire.Web/BuildWithAspire.Web.csproj +++ b/src/BuildWithAspire.Web/BuildWithAspire.Web.csproj @@ -1,16 +1,15 @@ - net9.0 + net10.0 enable enable - - + + - diff --git a/tests/BuildWithAspire.ApiService.UnitTests/BuildWithAspire.ApiService.UnitTests.csproj b/tests/BuildWithAspire.ApiService.UnitTests/BuildWithAspire.ApiService.UnitTests.csproj index 17f72ff..61f3f9d 100644 --- a/tests/BuildWithAspire.ApiService.UnitTests/BuildWithAspire.ApiService.UnitTests.csproj +++ b/tests/BuildWithAspire.ApiService.UnitTests/BuildWithAspire.ApiService.UnitTests.csproj @@ -1,31 +1,33 @@ - net9.0 + net10.0 enable enable false true $(NoWarn);xUnit1013 + + $(NETCoreSdkRuntimeIdentifier) - + runtime; build; native; contentfiles; analyzers; buildtransitive all - + runtime; build; native; contentfiles; analyzers; buildtransitive all - - - - - + + + + + diff --git a/tests/BuildWithAspire.AppHost.UnitTests/BuildWithAspire.AppHost.UnitTests.csproj b/tests/BuildWithAspire.AppHost.UnitTests/BuildWithAspire.AppHost.UnitTests.csproj index ef596f2..d871885 100644 --- a/tests/BuildWithAspire.AppHost.UnitTests/BuildWithAspire.AppHost.UnitTests.csproj +++ b/tests/BuildWithAspire.AppHost.UnitTests/BuildWithAspire.AppHost.UnitTests.csproj @@ -1,7 +1,7 @@ - net9.0 + net10.0 enable enable @@ -10,12 +10,12 @@ - - + + runtime; build; native; contentfiles; analyzers; buildtransitive all - + runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/tests/BuildWithAspire.Web.UnitTests/BuildWithAspire.Web.UnitTests.csproj b/tests/BuildWithAspire.Web.UnitTests/BuildWithAspire.Web.UnitTests.csproj index b1bafb2..f7abfe3 100644 --- a/tests/BuildWithAspire.Web.UnitTests/BuildWithAspire.Web.UnitTests.csproj +++ b/tests/BuildWithAspire.Web.UnitTests/BuildWithAspire.Web.UnitTests.csproj @@ -1,7 +1,7 @@ - net9.0 + net10.0 enable enable false @@ -10,21 +10,20 @@ - + runtime; build; native; contentfiles; analyzers; buildtransitive all - + runtime; build; native; contentfiles; analyzers; buildtransitive all - - - - + + + From 16dfe86933145851d59327f8c459e57683888222 Mon Sep 17 00:00:00 2001 From: Chris Ayers Date: Wed, 8 Jul 2026 23:42:58 -0400 Subject: [PATCH 2/6] Fix AI provider routing, migrate to Aspire.Hosting.Foundry, upgrade model catalog Register Foundry Local and GitHub Models via AddAzureChatCompletionsClient (Azure AI Inference) instead of the Azure OpenAI client, fixing the /openai/deployments 404 on chat submit. Migrate the AppHost off the deprecated Aspire.Hosting.Azure.AIFoundry package to Aspire.Hosting.Foundry (AddAzureAIFoundry -> AddFoundry). Add a hybrid model catalog: strongly-typed FoundryModel/GitHubModel defaults per provider, with a string override honored when AI:Model is explicitly configured. Upgrade defaults to Azure OpenAI gpt-5, GitHub openai/gpt-5-mini, Foundry cloud gpt-5-mini, and Foundry Local qwen2.5-1.5b (tool-capable). Update provider-default tests. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../AIConfiguration.cs | 30 +++++++++++++++--- .../Extensions/AIServiceExtensions.cs | 13 ++++---- .../BuildWithAspire.AppHost.csproj | 3 +- .../Extensions/AIModelExtensions.cs | 31 ++++++++++++++----- .../appsettings.AzureAIFoundry.json | 6 ++-- .../appsettings.AzureOpenAI.json | 4 +-- .../appsettings.FoundryLocal.json | 4 +-- .../appsettings.GitHubModels.json | 2 +- .../AIConfigurationExtendedTests.cs | 26 ++++++++++++++-- .../Extensions/AIConfigurationTests.cs | 6 ++-- .../Services/ChatServiceTests.cs | 2 +- .../AIModelExtensionsSimpleTests.cs | 27 +++++----------- 12 files changed, 99 insertions(+), 55 deletions(-) diff --git a/src/BuildWithAspire.Abstractions/AIConfiguration.cs b/src/BuildWithAspire.Abstractions/AIConfiguration.cs index 466cc0e..bf54157 100644 --- a/src/BuildWithAspire.Abstractions/AIConfiguration.cs +++ b/src/BuildWithAspire.Abstractions/AIConfiguration.cs @@ -12,7 +12,16 @@ public enum AIProvider AzureAIFoundry } - public record AISettings(AIProvider Provider, string DeploymentName, string Model, int TimeoutSeconds); + public record AISettings(AIProvider Provider, string DeploymentName, string Model, int TimeoutSeconds) + { + /// + /// True when the model was set explicitly via AI:Model configuration, + /// as opposed to falling back to the provider default. The AppHost uses this to + /// choose between the strongly-typed model catalog (defaults) and the string + /// overloads (explicit overrides). + /// + public bool ModelExplicitlyConfigured { get; init; } + } public static AISettings GetSettings(IConfiguration configuration) { @@ -21,7 +30,10 @@ public static AISettings GetSettings(IConfiguration configuration) var model = GetModel(configuration, provider); var timeoutSeconds = GetTimeoutSeconds(configuration); - return new AISettings(provider, deploymentName, model, timeoutSeconds); + return new AISettings(provider, deploymentName, model, timeoutSeconds) + { + ModelExplicitlyConfigured = !string.IsNullOrEmpty(configuration["AI:Model"]) + }; } public static AIProvider GetProvider(IConfiguration configuration) @@ -61,14 +73,22 @@ public static string GetModel(IConfiguration configuration, AIProvider? provider var model = provider switch { AIProvider.Ollama => "llama3.2", - AIProvider.AzureOpenAI => "gpt-4o", - AIProvider.GitHubModels => "openai/gpt-4o-mini", - AIProvider.AzureAIFoundry => "phi-3.5-mini", + AIProvider.AzureOpenAI => "gpt-5", + AIProvider.GitHubModels => "openai/gpt-5-mini", + // Foundry Local runs small on-device models; cloud Azure AI Foundry defaults to a + // hosted OpenAI model. These names mirror the strongly-typed catalog constants the + // AppHost deploys (FoundryModel.Local.Qwen2515b / FoundryModel.OpenAI.Gpt5Mini) so the + // advertised AI:Model matches the deployed model. qwen2.5-1.5b reliably emits tool + // calls locally (the phi-4-mini Foundry build does not). + AIProvider.AzureAIFoundry => IsFoundryLocal(configuration) ? "qwen2.5-1.5b" : "gpt-5-mini", _ => throw new InvalidOperationException($"No default model available for provider: {provider}") }; return model; } + private static bool IsFoundryLocal(IConfiguration configuration) => + configuration["AI:Provider"]?.ToLowerInvariant() == "foundrylocal"; + public static int GetTimeoutSeconds(IConfiguration configuration) { var timeoutString = configuration["AI:TimeoutSeconds"]; diff --git a/src/BuildWithAspire.ApiService/Extensions/AIServiceExtensions.cs b/src/BuildWithAspire.ApiService/Extensions/AIServiceExtensions.cs index 10386a0..e1c3059 100644 --- a/src/BuildWithAspire.ApiService/Extensions/AIServiceExtensions.cs +++ b/src/BuildWithAspire.ApiService/Extensions/AIServiceExtensions.cs @@ -34,15 +34,16 @@ private static IHostApplicationBuilder AddAIProvider( break; case AIConfiguration.AIProvider.AzureOpenAI: - builder.AddAzureOpenAIClient("ai-service") - .AddChatClient(aiSettings.DeploymentName); - break; - - case AIConfiguration.AIProvider.GitHubModels: - builder.AddOpenAIClient(aiSettings.DeploymentName) + builder.AddAzureOpenAIClient(aiSettings.DeploymentName) .AddChatClient(); break; + // GitHub Models and Azure AI Foundry (local emulator + cloud) expose the + // Azure AI Inference / OpenAI-compatible chat completions protocol, so they + // use AddAzureChatCompletionsClient rather than the Azure OpenAI client + // (which targets the /openai/deployments/... URL scheme and 404s here). + // Matches the official Aspire FoundryEndToEnd / GitHubModelsEndToEnd playgrounds. + case AIConfiguration.AIProvider.GitHubModels: case AIConfiguration.AIProvider.AzureAIFoundry: builder.AddAzureChatCompletionsClient(aiSettings.DeploymentName) .AddChatClient(); diff --git a/src/BuildWithAspire.AppHost/BuildWithAspire.AppHost.csproj b/src/BuildWithAspire.AppHost/BuildWithAspire.AppHost.csproj index 37ff24c..f753573 100644 --- a/src/BuildWithAspire.AppHost/BuildWithAspire.AppHost.csproj +++ b/src/BuildWithAspire.AppHost/BuildWithAspire.AppHost.csproj @@ -10,10 +10,9 @@ - - + diff --git a/src/BuildWithAspire.AppHost/Extensions/AIModelExtensions.cs b/src/BuildWithAspire.AppHost/Extensions/AIModelExtensions.cs index ad7f13a..0c28c9d 100644 --- a/src/BuildWithAspire.AppHost/Extensions/AIModelExtensions.cs +++ b/src/BuildWithAspire.AppHost/Extensions/AIModelExtensions.cs @@ -1,5 +1,7 @@ using Microsoft.Extensions.Configuration; using Aspire.Hosting; +using Aspire.Hosting.Foundry; +using Aspire.Hosting.GitHub; using BuildWithAspire.Abstractions; using AIProvider = BuildWithAspire.Abstractions.AIConfiguration.AIProvider; @@ -61,7 +63,7 @@ private static IResourceBuilder ConfigureAzureOpe AIConfiguration.AISettings settings) { var config = builder.Configuration; - var modelVersion = config["AI:ModelVersion"] ?? "2024-11-20"; + var modelVersion = config["AI:ModelVersion"] ?? "2025-08-07"; var skuName = config["AI:SkuName"] ?? "GlobalStandard"; var skuCapacity = config.GetValue("AI:SkuCapacity") ?? 150; @@ -101,8 +103,13 @@ private static IResourceBuilder ConfigureGitHubMo IDistributedApplicationBuilder builder, AIConfiguration.AISettings settings) { - return builder.AddGitHubModel(settings.DeploymentName, settings.Model) - .WithHealthCheck(); + // Default to the strongly-typed model catalog; fall back to the string overload + // only when a model is explicitly configured via AI:Model. + var github = settings.ModelExplicitlyConfigured + ? builder.AddGitHubModel(settings.DeploymentName, settings.Model) + : builder.AddGitHubModel(settings.DeploymentName, GitHubModel.OpenAI.OpenAIGpt5Mini); + + return github.WithHealthCheck(); } private static IResourceBuilder ConfigureAzureAIFoundry( @@ -120,15 +127,25 @@ private static IResourceBuilder ConfigureAzureAIF $"Azure AI Foundry: {(isLocal ? "Local" : "Cloud")}, " + $"Model: {settings.Model}, Version: {version}"); - var foundry = builder.AddAzureAIFoundry(name); + var foundry = builder.AddFoundry(name); if (isLocal) { foundry = foundry.RunAsFoundryLocal(); } - return foundry - .AddDeployment(settings.DeploymentName, settings.Model, version, format) - .WithProperties(p => p.SkuCapacity = skuCapacity); + // Default to the strongly-typed model catalog (which encodes the correct model + // version/format per environment); fall back to the string overload only when a + // model is explicitly configured via AI:Model. + // Local default is qwen2.5-1.5b: unlike the Foundry-optimized phi-4-mini build (which + // does not emit tool calls), the qwen2.5 builds reliably invoke MCP tools. Swap to the + // larger FoundryModel.Local.Qwen257b (qwen2.5-7b) for higher tool-calling reliability. + var deployment = settings.ModelExplicitlyConfigured + ? foundry.AddDeployment(settings.DeploymentName, settings.Model, version, format) + : foundry.AddDeployment( + settings.DeploymentName, + isLocal ? FoundryModel.Local.Qwen2515b : FoundryModel.OpenAI.Gpt5Mini); + + return deployment.WithProperties(p => p.SkuCapacity = skuCapacity); } private static IResourceBuilder ConnectToAIService( diff --git a/src/BuildWithAspire.AppHost/appsettings.AzureAIFoundry.json b/src/BuildWithAspire.AppHost/appsettings.AzureAIFoundry.json index d6cc9cf..c95d260 100644 --- a/src/BuildWithAspire.AppHost/appsettings.AzureAIFoundry.json +++ b/src/BuildWithAspire.AppHost/appsettings.AzureAIFoundry.json @@ -8,9 +8,9 @@ "AI": { "Provider": "azureaifoundry", "DeploymentName": "chat", - "Model": "phi-3.5-mini", - "ModelVersion": "1", - "ModelFormat": "Microsoft", + "Model": "gpt-5-mini", + "ModelVersion": "2025-08-07", + "ModelFormat": "OpenAI", "SkuCapacity": 20 } } diff --git a/src/BuildWithAspire.AppHost/appsettings.AzureOpenAI.json b/src/BuildWithAspire.AppHost/appsettings.AzureOpenAI.json index dad2e78..ddee6d0 100644 --- a/src/BuildWithAspire.AppHost/appsettings.AzureOpenAI.json +++ b/src/BuildWithAspire.AppHost/appsettings.AzureOpenAI.json @@ -8,8 +8,8 @@ "AI": { "Provider": "azureopenai", "DeploymentName": "chat", - "Model": "gpt-4o", - "ModelVersion": "2024-11-20", + "Model": "gpt-5", + "ModelVersion": "2025-08-07", "SkuName": "GlobalStandard", "SkuCapacity": 10 } diff --git a/src/BuildWithAspire.AppHost/appsettings.FoundryLocal.json b/src/BuildWithAspire.AppHost/appsettings.FoundryLocal.json index f2357a1..1ddbd47 100644 --- a/src/BuildWithAspire.AppHost/appsettings.FoundryLocal.json +++ b/src/BuildWithAspire.AppHost/appsettings.FoundryLocal.json @@ -9,8 +9,8 @@ "AI": { "Provider": "foundrylocal", "DeploymentName": "chat", - "Model": "phi-3.5-mini", - "ModelVersion": "1", + "Model": "qwen2.5-1.5b", + "ModelVersion": "4", "ModelFormat": "Microsoft", "SkuCapacity": 20 } diff --git a/src/BuildWithAspire.AppHost/appsettings.GitHubModels.json b/src/BuildWithAspire.AppHost/appsettings.GitHubModels.json index 3771d7c..eca9185 100644 --- a/src/BuildWithAspire.AppHost/appsettings.GitHubModels.json +++ b/src/BuildWithAspire.AppHost/appsettings.GitHubModels.json @@ -9,6 +9,6 @@ "AI": { "Provider": "githubmodels", "DeploymentName": "chat", - "Model": "openai/gpt-4.1-mini" + "Model": "openai/gpt-5-mini" } } diff --git a/tests/BuildWithAspire.ApiService.UnitTests/Configuration/AIConfigurationExtendedTests.cs b/tests/BuildWithAspire.ApiService.UnitTests/Configuration/AIConfigurationExtendedTests.cs index 03b47af..a5a6ab8 100644 --- a/tests/BuildWithAspire.ApiService.UnitTests/Configuration/AIConfigurationExtendedTests.cs +++ b/tests/BuildWithAspire.ApiService.UnitTests/Configuration/AIConfigurationExtendedTests.cs @@ -40,10 +40,10 @@ public void GetProvider_WithMissingConfiguration_ShouldReturnDefaultOllama() } [Theory] - [InlineData(AIConfiguration.AIProvider.AzureOpenAI, "gpt-4o")] + [InlineData(AIConfiguration.AIProvider.AzureOpenAI, "gpt-5")] [InlineData(AIConfiguration.AIProvider.Ollama, "llama3.2")] - [InlineData(AIConfiguration.AIProvider.GitHubModels, "openai/gpt-4o-mini")] - [InlineData(AIConfiguration.AIProvider.AzureAIFoundry, "phi-3.5-mini")] + [InlineData(AIConfiguration.AIProvider.GitHubModels, "openai/gpt-5-mini")] + [InlineData(AIConfiguration.AIProvider.AzureAIFoundry, "gpt-5-mini")] public void GetModel_WithProviders_ShouldReturnCorrectDefaults(AIConfiguration.AIProvider provider, string expectedModel) { // Arrange @@ -56,6 +56,26 @@ public void GetModel_WithProviders_ShouldReturnCorrectDefaults(AIConfiguration.A Assert.Equal(expectedModel, model); } + [Theory] + [InlineData("foundrylocal", "qwen2.5-1.5b")] + [InlineData("azureaifoundry", "gpt-5-mini")] + public void GetModel_AzureAIFoundry_ShouldReturnEnvironmentSpecificDefault(string providerString, string expectedModel) + { + // Arrange + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["AI:Provider"] = providerString + }) + .Build(); + + // Act + var model = AIConfiguration.GetModel(configuration, AIConfiguration.AIProvider.AzureAIFoundry); + + // Assert + Assert.Equal(expectedModel, model); + } + [Fact] public void AISettings_WithAllProviders_ShouldConstructCorrectly() { diff --git a/tests/BuildWithAspire.ApiService.UnitTests/Extensions/AIConfigurationTests.cs b/tests/BuildWithAspire.ApiService.UnitTests/Extensions/AIConfigurationTests.cs index ba86b18..384d349 100644 --- a/tests/BuildWithAspire.ApiService.UnitTests/Extensions/AIConfigurationTests.cs +++ b/tests/BuildWithAspire.ApiService.UnitTests/Extensions/AIConfigurationTests.cs @@ -64,9 +64,9 @@ public void GetModel_WithCustomModel_ReturnsCustomModel() [Theory] [InlineData(AIConfiguration.AIProvider.Ollama, "llama3.2")] - [InlineData(AIConfiguration.AIProvider.AzureOpenAI, "gpt-4o")] - [InlineData(AIConfiguration.AIProvider.GitHubModels, "openai/gpt-4o-mini")] - [InlineData(AIConfiguration.AIProvider.AzureAIFoundry, "phi-3.5-mini")] + [InlineData(AIConfiguration.AIProvider.AzureOpenAI, "gpt-5")] + [InlineData(AIConfiguration.AIProvider.GitHubModels, "openai/gpt-5-mini")] + [InlineData(AIConfiguration.AIProvider.AzureAIFoundry, "gpt-5-mini")] public void GetModel_WithDefaultConfiguration_ReturnsProviderDefaults(AIConfiguration.AIProvider provider, string expectedModel) { // Arrange diff --git a/tests/BuildWithAspire.ApiService.UnitTests/Services/ChatServiceTests.cs b/tests/BuildWithAspire.ApiService.UnitTests/Services/ChatServiceTests.cs index f4a3626..af65f05 100644 --- a/tests/BuildWithAspire.ApiService.UnitTests/Services/ChatServiceTests.cs +++ b/tests/BuildWithAspire.ApiService.UnitTests/Services/ChatServiceTests.cs @@ -107,7 +107,7 @@ public void GetModel_WithAzureOpenAIProvider_ShouldReturnDefaultModel() var model = AIConfiguration.GetModel(configuration, AIConfiguration.AIProvider.AzureOpenAI); // Assert - Assert.Equal("gpt-4o", model); + Assert.Equal("gpt-5", model); } [Fact] diff --git a/tests/BuildWithAspire.AppHost.UnitTests/Extensions/AIModelExtensionsSimpleTests.cs b/tests/BuildWithAspire.AppHost.UnitTests/Extensions/AIModelExtensionsSimpleTests.cs index c38866d..4a5c879 100644 --- a/tests/BuildWithAspire.AppHost.UnitTests/Extensions/AIModelExtensionsSimpleTests.cs +++ b/tests/BuildWithAspire.AppHost.UnitTests/Extensions/AIModelExtensionsSimpleTests.cs @@ -89,9 +89,9 @@ public void GetAIModel_WithDefaultProvider_ReturnsExpectedDefaults() // Act & Assert - Test each provider's default model Assert.Equal("llama3.2", GetAIModelFromConfiguration(configuration, AIProvider.Ollama)); - Assert.Equal("gpt-4o", GetAIModelFromConfiguration(configuration, AIProvider.AzureOpenAI)); - Assert.Equal("openai/gpt-4o-mini", GetAIModelFromConfiguration(configuration, AIProvider.GitHubModels)); - Assert.Equal("phi-3.5-mini", GetAIModelFromConfiguration(configuration, AIProvider.AzureAIFoundry)); + Assert.Equal("gpt-5", GetAIModelFromConfiguration(configuration, AIProvider.AzureOpenAI)); + Assert.Equal("openai/gpt-5-mini", GetAIModelFromConfiguration(configuration, AIProvider.GitHubModels)); + Assert.Equal("gpt-5-mini", GetAIModelFromConfiguration(configuration, AIProvider.AzureAIFoundry)); } [Fact] @@ -119,22 +119,9 @@ private static AIProvider GetAIProviderFromConfiguration(IConfiguration configur private static string GetAIModelFromConfiguration(IConfiguration configuration, AIProvider provider) { - // Simulate what AIConfiguration.GetSettings does but forcing a provider for test determinism - var model = configuration["AI:Model"]; // If user overrides model we honor it - if (!string.IsNullOrWhiteSpace(model)) - { - return provider == AIProvider.GitHubModels && !model.Contains('/') - ? $"openai/{model}" // normalization mirrors central logic - : model; - } - - return provider switch - { - AIProvider.Ollama => "llama3.2", - AIProvider.AzureOpenAI => "gpt-4o", - AIProvider.GitHubModels => "openai/gpt-4o-mini", - AIProvider.AzureAIFoundry => "phi-3.5-mini", - _ => throw new InvalidOperationException($"No default model available for provider: {provider}") - }; + // Delegate to the central resolution logic so this test stays in sync with + // AIConfiguration.GetModel (override handling, GitHub normalization, and the + // Foundry Local vs cloud default all live there). + return AIConfiguration.GetModel(configuration, provider); } } From 0fa8b2ebf2f6c1625a092cf919772796986b0dd8 Mon Sep 17 00:00:00 2001 From: Chris Ayers Date: Wed, 8 Jul 2026 23:43:12 -0400 Subject: [PATCH 3/6] Add TextTools and modernize MCP server tool registration Add a new TextTools tool set (including transformCase with a reverse mode) and register it with the MCP server. Rework the /debug/tools endpoint to enumerate the DI-registered McpServerTool instances instead of a hardcoded list, so it stays accurate as tools change. Refresh Math/System/Weather tool annotations and logging to match the latest MCP SDK conventions. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../BuildWithAspire.MCPServer.csproj | 4 +- src/BuildWithAspire.MCPServer/Program.cs | 48 ++++--- .../Tools/MathTools.cs | 10 +- .../Tools/SystemTools.cs | 10 +- .../Tools/TextTools.cs | 128 ++++++++++++++++++ .../Tools/WeatherTools.cs | 6 +- 6 files changed, 170 insertions(+), 36 deletions(-) create mode 100644 src/BuildWithAspire.MCPServer/Tools/TextTools.cs diff --git a/src/BuildWithAspire.MCPServer/BuildWithAspire.MCPServer.csproj b/src/BuildWithAspire.MCPServer/BuildWithAspire.MCPServer.csproj index f1cb130..2f6a736 100644 --- a/src/BuildWithAspire.MCPServer/BuildWithAspire.MCPServer.csproj +++ b/src/BuildWithAspire.MCPServer/BuildWithAspire.MCPServer.csproj @@ -24,8 +24,8 @@ - - + + diff --git a/src/BuildWithAspire.MCPServer/Program.cs b/src/BuildWithAspire.MCPServer/Program.cs index 92f5875..64bf761 100644 --- a/src/BuildWithAspire.MCPServer/Program.cs +++ b/src/BuildWithAspire.MCPServer/Program.cs @@ -49,7 +49,8 @@ }) .WithTools() .WithTools() -.WithTools(); +.WithTools() +.WithTools(); // Add logging builder.Logging.AddConsole(options => @@ -100,28 +101,33 @@ }) .WithName("DebugInfo"); -// Add diagnostic endpoint to list registered tools (for debugging) -app.MapGet("/debug/tools", () => +// Add diagnostic endpoint to list registered tools (for debugging). Tool metadata is +// enumerated directly from the DI-registered McpServerTool instances, so this endpoint +// stays accurate automatically as tools are added or removed. +app.MapGet("/debug/tools", (IEnumerable registeredTools) => { - // This is a diagnostic endpoint to show the actual registered tool names - var tools = new[] - { - "getWeatherForecast", - "getCurrentWeather", - "convertTemperature", - "getCurrentDateTime", - "getSystemInfo", - "generateRandomNumber", - "encodeToBase64", - "decodeFromBase64", - "calculate", - "squareRoot", - "power", - "generateFibonacci", - "isPrime" - }; + var tools = registeredTools + .Select(t => new + { + name = t.ProtocolTool.Name, + title = t.ProtocolTool.Title, + description = t.ProtocolTool.Description, + annotations = t.ProtocolTool.Annotations is { } a + ? new + { + readOnly = a.ReadOnlyHint, + idempotent = a.IdempotentHint, + destructive = a.DestructiveHint, + openWorld = a.OpenWorldHint + } + : null + }) + .OrderBy(t => t.name, StringComparer.Ordinal) + .ToArray(); + return Results.Ok(new { message = "MCP tools registered with camelCase names (following official SDK conventions)", + count = tools.Length, tools = tools, usage = new { @@ -154,7 +160,7 @@ app.Logger.LogInformation(" - POST / : JSON-RPC requests (auto-creates session on first request)"); app.Logger.LogInformation(" - GET / : SSE notifications stream"); app.Logger.LogInformation(" - DELETE / : Session cleanup"); -app.Logger.LogInformation("Tools: Weather, System, Math"); +app.Logger.LogInformation("Tools: Weather, System, Math, Text"); app.Logger.LogInformation("Session Timeout: 2 hours idle"); // Run the MCP-compliant server diff --git a/src/BuildWithAspire.MCPServer/Tools/MathTools.cs b/src/BuildWithAspire.MCPServer/Tools/MathTools.cs index aed597b..3609f63 100644 --- a/src/BuildWithAspire.MCPServer/Tools/MathTools.cs +++ b/src/BuildWithAspire.MCPServer/Tools/MathTools.cs @@ -18,7 +18,7 @@ public MathTools(ILogger logger) _logger = logger; } - [McpServerTool(Name = "calculate")] + [McpServerTool(Name = "calculate", Title = "Basic Calculator", ReadOnly = true, Idempotent = true, OpenWorld = false)] [Description("Performs basic arithmetic operations (add, subtract, multiply, divide).")] public CalculationResult Calculate( [Description("First number")] double a, @@ -56,7 +56,7 @@ public CalculationResult Calculate( } } - [McpServerTool(Name = "squareRoot")] + [McpServerTool(Name = "squareRoot", Title = "Square Root", ReadOnly = true, Idempotent = true, OpenWorld = false)] [Description("Calculates the square root of a number.")] public CalculationResult SquareRoot( [Description("Number to find square root of")] double number) @@ -81,7 +81,7 @@ public CalculationResult SquareRoot( ); } - [McpServerTool(Name = "power")] + [McpServerTool(Name = "power", Title = "Exponentiation", ReadOnly = true, Idempotent = true, OpenWorld = false)] [Description("Raises a number to a specified power.")] public CalculationResult Power( [Description("Base number")] double baseNumber, @@ -109,7 +109,7 @@ public CalculationResult Power( } } - [McpServerTool(Name = "generateFibonacci")] + [McpServerTool(Name = "generateFibonacci", Title = "Fibonacci Sequence", ReadOnly = true, Idempotent = true, OpenWorld = false)] [Description("Generates the Fibonacci sequence up to n terms.")] public FibonacciResult GenerateFibonacci( [Description("Number of terms to generate (1-50)")] int terms) @@ -153,7 +153,7 @@ public FibonacciResult GenerateFibonacci( ); } - [McpServerTool(Name = "isPrime")] + [McpServerTool(Name = "isPrime", Title = "Primality Test", ReadOnly = true, Idempotent = true, OpenWorld = false)] [Description("Checks if a number is prime.")] public PrimeCheckResult IsPrime( [Description("Number to check for primality")] long number) diff --git a/src/BuildWithAspire.MCPServer/Tools/SystemTools.cs b/src/BuildWithAspire.MCPServer/Tools/SystemTools.cs index a1f5845..b6090b2 100644 --- a/src/BuildWithAspire.MCPServer/Tools/SystemTools.cs +++ b/src/BuildWithAspire.MCPServer/Tools/SystemTools.cs @@ -20,7 +20,7 @@ public SystemTools(ILogger logger) _logger = logger; } - [McpServerTool(Name = "getCurrentDateTime")] + [McpServerTool(Name = "getCurrentDateTime", Title = "Current Date and Time", ReadOnly = true, Idempotent = false, OpenWorld = false)] [Description("Gets the current date and time information.")] public DateTimeInfo GetCurrentDateTime() { @@ -36,7 +36,7 @@ public DateTimeInfo GetCurrentDateTime() ); } - [McpServerTool(Name = "getSystemInfo")] + [McpServerTool(Name = "getSystemInfo", Title = "System Information", ReadOnly = true, Idempotent = true, OpenWorld = false)] [Description("Gets basic system information including OS and .NET version.")] public SystemInfo GetSystemInfo() { @@ -50,7 +50,7 @@ public SystemInfo GetSystemInfo() ); } - [McpServerTool(Name = "generateRandomNumber")] + [McpServerTool(Name = "generateRandomNumber", Title = "Random Number Generator", ReadOnly = true, Idempotent = false, OpenWorld = false)] [Description("Generates a random number within the specified range.")] public RandomNumber GenerateRandomNumber( [Description("Minimum value (inclusive)")] int min = 1, @@ -66,7 +66,7 @@ public RandomNumber GenerateRandomNumber( return new RandomNumber(value, min, max - 1); } - [McpServerTool(Name = "encodeToBase64")] + [McpServerTool(Name = "encodeToBase64", Title = "Base64 Encode", ReadOnly = true, Idempotent = true, OpenWorld = false)] [Description("Encodes text to Base64 format.")] public EncodingResult EncodeToBase64( [Description("Text to encode")] string text) @@ -89,7 +89,7 @@ public EncodingResult EncodeToBase64( } } - [McpServerTool(Name = "decodeFromBase64")] + [McpServerTool(Name = "decodeFromBase64", Title = "Base64 Decode", ReadOnly = true, Idempotent = true, OpenWorld = false)] [Description("Decodes Base64 text to plain text.")] public EncodingResult DecodeFromBase64( [Description("Base64 text to decode")] string base64Text) diff --git a/src/BuildWithAspire.MCPServer/Tools/TextTools.cs b/src/BuildWithAspire.MCPServer/Tools/TextTools.cs new file mode 100644 index 0000000..9dfea2d --- /dev/null +++ b/src/BuildWithAspire.MCPServer/Tools/TextTools.cs @@ -0,0 +1,128 @@ +using System.ComponentModel; +using System.Globalization; +using System.Text; +using Microsoft.Extensions.Logging; +using ModelContextProtocol.Server; + +namespace BuildWithAspire.MCPServer.Tools; + +/// +/// Text manipulation and analysis tools for MCP clients. +/// All operations are deterministic, read-only, and side-effect free, which makes +/// them ideal for demonstrating tool-calling with small local models. +/// +[McpServerToolType] +public sealed class TextTools +{ + private static readonly char[] SentenceTerminators = ['.', '!', '?']; + + private readonly ILogger _logger; + + public TextTools(ILogger logger) + { + _logger = logger; + } + + [McpServerTool(Name = "countText", Title = "Text Statistics", ReadOnly = true, Idempotent = true, OpenWorld = false)] + [Description("Counts the number of characters, words, lines, and sentences in the supplied text.")] + public TextStatistics CountText( + [Description("The text to analyze")] string text) + { + _logger.LogInformation("MCP Tool 'countText' called with text length={Length}", text?.Length ?? 0); + + if (string.IsNullOrEmpty(text)) + { + return new TextStatistics(0, 0, 0, 0, 0); + } + + var characters = text.Length; + var charactersNoWhitespace = text.Count(c => !char.IsWhiteSpace(c)); + var words = text.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries).Length; + var lines = text.Split('\n').Length; + var sentences = text.Split(SentenceTerminators, StringSplitOptions.RemoveEmptyEntries) + .Count(s => !string.IsNullOrWhiteSpace(s)); + + return new TextStatistics(characters, charactersNoWhitespace, words, lines, sentences); + } + + [McpServerTool(Name = "transformCase", Title = "Transform Text Case", ReadOnly = true, Idempotent = true, OpenWorld = false)] + [Description("Transforms text casing. Supported modes: 'upper', 'lower', 'title', 'reverse'.")] + public TextTransformResult TransformCase( + [Description("The text to transform")] string text, + [Description("Transform mode: 'upper', 'lower', 'title', or 'reverse'")] string mode = "upper") + { + _logger.LogInformation("MCP Tool 'transformCase' called with mode={Mode}", mode); + + text ??= string.Empty; + var normalizedMode = mode?.Trim().ToLowerInvariant() ?? "upper"; + + switch (normalizedMode) + { + case "upper": + return new TextTransformResult(text.ToUpperInvariant(), normalizedMode, true, "Converted to upper case"); + case "lower": + return new TextTransformResult(text.ToLowerInvariant(), normalizedMode, true, "Converted to lower case"); + case "title": + var title = CultureInfo.InvariantCulture.TextInfo.ToTitleCase(text.ToLowerInvariant()); + return new TextTransformResult(title, normalizedMode, true, "Converted to title case"); + case "reverse": + var reversed = new string(text.Reverse().ToArray()); + return new TextTransformResult(reversed, normalizedMode, true, "Reversed the text"); + default: + return new TextTransformResult(text, normalizedMode, false, $"Unknown mode: {mode}. Use 'upper', 'lower', 'title', or 'reverse'."); + } + } + + [McpServerTool(Name = "slugify", Title = "URL Slug Generator", ReadOnly = true, Idempotent = true, OpenWorld = false)] + [Description("Converts text into a URL-friendly slug (lowercase, hyphen-separated, alphanumeric).")] + public SlugResult Slugify( + [Description("The text to convert into a slug")] string text) + { + _logger.LogInformation("MCP Tool 'slugify' called with text length={Length}", text?.Length ?? 0); + + if (string.IsNullOrWhiteSpace(text)) + { + return new SlugResult(string.Empty, "Empty input provided"); + } + + var builder = new StringBuilder(text.Length); + var previousWasHyphen = false; + + foreach (var ch in text.Trim().ToLowerInvariant()) + { + if (char.IsLetterOrDigit(ch)) + { + builder.Append(ch); + previousWasHyphen = false; + } + else if (!previousWasHyphen) + { + builder.Append('-'); + previousWasHyphen = true; + } + } + + var slug = builder.ToString().Trim('-'); + return new SlugResult(slug, $"Generated slug from {text.Length} characters"); + } +} + +public record TextStatistics( + int Characters, + int CharactersNoWhitespace, + int Words, + int Lines, + int Sentences +); + +public record TextTransformResult( + string Result, + string Mode, + bool Success, + string Message +); + +public record SlugResult( + string Slug, + string Message +); diff --git a/src/BuildWithAspire.MCPServer/Tools/WeatherTools.cs b/src/BuildWithAspire.MCPServer/Tools/WeatherTools.cs index 18678bc..f154bb1 100644 --- a/src/BuildWithAspire.MCPServer/Tools/WeatherTools.cs +++ b/src/BuildWithAspire.MCPServer/Tools/WeatherTools.cs @@ -21,7 +21,7 @@ public WeatherTools(ILogger logger, IChatClient? chatClient = null _chatClient = chatClient; } - [McpServerTool(Name = "getWeatherForecast")] + [McpServerTool(Name = "getWeatherForecast", Title = "Weather Forecast", ReadOnly = true, Idempotent = false, OpenWorld = false)] [Description("Gets a weather forecast for the next 5 days with AI-generated weather descriptions.")] public async Task GetWeatherForecast( [Description("Maximum number of forecast days to return (1-10)")] int maxDays = 5) @@ -54,7 +54,7 @@ public async Task GetWeatherForecast( return forecasts.ToArray(); } - [McpServerTool(Name = "getCurrentWeather")] + [McpServerTool(Name = "getCurrentWeather", Title = "Current Weather", ReadOnly = true, Idempotent = false, OpenWorld = false)] [Description("Gets current weather information for today with AI-generated description.")] public async Task GetCurrentWeather() { @@ -70,7 +70,7 @@ public async Task GetCurrentWeather() ); } - [McpServerTool(Name = "convertTemperature")] + [McpServerTool(Name = "convertTemperature", Title = "Temperature Converter", ReadOnly = true, Idempotent = true, OpenWorld = false)] [Description("Converts temperature between Celsius and Fahrenheit.")] public static TemperatureConversion ConvertTemperature( [Description("Temperature value to convert")] double temperature, From cb78c8e3af0bdd5ce166324e93cfa7c673c4f9d6 Mon Sep 17 00:00:00 2001 From: Chris Ayers Date: Wed, 8 Jul 2026 23:43:12 -0400 Subject: [PATCH 4/6] Align ChatService with MAF agent pattern and strip tool-call template leak Construct ChatClientAgent with the MCP tools supplied at construction time and let the agent apply the function-invocation middleware, removing the hand-rolled tool-injection pipeline (matches the official MAF agent pattern). Add a model-agnostic server-side sanitizer that strips the qwen2.5/Hermes ... template that Foundry Local echoes into the text channel, using source-generated regexes; structured tool_calls and MCP execution are untouched. Add ChatServiceSanitizeTests. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Services/ChatService.cs | 92 +++++++++++-------- .../Services/ChatServiceSanitizeTests.cs | 60 ++++++++++++ 2 files changed, 116 insertions(+), 36 deletions(-) create mode 100644 tests/BuildWithAspire.ApiService.UnitTests/Services/ChatServiceSanitizeTests.cs diff --git a/src/BuildWithAspire.ApiService/Services/ChatService.cs b/src/BuildWithAspire.ApiService/Services/ChatService.cs index 2d79afb..5b86eb9 100644 --- a/src/BuildWithAspire.ApiService/Services/ChatService.cs +++ b/src/BuildWithAspire.ApiService/Services/ChatService.cs @@ -1,3 +1,4 @@ +using System.Text.RegularExpressions; using BuildWithAspire.Abstractions; using BuildWithAspire.ApiService.Models; using Microsoft.Extensions.AI; @@ -5,7 +6,7 @@ namespace BuildWithAspire.ApiService.Services; -public class ChatService +public partial class ChatService { private AIAgent? _agent; private readonly IChatClient _chatClient; @@ -42,43 +43,32 @@ private async Task EnsureInitializedAsync(CancellationToken cancellationToken = _logger.LogInformation("Initializing AI Agent with dynamic MCP tools..."); - // Dynamically load tools from MCP server + // Dynamically load tools from the MCP server. McpClientTool instances derive from + // AIFunction (and therefore AITool), so they can be handed straight to the agent. _tools = await _toolConverter.GetAllToolsAsync(cancellationToken).ConfigureAwait(false); - _logger.LogInformation("Loaded {ToolCount} tools from MCP server", _tools.Count()); - - // Create a chat client with function invocation enabled and tools configured via middleware - var toolEnabledClient = _chatClient.AsBuilder() - .Use((chatMessages, options, next, cancellationToken) => - { - // Inject tools into ChatOptions for every request - if (options is not null && options.Tools?.Count is null or 0) - { - options.Tools = _tools?.Select(t => (AITool)t).ToList(); - _logger.LogInformation("Middleware: Injected {ToolCount} tools into ChatOptions", options.Tools?.Count ?? 0); - } - - var toolCount = options?.Tools?.Count ?? 0; - _logger.LogInformation("Middleware: Sending request to model with {ToolCount} tools available", toolCount); - var result = next(chatMessages, options, cancellationToken); - _logger.LogInformation("Middleware: Received response from model"); - return result; - }) - .UseFunctionInvocation() - .Build(); - - // Create an AI Agent using the Microsoft Agent Framework with dynamic tools + var tools = _tools.Cast().ToList(); + _logger.LogInformation("Loaded {ToolCount} tools from MCP server", tools.Count); + + // Create the agent using the Microsoft Agent Framework, supplying the MCP tools at + // construction time. ChatClientAgent automatically decorates the IChatClient with the + // function-invocation middleware (UseProvidedChatClientAsIs defaults to false), so we do + // not wire UseFunctionInvocation() or a custom tool-injection pipeline ourselves. This + // mirrors the official MAF agent pattern (see the aspire FoundryAgents playground). _agent = new ChatClientAgent( - toolEnabledClient, - instructions: @"You are an AI demonstration application. - You are a helpful chatbot with access to various tools dynamically discovered from the MCP server. - Use the available tools when appropriate to provide accurate information. - When a user asks for something that a tool can provide (like a random number, weather, calculations, etc.), USE THE TOOL instead of making up an answer. - Respond to the user's input responsibly. - All responses should be safe for work.", - name: "ChatAssistant"); + _chatClient, + instructions: """ + You are an AI demonstration application. + You are a helpful chatbot with access to various tools dynamically discovered from the MCP server. + Use the available tools when appropriate to provide accurate information. + When a user asks for something that a tool can provide (like a random number, weather, calculations, etc.), USE THE TOOL instead of making up an answer. + Respond to the user's input responsibly. + All responses should be safe for work. + """, + name: "ChatAssistant", + tools: tools); _isInitialized = true; - _logger.LogInformation("AI Agent initialized with {ToolCount} MCP tools available", _tools.Count()); + _logger.LogInformation("AI Agent initialized with {ToolCount} MCP tools available", tools.Count); } public async Task ProcessMessage(string message) @@ -101,7 +91,7 @@ public async Task ProcessMessage(string message) var response = await _agent.RunAsync(message ?? string.Empty).ConfigureAwait(false); var duration = DateTime.UtcNow - startTime; - var combinedResponse = response.Text ?? string.Empty; + var combinedResponse = SanitizeResponse(response.Text); _logger.LogInformation("AI response generated successfully. ResponseLength: {ResponseLength}, Duration: {Duration}ms", combinedResponse.Length, duration.TotalMilliseconds); @@ -165,7 +155,7 @@ public async Task ProcessMessagesWithHistory(List me var response = await _agent.RunAsync(chatMessages, cancellationToken: cts.Token).ConfigureAwait(false); var duration = DateTime.UtcNow - startTime; - var combinedResponse = response.Text ?? string.Empty; + var combinedResponse = SanitizeResponse(response.Text); _logger.LogInformation("AI conversation response generated successfully. InputMessages: {InputMessages}, ResponseLength: {ResponseLength}, Duration: {Duration}ms", messageCount, combinedResponse.Length, duration.TotalMilliseconds); @@ -187,4 +177,34 @@ public async Task ProcessMessagesWithHistory(List me throw new InvalidOperationException($"Failed to process messages: {ex.Message}", ex); } } + + /// + /// Removes tool-call template noise that some local models (e.g. the qwen2.5 / Hermes family + /// served through Foundry Local) echo into the text channel in addition to the structured + /// tool_calls the serving layer already parses. The tools still fire correctly; this only + /// cleans the visible answer. Model-agnostic and a no-op for models that emit clean text. + /// + public static string SanitizeResponse(string? text) { + if (string.IsNullOrEmpty(text)) + { + return string.Empty; + } + + var cleaned = ToolCallTemplateRegex().Replace(text, string.Empty); + cleaned = OrphanToolCallTagRegex().Replace(cleaned, string.Empty); + cleaned = ExcessBlankLinesRegex().Replace(cleaned, "\n\n"); + return cleaned.Trim(); + } + + // Matches a complete ... block (Singleline so '.' spans newlines). + [GeneratedRegex(@".*?", RegexOptions.Singleline | RegexOptions.IgnoreCase)] + private static partial Regex ToolCallTemplateRegex(); + + // Defensively removes any orphaned opening/closing tool_call tags left by a truncated block. + [GeneratedRegex(@"", RegexOptions.IgnoreCase)] + private static partial Regex OrphanToolCallTagRegex(); + + // Collapses the blank lines left behind after stripping a block. + [GeneratedRegex(@"\n{3,}")] + private static partial Regex ExcessBlankLinesRegex(); } diff --git a/tests/BuildWithAspire.ApiService.UnitTests/Services/ChatServiceSanitizeTests.cs b/tests/BuildWithAspire.ApiService.UnitTests/Services/ChatServiceSanitizeTests.cs new file mode 100644 index 0000000..e67cadb --- /dev/null +++ b/tests/BuildWithAspire.ApiService.UnitTests/Services/ChatServiceSanitizeTests.cs @@ -0,0 +1,60 @@ +using BuildWithAspire.ApiService.Services; + +namespace BuildWithAspire.ApiService.UnitTests.Services; + +public class ChatServiceSanitizeTests +{ + [Fact] + public void SanitizeResponse_StripsToolCallTemplate_KeepsAnswer() + { + // qwen2.5 / Hermes-style local models echo the tool-call template into the text channel + // in addition to the structured tool_calls the serving layer already parsed. + var raw = "\n{\"name\": \"squareRoot\", \"arguments\": {\"number\": 1764}}\n\nThe square root of 1764 is 42."; + + var cleaned = ChatService.SanitizeResponse(raw); + + Assert.Equal("The square root of 1764 is 42.", cleaned); + Assert.DoesNotContain("tool_call", cleaned, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void SanitizeResponse_StripsMultipleToolCallBlocks() + { + var raw = "{\"name\":\"a\"}Line one.\n{\"name\":\"b\"}\nLine two."; + + var cleaned = ChatService.SanitizeResponse(raw); + + Assert.DoesNotContain("tool_call", cleaned, StringComparison.OrdinalIgnoreCase); + Assert.Contains("Line one.", cleaned); + Assert.Contains("Line two.", cleaned); + } + + [Fact] + public void SanitizeResponse_RemovesOrphanedToolCallTag() + { + var raw = "Partial answer.\n\n{\"name\": \"weather\"}"; + + var cleaned = ChatService.SanitizeResponse(raw); + + Assert.DoesNotContain("", cleaned, StringComparison.OrdinalIgnoreCase); + Assert.StartsWith("Partial answer.", cleaned); + } + + [Fact] + public void SanitizeResponse_LeavesCleanTextUnchanged() + { + var raw = "The weather in Seattle is 55F and cloudy."; + + var cleaned = ChatService.SanitizeResponse(raw); + + Assert.Equal(raw, cleaned); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + public void SanitizeResponse_HandlesNullOrEmpty(string? input) + { + Assert.Equal(string.Empty, ChatService.SanitizeResponse(input)); + } +} From f40a5bd765e9f2581a87203fa3e82622ef0416e3 Mon Sep 17 00:00:00 2001 From: Chris Ayers Date: Wed, 8 Jul 2026 23:43:12 -0400 Subject: [PATCH 5/6] Add aspire CLI apphost config and pin NuGet package source Add aspire.config.json so 'aspire run' resolves the AppHost without arguments, and add nuget.config pinning nuget.org as the sole restore source with package source mapping for reproducible restores. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- aspire.config.json | 5 +++++ nuget.config | 12 ++++++++++++ 2 files changed, 17 insertions(+) create mode 100644 aspire.config.json create mode 100644 nuget.config diff --git a/aspire.config.json b/aspire.config.json new file mode 100644 index 0000000..d84e4cd --- /dev/null +++ b/aspire.config.json @@ -0,0 +1,5 @@ +{ + "appHost": { + "path": "src/BuildWithAspire.AppHost/BuildWithAspire.AppHost.csproj" + } +} \ No newline at end of file diff --git a/nuget.config b/nuget.config new file mode 100644 index 0000000..993d13d --- /dev/null +++ b/nuget.config @@ -0,0 +1,12 @@ + + + + + + + + + + + + \ No newline at end of file From 4e19cad13e98225148781d637fc0451360529b21 Mon Sep 17 00:00:00 2001 From: Chris Ayers Date: Wed, 8 Jul 2026 23:52:41 -0400 Subject: [PATCH 6/6] Update slides to Aspire 13.4, GPT-5/Qwen2.5, MAF GA, and MCP Refresh the deck with current stack details and add a Model Context Protocol slide. Validate every slide renders within the 1280x720 frame (bare Marp template) and trim content so no slide overflows. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- slides/Slides.md | 79 +++++++++++++++++++++++++++--------------------- 1 file changed, 45 insertions(+), 34 deletions(-) diff --git a/slides/Slides.md b/slides/Slides.md index 9fbc9ae..0396205 100644 --- a/slides/Slides.md +++ b/slides/Slides.md @@ -192,7 +192,7 @@ footer: '@Chris_L_Ayers - https://chris-ayers.com' - **Databases**: PostgreSQL, MySQL, MongoDB, SQL Server - **Messaging**: Kafka, RabbitMQ, NATS -- **AI & Observability**: Ollama, Semantic Kernel, OpenTelemetry +- **AI & Observability**: Ollama, OpenAI, Foundry, OpenTelemetry
@@ -235,34 +235,34 @@ footer: '@Chris_L_Ayers - https://chris-ayers.com' --- -# What's New in .NET Aspire 9.5 +# What's New in .NET Aspire 13.4
## ⚙️ CLI & Tooling -- **aspire update** - Auto-update packages -- **SSH Remote port forwarding** in VS Code +- **TypeScript AppHost** now GA +- **aspire deploy / publish** GA +- **AI agent readiness** via skills -## 🎨 Dashboard Enhancements +## 🎨 Dashboard -- **GenAI Visualizer** - Explore AI interactions -- **Multi-resource console** logs view +- **AI Agents dialog** +- Structured search & trace filtering
-## 🤖 New Integrations +## 🧩 App Model -- **OpenAI hosting** integration -- **GitHub Models** & **Azure AI Foundry** catalogs -- **Dev Tunnels** hosting support +- **Go** & **Bun** hosting integrations +- **Blazor WebAssembly** hosting (preview) ## 🚀 Deployment -- **Azure Container App Jobs** -- **Built-in Azure deployment** via `aspire deploy` +- **Azure Container App Jobs** stable +- Richer **Kubernetes / AKS** APIs
@@ -324,7 +324,6 @@ aspire exec ```bash azd up # Deploy everything azd deploy # App only -azd provision # Infrastructure only azd init # Initialize ``` @@ -336,9 +335,7 @@ azd init # Initialize - Auto-detects app structure - Environment variable mapping - Native Aspire support -- Credential providers - Key Vault integration -- Secure access
@@ -368,8 +365,6 @@ var api = builder.AddProject("api") - **Automatic prompting** for missing parameters - **Rich form inputs** in dashboard - **Secret masking** for sensitive data -- **Validation support** with custom rules -- **Save to user secrets** for persistence **Input Types**: Text, Password, Choice, Boolean, Number @@ -383,7 +378,7 @@ var api = builder.AddProject("api") | **Component** | **Local** | **Cloud** | |--------------|----------|----------| | **AI Models** | Ollama, Foundry local, LM Studio | Azure OpenAI, AI Foundry | -| **Models** | Llama 3, Phi-4, Qwen | GPT-4.1, GPT-5-mini, Claude 4 | +| **Models** | Llama 3.2, Qwen2.5, Phi-4 | GPT-5, GPT-5-mini, Claude 4 | | **Vector DB** | Qdrant, Chroma, pgvector | Azure AI Search, Cosmos DB | **Features**: Zero-friction transitions • Emulator support • Hybrid development • Auto configuration • Secret management @@ -430,7 +425,6 @@ public class ExampleService(IChatClient chatClient) - **Privacy & Security**: All data stays on your machine - **Offline Capability**: Work without internet connectivity - **Fast Iteration**: No network latency for testing -- **Model Experimentation**: Try different open-source models @@ -461,11 +455,10 @@ public class ExampleService(IChatClient chatClient)
-- **Latest Models**: Access to GPT-4, Claude, and cutting-edge AI +- **Latest Models**: Access to GPT-5, Claude 4, and cutting-edge AI - **Global Availability**: 99.9% SLA with worldwide deployment - **Managed Infrastructure**: No server maintenance required - **Advanced Features**: Function calling, vision, audio processing -- **Compliance Ready**: SOC 2, HIPAA, and enterprise certifications
@@ -491,7 +484,6 @@ public class ExampleService(IChatClient chatClient) - **Inconsistent APIs**: Each framework had unique approaches - **Integration Complexity**: Difficult to combine tools - **Provider Lock-in**: Hard to switch AI providers -- **Learning Curve**: Multiple frameworks to master @@ -505,10 +497,9 @@ public class ExampleService(IChatClient chatClient) ## Unified Multi-Agent Platform -- **Consolidation**: Replaces Semantic Kernel & AutoGen -- **Built on Microsoft.Extensions.AI**: Leverages unified abstractions -- **Multi-Agent Orchestration**: Coordinate multiple AI agents -- **Event-Driven Architecture**: Flexible agent communication +- **Now GA**: Unifies Semantic Kernel & AutoGen +- **Built on Microsoft.Extensions.AI**: Unified abstractions +- **Multi-Agent Orchestration**: Coordinate AI agents
@@ -516,11 +507,33 @@ public class ExampleService(IChatClient chatClient) ## Key Features - **Agent Types**: Task-based, conversational, autonomous -- **Memory Systems**: Short-term and long-term context -- **Tool Integration**: Function calling and external tools +- **Tool Integration**: MCP tools & function calling - **Provider Agnostic**: Works with any AI backend -**Learn more**: [Microsoft Agent Framework](https://github.com/microsoft/agent-framework) +
+ + +--- + +# Model Context Protocol (MCP) + +
+
+ +## Standardized Tool Access + +- **Open protocol** for exposing tools to AI +- **Dynamic discovery**: agents load tools at runtime +- **Transport-agnostic**: stdio, HTTP, SSE + +
+
+ +## In this Demo + +- **MCP server** hosts Math, Weather, System & Text tools +- **MAF agent** discovers tools via `McpClientTool` +- **Local tool-calling** with Foundry Local (Qwen2.5)
@@ -547,19 +560,17 @@ public class ExampleService(IChatClient chatClient) ## Links - [.NET Aspire](https://learn.microsoft.com/en-us/dotnet/aspire/get-started/aspire-overview) -- [What's new in .NET Aspire 9.5](https://learn.microsoft.com/en-us/dotnet/aspire/whats-new/dotnet-aspire-9.5) +- [What's new in .NET Aspire 13.4](https://aspire.dev/whats-new/aspire-13-4/) - [Microsoft.Extensions.AI](https://learn.microsoft.com/en-us/dotnet/ai/microsoft-extensions-ai) - [Microsoft Agent Framework](https://github.com/microsoft/agent-framework) -- [Aspirify](https://aspireify.net/) - [Aspire Samples](https://github.com/dotnet/aspire-samples) -- [eShopLite](https://github.com/Azure-Samples/eShopLite)
## Follow Chris Ayers -![w:400px](./img/chris_ayers.svg) +![w:300px](./img/chris_ayers.svg)