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..1617535c 100644 --- a/samples/dotnet/Agent Framework/Agent/WeatherAgent.cs +++ b/samples/dotnet/Agent Framework/Agent/WeatherAgent.cs @@ -1,45 +1,73 @@ -// 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.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; + + // 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 +76,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 +112,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 = string.Equals(turnContext.Activity.ChannelId, "slack", StringComparison.OrdinalIgnoreCase); + + 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 +163,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 +223,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 +346,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", diff --git a/samples/dotnet/discordagent/DiscordAgent.csproj b/samples/dotnet/discordagent/DiscordAgent.csproj new file mode 100644 index 00000000..b7f10a95 --- /dev/null +++ b/samples/dotnet/discordagent/DiscordAgent.csproj @@ -0,0 +1,34 @@ + + + + Exe + net8.0 + enable + enable + discord-agent-vinchen + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/samples/dotnet/discordagent/Program.cs b/samples/dotnet/discordagent/Program.cs new file mode 100644 index 00000000..b470013b --- /dev/null +++ b/samples/dotnet/discordagent/Program.cs @@ -0,0 +1,206 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.Collections.Concurrent; +using System.Text; +using Azure; +using Azure.AI.OpenAI; +using Discord; +using Discord.WebSocket; +using DiscordAgent.Tools; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Configuration; +using ModelContextProtocol.Client; + +// Discord host for the weather (+ WorkIQ, next step) agent. +// +// Discord has no first-party Azure Bot Service channel, so we connect directly to the +// Discord Gateway with Discord.Net and drive the Agent Framework agent ourselves: +// Discord message -> agent.RunStreamingAsync -> reply as a Discord embed (Discord's card). + +var config = new ConfigurationBuilder() + .AddUserSecrets(typeof(Program).Assembly) + .AddEnvironmentVariables() + .Build(); + +// Bot token from user-secret "Discord:BotToken" or env var DISCORD_BOT_TOKEN. +var token = config["Discord:BotToken"] ?? Environment.GetEnvironmentVariable("DISCORD_BOT_TOKEN"); +if (string.IsNullOrWhiteSpace(token)) +{ + Console.Error.WriteLine( + "Missing Discord bot token. Set it with:\n" + + " dotnet user-secrets set \"Discord:BotToken\" \"\"\n" + + "or the DISCORD_BOT_TOKEN environment variable."); + return; +} + +// ---- Build the agent (same stack as the weather sample) ---- +var endpoint = config["AIServices:AzureOpenAI:Endpoint"]; +var apiKey = config["AIServices:AzureOpenAI:ApiKey"]; +var deployment = config["AIServices:AzureOpenAI:DeploymentName"]; +var openWeatherApiKey = config["OpenWeatherApiKey"]; + +if (string.IsNullOrWhiteSpace(endpoint) || string.IsNullOrWhiteSpace(apiKey) || + string.IsNullOrWhiteSpace(deployment) || string.IsNullOrWhiteSpace(openWeatherApiKey)) +{ + Console.Error.WriteLine( + "Missing AI/weather config. Set user-secrets:\n" + + " AIServices:AzureOpenAI:Endpoint, AIServices:AzureOpenAI:ApiKey, AIServices:AzureOpenAI:DeploymentName, OpenWeatherApiKey"); + return; +} + +const string instructions = """ + 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: + 1. United States weather -- use your weather tools: + - current-weather tool for current conditions (temperature, low/high, wind, humidity, short description). + - forecast tool for the next 5 days (date, high/low, short description). + - date tool to resolve "today". Location is a city name; resolve 2-letter US state codes to the full US state name. + 2. Anything that is NOT United States weather -- use the WorkIQ tools (Microsoft 365 / Teams: chats, channels, teams, messages). + + Routing rule: US weather questions go to the weather tools; every other question goes to the WorkIQ tools. + Format answers nicely in markdown, keep them easy to read, and always speak like a cat. Use emojis if it fits! + """; + +IChatClient chatClient = new AzureOpenAIClient(new Uri(endpoint), new AzureKeyCredential(apiKey)) + .GetChatClient(deployment) + .AsIChatClient(); + +var weatherTool = new WeatherTool(openWeatherApiKey); +var tools = new List +{ + AIFunctionFactory.Create(DateTimeTool.GetDate), + AIFunctionFactory.Create(weatherTool.GetCurrentWeatherForLocation), + AIFunctionFactory.Create(weatherTool.GetWeatherForecastForLocation), +}; + +// ---- WorkIQ (Agent 365) MCP tools ---- +// Discord has no OBO sign-in channel, so we use a dev bearer token for the Teams MCP server. +// Token from user-secret "WorkIQ:McpTeamsServerToken" or env BEARER_TOKEN_MCP_TEAMSSERVER +// (refresh with: a365 develop get-token ...). If absent, the agent runs weather-only. +var mcpToken = config["WorkIQ:McpTeamsServerToken"] ?? Environment.GetEnvironmentVariable("BEARER_TOKEN_MCP_TEAMSSERVER"); +if (!string.IsNullOrWhiteSpace(mcpToken)) +{ + try + { + var mcpTransport = new SseClientTransport(new SseClientTransportOptions + { + Endpoint = new Uri("https://agent365.svc.cloud.microsoft/agents/servers/mcp_TeamsServer"), + AdditionalHeaders = new Dictionary { ["Authorization"] = $"Bearer {mcpToken}" }, + TransportMode = HttpTransportMode.AutoDetect, + Name = "mcp_TeamsServer", + }); + var mcpClient = await McpClientFactory.CreateAsync(mcpTransport); + var mcpTools = await mcpClient.ListToolsAsync(); + tools.AddRange(mcpTools); + Console.WriteLine($"WorkIQ MCP tools loaded from mcp_TeamsServer: {mcpTools.Count}"); + } + catch (Exception ex) + { + Console.Error.WriteLine($"WorkIQ MCP tools failed to load (continuing weather-only): {ex.Message}"); + } +} +else +{ + Console.WriteLine("No WorkIQ MCP token (BEARER_TOKEN_MCP_TEAMSSERVER) - running weather-only."); +} + +AIAgent agent = new ChatClientAgent(chatClient, new ChatClientAgentOptions +{ + Name = "Purrfect Weather Agent", + ChatOptions = new ChatOptions + { + Temperature = 0.2f, + Tools = tools, + Instructions = instructions, + AllowMultipleToolCalls = true, + }, +}); + +// One conversation session per Discord channel, so context is preserved within a channel. +var sessions = new ConcurrentDictionary(); + +// ---- Discord wiring ---- +var socketConfig = new DiscordSocketConfig +{ + // MessageContent is a privileged intent - enable it in the Discord Developer Portal + // (Bot -> Privileged Gateway Intents -> Message Content Intent). + GatewayIntents = + GatewayIntents.Guilds | + GatewayIntents.GuildMessages | + GatewayIntents.DirectMessages | + GatewayIntents.MessageContent, + LogLevel = LogSeverity.Info +}; + +var client = new DiscordSocketClient(socketConfig); + +client.Log += msg => +{ + Console.WriteLine(msg.ToString()); + return Task.CompletedTask; +}; + +client.Ready += () => +{ + Console.WriteLine($"Discord bot ready as {client.CurrentUser}"); + return Task.CompletedTask; +}; + +client.MessageReceived += async (SocketMessage message) => +{ + // Only handle real user messages; ignore system messages and other bots (incl. ourselves). + if (message is not SocketUserMessage userMessage) return; + if (message.Author.IsBot) return; + + var userText = message.Content?.Trim() ?? string.Empty; + if (string.IsNullOrEmpty(userText)) return; + + try + { + using (userMessage.Channel.EnterTypingState()) + { + var session = sessions.GetOrAdd(message.Channel.Id, _ => agent.CreateSessionAsync().GetAwaiter().GetResult()); + + // Run the agent and collect the full answer. + var sb = new StringBuilder(); + await foreach (var update in agent.RunStreamingAsync(userText, session)) + { + if (update.Role == ChatRole.Assistant && !string.IsNullOrEmpty(update.Text)) + { + sb.Append(update.Text); + } + } + + var answer = sb.ToString(); + if (string.IsNullOrWhiteSpace(answer)) answer = "_(no response)_"; + if (answer.Length > 4000) answer = answer[..4000] + "…"; + + // Discord embed = Discord's equivalent of a Slack Block Kit card. + var embed = new EmbedBuilder() + .WithColor(new Color(0x2E, 0x9B, 0xF5)) + .WithAuthor("🐾 Purrfect Assistant") + .WithDescription(answer) + .WithFooter("Agent Framework + WorkIQ · Discord adapter") + .WithCurrentTimestamp() + .Build(); + + await userMessage.Channel.SendMessageAsync(embed: embed); + } + } + catch (Exception ex) + { + Console.Error.WriteLine($"Error handling message: {ex}"); + await userMessage.Channel.SendMessageAsync("Sorry, I hit an error while processing that. 🐾"); + } +}; + +await client.LoginAsync(TokenType.Bot, token); +await client.StartAsync(); + +Console.WriteLine("Discord agent host started. Press Ctrl+C to exit."); + +// Keep the process running. +await Task.Delay(Timeout.Infinite); diff --git a/samples/dotnet/discordagent/Tools/DateTimeTool.cs b/samples/dotnet/discordagent/Tools/DateTimeTool.cs new file mode 100644 index 00000000..91f2d42d --- /dev/null +++ b/samples/dotnet/discordagent/Tools/DateTimeTool.cs @@ -0,0 +1,15 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.ComponentModel; + +namespace DiscordAgent.Tools; + +/// +/// Simple date/time tool so the agent can resolve "today" for forecasts. +/// +public static class DateTimeTool +{ + [Description("Gets the current date and time (UTC).")] + public static string GetDate() => DateTimeOffset.UtcNow.ToString("f"); +} diff --git a/samples/dotnet/discordagent/Tools/WeatherTool.cs b/samples/dotnet/discordagent/Tools/WeatherTool.cs new file mode 100644 index 00000000..acc93e9c --- /dev/null +++ b/samples/dotnet/discordagent/Tools/WeatherTool.cs @@ -0,0 +1,76 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.ComponentModel; +using OpenWeatherMapSharp; +using OpenWeatherMapSharp.Models; + +namespace DiscordAgent.Tools; + +/// +/// Weather lookup tools for the Discord host. Unlike the Bot Framework sample's +/// WeatherLookupTool, this version has no ITurnContext dependency (Discord has no +/// Bot Framework turn), so it just calls OpenWeatherMap and returns the data. +/// +public class WeatherTool(string openWeatherApiKey) +{ + [Description("Retrieves the current weather for a location; location is a US city name and state is the full US state name.")] + public async Task GetCurrentWeatherForLocation(string location, string state) + { + Console.WriteLine($"[weather] current weather for {location}, {state}"); + + var openWeather = new OpenWeatherMapService(openWeatherApiKey); + var openWeatherLocation = await openWeather.GetLocationByNameAsync($"{location},{state}"); + if (openWeatherLocation is { IsSuccess: true }) + { + var locationInfo = openWeatherLocation.Response.FirstOrDefault(); + if (locationInfo == null) + { + throw new ArgumentException($"Unable to resolve location from provided information {location}, {state}"); + } + + var weather = await openWeather.GetWeatherAsync( + locationInfo.Latitude, locationInfo.Longitude, unit: OpenWeatherMapSharp.Models.Enums.Unit.Imperial); + if (weather.IsSuccess) + { + return weather.Response; + } + } + else + { + System.Diagnostics.Trace.WriteLine($"OpenWeather API call failed: {openWeatherLocation!.Error}"); + } + + return null; + } + + [Description("Retrieves the 5-day weather forecast for a location; location is a US city name and state is the full US state name.")] + public async Task?> GetWeatherForecastForLocation(string location, string state) + { + Console.WriteLine($"[weather] forecast for {location}, {state}"); + + var openWeather = new OpenWeatherMapService(openWeatherApiKey); + var openWeatherLocation = await openWeather.GetLocationByNameAsync($"{location},{state}"); + if (openWeatherLocation is { IsSuccess: true }) + { + var locationInfo = openWeatherLocation.Response.FirstOrDefault(); + if (locationInfo == null) + { + throw new ArgumentException($"Unable to resolve location from provided information {location}, {state}"); + } + + var weather = await openWeather.GetForecastAsync( + locationInfo.Latitude, locationInfo.Longitude, unit: OpenWeatherMapSharp.Models.Enums.Unit.Imperial); + if (weather.IsSuccess) + { + return weather.Response.Items; + } + } + else + { + System.Diagnostics.Trace.WriteLine($"OpenWeather API call failed: {openWeatherLocation!.Error}"); + } + + return null; + } +}