diff --git a/.gitignore b/.gitignore index 1f6136e1..98b6276a 100644 --- a/.gitignore +++ b/.gitignore @@ -398,3 +398,13 @@ devTools/ node_modules/ *.tsbuildinfo + +# a365 CLI local/generated config (tenant/app identifiers - do not commit) +a365.config.json +a365.generated.config.json + +# a365-generated Teams app package +**/appPackage/ + +# Slack extension per-conversation state (may contain API tokens) +samples/dotnet/Agent Framework/slack/ diff --git a/samples/dotnet/Agent Framework/Agent/WeatherAgent.cs b/samples/dotnet/Agent Framework/Agent/WeatherAgent.cs index bffd4a24..6cfaf357 100644 --- a/samples/dotnet/Agent Framework/Agent/WeatherAgent.cs +++ b/samples/dotnet/Agent Framework/Agent/WeatherAgent.cs @@ -1,45 +1,81 @@ -// 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.Agents.Extensions.Slack; +using Microsoft.Agents.Extensions.Slack.Api; +using Microsoft.Extensions.AI; +using System.Collections.Concurrent; +using System.Text; +using System.Text.Json; + namespace AgentFrameworkWeather.Agent { - public class WeatherAgent : AgentApplication + [SlackExtension] + public partial 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; - public WeatherAgent(AgentApplicationOptions options, IChatClient chatClient, IConfiguration configuration) : base(options) + // Cache of WorkIQ MCP tools keyed by access-token hash. Loading the tools is an HTTP + // round-trip to mcp_TeamsServer (~5s); the tool set is stable for the life of a token, + // so we reuse it across turns and only reload when the token changes or nears expiry. + private static readonly ConcurrentDictionary _mcpToolsCache = new(); + + private sealed record CachedMcpTools(IList Tools, DateTimeOffset ExpiresAt); + + // 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, + 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 +84,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) @@ -60,18 +120,42 @@ protected async Task WelcomeMessageAsync(ITurnContext turnContext, ITurnState tu } protected async Task OnMessageAsync(ITurnContext turnContext, ITurnState turnState, CancellationToken cancellationToken) { - // Start a Streaming Process to let clients that support streaming know that we are processing the request. + // Slack: render the response as native Slack Blocks (a card) instead of the + // streamed Bot Framework text. Other channels keep the streaming experience. + bool isSlack = turnContext.Activity.ChannelId == Channels.Slack; + + var userText = turnContext.Activity.Text?.Trim() ?? string.Empty; + + // 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); + + if (isSlack) + { + // Collect the full response, then post it as a Slack Block card. + var sb = new StringBuilder(); + await foreach (var response in _agent.RunStreamingAsync(userText, thread, cancellationToken: cancellationToken)) + { + if (response.Role == ChatRole.Assistant && !string.IsNullOrEmpty(response.Text)) + { + sb.Append(response.Text); + } + } + turnState.Conversation.SetValue("conversation.threadInfo", (await _agent.SerializeSessionAsync(thread)).ToString()); + await PostSlackBlocksAsync(turnContext, sb.ToString(), cancellationToken); + return; + } + + // Non-Slack channels: stream the response back as it is produced. await turnContext.StreamingResponse.QueueInformativeUpdateAsync("Just a moment please..").ConfigureAwait(false); - try { - var userText = turnContext.Activity.Text?.Trim() ?? string.Empty; - var _agent = GetClientAgent(turnContext); - - // Read or Create the conversation thread for this conversation. - AgentSession? thread = await GetConversationThread(_agent, turnState); - - // Stream the response back to the user as we receive it from the agent. await foreach (var response in _agent.RunStreamingAsync(userText, thread, cancellationToken: cancellationToken)) { if (response.Role == ChatRole.Assistant && !string.IsNullOrEmpty(response.Text)) @@ -87,6 +171,59 @@ protected async Task OnMessageAsync(ITurnContext turnContext, ITurnState turnSta } } + /// + /// Post the agent's answer to Slack as a Block Kit card (mrkdwn section) via the Slack API, + /// instead of the plain text that the Azure Bot Slack channel would render. + /// + private async Task PostSlackBlocksAsync(ITurnContext turnContext, string text, CancellationToken cancellationToken) + { + var channelData = turnContext.Activity.GetChannelData(); + + // Slack section text is limited to 3000 chars; trim defensively. + var body = string.IsNullOrWhiteSpace(text) ? "_(no response)_" : text; + if (body.Length > 2900) + { + body = body.Substring(0, 2900) + "…"; + } + + // JSON-encode the text (adds surrounding quotes + escaping) so it is safe inside the payload. + var encoded = JsonSerializer.Serialize(body); + + // Wrap the blocks in a message attachment with a blue accent color. Slack Block Kit + // "header" blocks are plain_text only (no colored text), so the blue vertical accent + // bar on the attachment is the standard way to give the card a blue "brand" color. + var message = $$""" + { + "channel": "{{channelData.Channel}}", + "attachments": [ + { + "color": "#2E9BF5", + "blocks": [ + { + "type": "header", + "text": { "type": "plain_text", "text": "🐾 Purrfect Assistant", "emoji": true } + }, + { + "type": "section", + "text": { "type": "mrkdwn", "text": {{encoded}} } + }, + { "type": "divider" }, + { + "type": "context", + "elements": [ + { "type": "mrkdwn", "text": ":robot_face: *Agent Framework* + :sparkles: *WorkIQ* | rendered with Slack Block Kit" } + ] + } + ] + } + ] + } + """; + + await SlackExtension.CallAsync(turnContext, "chat.postMessage", message, channelData.ApiToken, cancellationToken); + } + + /// /// Resolve the ChatClientAgent with tools and options for this turn operation. @@ -94,26 +231,32 @@ 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. + // 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 (token-cached) on top of the weather tools. + toolList.AddRange(await GetWorkIqMcpToolsAsync(context, authHandlerName, announceLoading: true).ConfigureAwait(false)); // 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!, @@ -136,6 +279,143 @@ private AIAgent GetClientAgent(ITurnContext context) .Build(); } + /// + /// Resolve the WorkIQ MCP tools for this operation, using the token-keyed cache. Returns an + /// empty list when auth or tools are unavailable. When is + /// true, a "Loading tools..." status is streamed on a cache miss (skipped for background warm-up). + /// + private async Task> GetWorkIqMcpToolsAsync(ITurnContext context, string? authHandlerName, bool announceLoading) + { + // Acquire the access token once - 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)."); + } + + if (_toolService == null || string.IsNullOrEmpty(agentId)) + { + return Array.Empty(); + } + + try + { + // The tool set is bound to the user's access token; cache it by token so we + // only pay the ~5s MCP load once per token instead of on every turn. + var cacheKey = HashToken(accessToken!); + if (_mcpToolsCache.TryGetValue(cacheKey, out var cachedTools) + && cachedTools.ExpiresAt > DateTimeOffset.UtcNow) + { + _logger?.LogInformation("WorkIQ MCP tools served from cache ({Count} tools).", cachedTools.Tools.Count); + return cachedTools.Tools; + } + + if (announceLoading) + { + 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) + { + // Expire the cache entry shortly before the token itself expires so we + // never invoke a cached tool with a dead token. + var expiresAt = GetTokenExpiry(accessToken!).AddMinutes(-1); + _mcpToolsCache[cacheKey] = new CachedMcpTools(a365Tools, expiresAt); + PruneExpiredMcpToolsCache(); + _logger?.LogInformation("WorkIQ MCP tools loaded from server ({Count} tools); cached until {Expiry:u}.", a365Tools.Count, expiresAt); + return a365Tools; + } + + return Array.Empty(); + } + 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)."); + return Array.Empty(); + } + + _logger?.LogError(ex, "Failed to register WorkIQ MCP tools."); + throw; + } + } + + /// + /// Extract the expiry (exp claim) from a JWT access token. Falls back to a short window + /// if the token cannot be parsed, so a bad token never yields a long-lived cache entry. + /// + private static DateTimeOffset GetTokenExpiry(string token) + { + try + { + var parts = token.Split('.'); + if (parts.Length >= 2) + { + var payload = parts[1].Replace('-', '+').Replace('_', '/'); + switch (payload.Length % 4) + { + case 2: payload += "=="; break; + case 3: payload += "="; break; + } + var json = Encoding.UTF8.GetString(Convert.FromBase64String(payload)); + using var doc = JsonDocument.Parse(json); + if (doc.RootElement.TryGetProperty("exp", out var expEl) && expEl.TryGetInt64(out var exp)) + { + return DateTimeOffset.FromUnixTimeSeconds(exp); + } + } + } + catch + { + // Ignore parse failures and use the fallback below. + } + return DateTimeOffset.UtcNow.AddMinutes(5); + } + + /// Stable, non-reversible cache key for an access token. + private static string HashToken(string token) + { + var hash = System.Security.Cryptography.SHA256.HashData(Encoding.UTF8.GetBytes(token)); + return Convert.ToHexString(hash); + } + + /// Drop expired MCP tool cache entries to bound memory as tokens rotate. + private static void PruneExpiredMcpToolsCache() + { + var now = DateTimeOffset.UtcNow; + foreach (var entry in _mcpToolsCache) + { + if (entry.Value.ExpiresAt <= now) + { + _mcpToolsCache.TryRemove(entry.Key, out _); + } + } + } + /// /// Manage Agent threads against the conversation state. /// @@ -158,5 +438,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..af1581d7 100644 --- a/samples/dotnet/Agent Framework/AgentFrameworkWeather.csproj +++ b/samples/dotnet/Agent Framework/AgentFrameworkWeather.csproj @@ -16,6 +16,13 @@ + + + + + + + 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/ToolingManifest.json b/samples/dotnet/Agent Framework/ToolingManifest.json new file mode 100644 index 00000000..10fc976e --- /dev/null +++ b/samples/dotnet/Agent Framework/ToolingManifest.json @@ -0,0 +1,12 @@ +{ + "mcpServers": [ + { + "mcpServerName": "mcp_TeamsServer", + "mcpServerUniqueName": "mcp_TeamsServer", + "url": "https://agent365.svc.cloud.microsoft/agents/servers/mcp_TeamsServer", + "scope": "Tools.ListInvoke.All", + "audience": "ce5029ee-c1d3-45c0-bdcc-efb5a4245687", + "publisher": "Microsoft" + } + ] +} 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",