From 95a7a2494712b488d43bf4a5e6b8b7973487750e Mon Sep 17 00:00:00 2001 From: Vincent Chen Date: Thu, 9 Jul 2026 21:25:53 -0700 Subject: [PATCH] Port WorkIQ MCP integration to C# (.NET agent-framework weather sample) --- .../Agent Framework/Agent/WeatherAgent.cs | 189 ++++++++++++++---- .../AgentFrameworkWeather.csproj | 4 + samples/dotnet/Agent Framework/Program.cs | 27 ++- .../dotnet/Agent Framework/appsettings.json | 7 + 4 files changed, 180 insertions(+), 47 deletions(-) diff --git a/samples/dotnet/Agent Framework/Agent/WeatherAgent.cs b/samples/dotnet/Agent Framework/Agent/WeatherAgent.cs index bffd4a24..6a3b9f13 100644 --- a/samples/dotnet/Agent Framework/Agent/WeatherAgent.cs +++ b/samples/dotnet/Agent Framework/Agent/WeatherAgent.cs @@ -1,45 +1,69 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -using AgentFrameworkWeather.Tools; -using Microsoft.Agents.AI; -using Microsoft.Agents.Builder; -using Microsoft.Agents.Builder.App; -using Microsoft.Agents.Builder.State; -using Microsoft.Agents.Core; -using Microsoft.Agents.Core.Models; -using Microsoft.Agents.Core.Serialization; -using Microsoft.Agents.Core.Telemetry; -using Microsoft.Extensions.AI; -using System.Text.Json; - +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using AgentFrameworkWeather.Tools; +using Microsoft.Agents.A365.Runtime.Utils; +using Microsoft.Agents.A365.Tooling.Extensions.AgentFramework.Services; +using Microsoft.Agents.AI; +using Microsoft.Agents.Builder; +using Microsoft.Agents.Builder.App; +using Microsoft.Agents.Builder.State; +using Microsoft.Agents.Core; +using Microsoft.Agents.Core.Models; +using Microsoft.Agents.Core.Serialization; +using Microsoft.Agents.Core.Telemetry; +using Microsoft.Extensions.AI; +using System.Text.Json; + namespace AgentFrameworkWeather.Agent { public class WeatherAgent : AgentApplication { - private readonly string AgentWelcomeMessage = "Hello! I'm your friendly weather cat assistant. I can help you find the current weather or a weather forecast for any city. Just tell me the city name and, if you're in the US, the 2-letter state code. Meow!"; - - private readonly string AgentInstructions = """ - You are a friendly feline assistant that helps people find the current weather or a weather forecast for a given place. - You will always speak like a cat. - Location is a city name, 2 letter US state codes should be resolved to the full name of the United States State. - You may ask follow up questions until you have enough information to answer the customers question, but once you have the current weather or a forecast, make sure to format it nicely in text. - - For current weather, Use the {{WeatherLookupTool.GetCurrentWeatherForLocation}}, you should include the current temperature, low and high temperatures, wind speed, humidity, and a short description of the weather. - For forecast's, Use the {{WeatherLookupTool.GetWeatherForecastForLocation}}, you should report on the next 5 days, including the current day, and include the date, high and low temperatures, and a short description of the weather. - You should use the {{DateTimePlugin.GetDateTime}} to get the current date and time. - - When responding, make sure to format the information in a way that is easy to read and understand, markdown is good, and always speak like a cat. Use emojis if it fits the response! - + private readonly string AgentWelcomeMessage = "Hello! I'm your friendly purr-ductivity cat assistant. I can fetch the current weather or forecast for any US city, and I can help with your Microsoft 365 work too - like Teams chats, mail, and files. Ask me a weather question (city + 2-letter state) or anything about your workday. Meow!"; + + private readonly string AgentInstructions = """ + You are a friendly feline assistant. You always speak like a cat (use "meow", playful cat puns, and emojis when they fit). + + You can help with two kinds of requests, and you must always pick the right tool for each: + + 1. Weather in the United States -- use your local weather tools: + - Use {{WeatherLookupTool.GetCurrentWeatherForLocation}} for current conditions. Include the current temperature, low and high temperatures, wind speed, humidity, and a short description of the weather. + - Use {{WeatherLookupTool.GetWeatherForecastForLocation}} for forecasts. Report the next 5 days, including the current day, with the date, high and low temperatures, and a short description. + - Use {{DateTimeFunctionTool.getDate}} to get the current date and time. + - Location is a city name; resolve 2-letter US state codes to the full name of the United States state. + + 2. Anything that is NOT United States weather -- use the WorkIQ tools to answer. This includes Microsoft 365 and Microsoft Teams tasks such as reading or posting chat messages, listing chats, channels, and teams, and other workplace questions. + + Routing rule: US weather questions go to the weather tools; every other question goes to the WorkIQ tools. You may ask brief follow-up questions when you need more detail. Always format answers nicely in markdown, keep them easy to read, and always speak like a cat. Use emojis if it fits the response! """; private readonly IChatClient? _chatClient = null; private readonly IConfiguration? _configuration = null; + private readonly ILogger? _logger = null; + + // WorkIQ (Agent 365) MCP tool service. Nullable so the agent still runs + // (weather-only) when the service or its configuration is unavailable. + private readonly IMcpToolRegistrationService? _toolService = null; + + // Auth handler names for MCP access (configurable via appsettings.json). + private readonly string? AgenticAuthHandlerName; + private readonly string? OboAuthHandlerName; - public WeatherAgent(AgentApplicationOptions options, IChatClient chatClient, IConfiguration configuration) : base(options) + public WeatherAgent( + AgentApplicationOptions options, + IChatClient chatClient, + IConfiguration configuration, + IMcpToolRegistrationService? toolService = null, + ILogger? logger = null) : base(options) { _chatClient = chatClient; _configuration = configuration; + _toolService = toolService; + _logger = logger; + + // Read auth handler names from configuration (can be empty/null to disable). + AgenticAuthHandlerName = _configuration.GetValue("AgentApplication:AgenticAuthHandlerName"); + OboAuthHandlerName = _configuration.GetValue("AgentApplication:OboAuthHandlerName"); // Greet when members are added to the conversation OnConversationUpdate(ConversationUpdateEvents.MembersAdded, WelcomeMessageAsync); @@ -48,6 +72,30 @@ public WeatherAgent(AgentApplicationOptions options, IChatClient chatClient, ICo OnActivity(ActivityTypes.Message, OnMessageAsync); } + /// + /// Check if a bearer token is available in the environment for development/testing. + /// + private static bool TryGetBearerTokenForDevelopment(out string? bearerToken) + { + bearerToken = Environment.GetEnvironmentVariable("BEARER_TOKEN"); + return !string.IsNullOrEmpty(bearerToken); + } + + /// + /// Graceful fallback to weather-only mode when MCP tools fail to load. + /// Only allowed in Development AND when SKIP_TOOLING_ON_ERRORS=true. + /// + private static bool ShouldSkipToolingOnErrors() + { + var environment = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") + ?? Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT") + ?? "Production"; + var skip = Environment.GetEnvironmentVariable("SKIP_TOOLING_ON_ERRORS"); + return environment.Equals("Development", StringComparison.OrdinalIgnoreCase) + && !string.IsNullOrEmpty(skip) + && skip.Equals("true", StringComparison.OrdinalIgnoreCase); + } + protected async Task WelcomeMessageAsync(ITurnContext turnContext, ITurnState turnState, CancellationToken cancellationToken) { foreach (ChannelAccount member in turnContext.Activity.MembersAdded) @@ -66,7 +114,13 @@ protected async Task OnMessageAsync(ITurnContext turnContext, ITurnState turnSta try { var userText = turnContext.Activity.Text?.Trim() ?? string.Empty; - var _agent = GetClientAgent(turnContext); + + // Pick the auth handler for this turn (agentic vs OBO). In dev the BEARER_TOKEN path is used. + string? toolAuthHandlerName = turnContext.Activity.IsAgenticRequest() + ? AgenticAuthHandlerName + : OboAuthHandlerName; + + var _agent = await GetClientAgent(turnContext, turnState, toolAuthHandlerName); // Read or Create the conversation thread for this conversation. AgentSession? thread = await GetConversationThread(_agent, turnState); @@ -94,26 +148,85 @@ protected async Task OnMessageAsync(ITurnContext turnContext, ITurnState turnSta /// /// /// - private AIAgent GetClientAgent(ITurnContext context) + private async Task GetClientAgent(ITurnContext context, ITurnState turnState, string? authHandlerName) { AssertionHelpers.ThrowIfNull(_configuration!, nameof(_configuration)); AssertionHelpers.ThrowIfNull(context, nameof(context)); AssertionHelpers.ThrowIfNull(_chatClient!, nameof(_chatClient)); - // Setup the local tool to be able to access the AgentSDK current context,UserAuthorization and other services can be accessed from here as well. + // Acquire the access token once for this turn - used for WorkIQ MCP tool loading. + string? accessToken = null; + string? agentId = null; + if (!string.IsNullOrEmpty(authHandlerName)) + { + // Production / Teams: exchange an OBO token via the auth handler. + accessToken = await UserAuthorization.GetTurnTokenAsync(context, authHandlerName); + agentId = Utility.ResolveAgentIdentity(context, accessToken); + } + else if (TryGetBearerTokenForDevelopment(out var bearerToken)) + { + // Local dev: use the bearer token from `a365 develop get-token`. + _logger?.LogInformation("Using bearer token from environment for WorkIQ MCP."); + accessToken = bearerToken; + agentId = Utility.ResolveAgentIdentity(context, accessToken!); + } + else + { + _logger?.LogWarning("No auth handler or bearer token - WorkIQ MCP tools will not be loaded (weather-only)."); + } + + // Setup the local (weather) tools - these are always available. WeatherLookupTool weatherLookupTool = new(context, _configuration!); + var toolList = new List + { + AIFunctionFactory.Create(DateTimeFunctionTool.getDate), + AIFunctionFactory.Create(weatherLookupTool.GetCurrentWeatherForLocation), + AIFunctionFactory.Create(weatherLookupTool.GetWeatherForecastForLocation) + }; + + // Attach the WorkIQ MCP tools on top of the weather tools when available. + if (_toolService != null && !string.IsNullOrEmpty(agentId)) + { + try + { + await context.StreamingResponse.QueueInformativeUpdateAsync("Loading tools..."); + + // For the bearer-token (dev) flow, pass the token as an override and + // use the OBO/agentic handler name if configured. + var handlerForMcp = !string.IsNullOrEmpty(authHandlerName) + ? authHandlerName + : OboAuthHandlerName ?? AgenticAuthHandlerName ?? string.Empty; + var tokenOverride = string.IsNullOrEmpty(authHandlerName) ? accessToken : null; + + var a365Tools = await _toolService.GetMcpToolsAsync(agentId, UserAuthorization, handlerForMcp, context, tokenOverride).ConfigureAwait(false); + if (a365Tools != null && a365Tools.Count > 0) + { + toolList.AddRange(a365Tools); + } + } + catch (Exception ex) + { + // If setup fails, keep serving weather instead of crashing. + if (ShouldSkipToolingOnErrors()) + { + _logger?.LogWarning(ex, "Failed to register WorkIQ MCP tools. Continuing weather-only (SKIP_TOOLING_ON_ERRORS=true)."); + } + else + { + _logger?.LogError(ex, "Failed to register WorkIQ MCP tools."); + throw; + } + } + } // Setup the tools for the agent: var toolOptions = new ChatOptions { Temperature = (float?)0.2, - Tools = new List(), + Tools = toolList, Instructions = AgentInstructions, AllowMultipleToolCalls = true }; - toolOptions.Tools.Add(AIFunctionFactory.Create(DateTimeFunctionTool.getDate)); - toolOptions.Tools.Add(AIFunctionFactory.Create(weatherLookupTool.GetCurrentWeatherForLocation)); - toolOptions.Tools.Add(AIFunctionFactory.Create(weatherLookupTool.GetWeatherForecastForLocation)); // Create the chat Client passing in agent instructions and tools: return new ChatClientAgent(_chatClient!, @@ -158,5 +271,5 @@ private static async Task GetConversationThread(AIAgent? agent, IT } return thread; } - } + } } \ No newline at end of file diff --git a/samples/dotnet/Agent Framework/AgentFrameworkWeather.csproj b/samples/dotnet/Agent Framework/AgentFrameworkWeather.csproj index ec4da8c9..14a85697 100644 --- a/samples/dotnet/Agent Framework/AgentFrameworkWeather.csproj +++ b/samples/dotnet/Agent Framework/AgentFrameworkWeather.csproj @@ -16,6 +16,10 @@ + + + + diff --git a/samples/dotnet/Agent Framework/Program.cs b/samples/dotnet/Agent Framework/Program.cs index 6dfdf10e..b92a1f5e 100644 --- a/samples/dotnet/Agent Framework/Program.cs +++ b/samples/dotnet/Agent Framework/Program.cs @@ -5,6 +5,8 @@ using AgentFrameworkWeather.Agent; using Azure; using Azure.AI.OpenAI; +using Microsoft.Agents.A365.Tooling.Extensions.AgentFramework.Services; +using Microsoft.Agents.A365.Tooling.Services; using Microsoft.Agents.Builder; using Microsoft.Agents.Core; using Microsoft.Agents.Hosting.AspNetCore; @@ -20,8 +22,8 @@ builder.Services.AddHttpClient("WebClient", client => client.Timeout = TimeSpan.FromSeconds(600)); builder.Services.AddHttpContextAccessor(); -// Configure defaults for Aspire dashboard -builder.ConfigureOtelProviders(); +// Configure defaults for Aspire dashboard +builder.ConfigureOtelProviders(); builder.Logging.AddConsole(); @@ -34,6 +36,13 @@ // in a cluster of Agent instances. builder.Services.AddSingleton(); +// ********** Configure A365 (WorkIQ) Services ********** +// Registers the MCP tool registration + server configuration services so the +// agent can discover and call WorkIQ (Agent 365) MCP tools at runtime. +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +// ********** END Configure A365 Services ********** + // Add the bot (which is transient) builder.AddAgent(); @@ -72,17 +81,17 @@ app.UseAuthentication(); app.UseAuthorization(); -// Map GET "/" -app.MapAgentRootEndpoint(); - -// Map the endpoints for all agents using the [AgentInterface] attribute. -// If there is a single IAgent/AgentApplication, the endpoints will be mapped to (e.g. "/api/message"). -app.MapAgentApplicationEndpoints(requireAuth: !(app.Environment.IsDevelopment() || app.Environment.EnvironmentName == "Playground")); +// Map GET "/" +app.MapAgentRootEndpoint(); + +// Map the endpoints for all agents using the [AgentInterface] attribute. +// If there is a single IAgent/AgentApplication, the endpoints will be mapped to (e.g. "/api/message"). +app.MapAgentApplicationEndpoints(requireAuth: !(app.Environment.IsDevelopment() || app.Environment.EnvironmentName == "Playground")); if (app.Environment.IsDevelopment() || app.Environment.EnvironmentName == "Playground") { app.UseDeveloperExceptionPage(); - app.MapControllers().AllowAnonymous(); + app.MapControllers().AllowAnonymous(); } else { diff --git a/samples/dotnet/Agent Framework/appsettings.json b/samples/dotnet/Agent Framework/appsettings.json index 0161ea42..5cc6ac86 100644 --- a/samples/dotnet/Agent Framework/appsettings.json +++ b/samples/dotnet/Agent Framework/appsettings.json @@ -13,6 +13,13 @@ "NormalizeMentions": false }, + "AgentApplication": { + // WorkIQ MCP auth handler names. Leave empty for local dev (uses BEARER_TOKEN env var). + // Set these for production / Teams to use OBO / agentic token exchange. + "AgenticAuthHandlerName": "", + "OboAuthHandlerName": "" + }, + "Logging": { "LogLevel": { "Default": "Information",