-
Notifications
You must be signed in to change notification settings - Fork 16
Add AI/MCP Client example #486
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
16 commits
Select commit
Hold shift + click to select a range
e2e284a
init
MehakBindra 8aadac7
Merge branch 'main' into mehak/ext-ai-sample
MehakBindra ddd1748
update readme
MehakBindra eb8892f
Merge branch 'mehak/ext-ai-sample' of https://github.com/microsoft/te…
MehakBindra 3d9541c
fixes
MehakBindra 7554c66
fixes
MehakBindra 76b5e1e
fixes
MehakBindra 1a6b026
Merge branch 'main' into mehak/ext-ai-sample
MehakBindra e19169e
Merge branch 'main' into mehak/ext-ai-sample
MehakBindra be49faa
final fixes
MehakBindra cb73f71
feedback
MehakBindra 14c8a88
Merge branch 'main' into mehak/ext-ai-sample
MehakBindra 15f77c3
Merge branch 'mehak/ext-ai-sample' of https://github.com/microsoft/te…
MehakBindra 7993165
fixes
MehakBindra 841315c
Merge branch 'main' into mehak/ext-ai-sample
MehakBindra 1b98e29
Merge branch 'main' into mehak/ext-ai-sample
MehakBindra File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,155 @@ | ||
| // Copyright (c) Microsoft Corporation. | ||
| // Licensed under the MIT License. | ||
|
|
||
| using System.Collections.Concurrent; | ||
| using System.Text; | ||
| using System.Text.Json.Serialization; | ||
| using Microsoft.Extensions.AI; | ||
| using Microsoft.Teams.Apps; | ||
| using Microsoft.Teams.Apps.Schema; | ||
|
|
||
| namespace ExtAIBot; | ||
|
|
||
| // Holds the IChatClient, per-conversation history, and the MCP tool set. | ||
| // RunAsync drives a single turn: it builds per-turn tools (local + MCP wrapped for citations), | ||
| // streams the model response, then runs a dedicated structured-output call to | ||
| // generate exactly 2 follow-up suggestions. | ||
| sealed class Agent | ||
| { | ||
| private readonly IChatClient _chatClient; | ||
| private readonly McpToolSetLifetimeService _mcpTools; | ||
| private readonly ILogger<Agent> _logger; | ||
| private readonly ConcurrentDictionary<string, List<ChatMessage>> _histories = new(); | ||
| // One lock per conversation so concurrent turns on the same conversation serialize | ||
| // their history mutations (List<ChatMessage> is not thread-safe). | ||
| private readonly ConcurrentDictionary<string, SemaphoreSlim> _locks = new(); | ||
|
|
||
| private const string SystemPrompt = """ | ||
| You are a Teams docs assistant that can search Microsoft Learn (Teams, .NET, Microsoft Graph, Azure) | ||
| and explain bot concepts (streaming, Adaptive Cards, citations, feedback). | ||
|
|
||
| When you use information from a search tool, cite your sources inline using the "citation" value \ | ||
| provided in each result (e.g. [1], [2]). | ||
| Do not add a references or sources list at the end of your response — citations are displayed separately in the UI. | ||
| """; | ||
|
|
||
| private const string FollowUpsPrompt = """ | ||
| Produce 2 specific prompts the user might want to ask next. | ||
|
|
||
| Output format — read carefully: | ||
| Return ONLY a JSON object INSTANCE, like this: | ||
| {"prompt1": "How do I stream a reply?", "prompt2": "Show me an Adaptive Card example"} | ||
|
|
||
| Each prompt MUST: | ||
| - Be phrased in the first person, as the user would type. | ||
| - Stay under 8 words. | ||
|
|
||
| Pick based on the conversation: | ||
| - If recent turns have substantive content, drill into a concrete topic, API, or | ||
| concept that just came up. | ||
| - Otherwise (e.g. conversation just started, or the last turn is generic), | ||
| suggest prompts that showcase what you can help with based on the MCP tools available. | ||
| """; | ||
|
|
||
| private sealed record FollowUps( | ||
| [property: JsonPropertyName("prompt1")] string Prompt1, | ||
| [property: JsonPropertyName("prompt2")] string Prompt2); | ||
|
|
||
| public Agent(IChatClient chatClient, McpToolSetLifetimeService mcpTools, ILogger<Agent> logger) | ||
| { | ||
| _chatClient = chatClient; | ||
| _mcpTools = mcpTools; | ||
| _logger = logger; | ||
| } | ||
|
|
||
| public async Task<RunResult> RunAsync( | ||
| string conversationId, | ||
| string userText, | ||
| TeamsStreamingWriter writer, | ||
| CancellationToken cancellationToken) | ||
| { | ||
| List<ChatMessage> history = _histories.GetOrAdd( | ||
| conversationId, | ||
| _ => [new ChatMessage(ChatRole.System, SystemPrompt)]); | ||
|
|
||
| // Serialize turns within a single conversation so concurrent submits | ||
| // (e.g. clarification race) don't interleave history mutations. | ||
| SemaphoreSlim gate = _locks.GetOrAdd(conversationId, _ => new SemaphoreSlim(1, 1)); | ||
| await gate.WaitAsync(cancellationToken).ConfigureAwait(false); | ||
| try | ||
| { | ||
| List<object> pendingCards = []; | ||
| CitationCollector citations = new(_logger); | ||
| McpToolSet mcpTools = _mcpTools.Value; | ||
|
|
||
| ChatOptions options = new() | ||
| { | ||
| Tools = | ||
| [ | ||
| LocalTools.CreateClarificationCardTool(pendingCards, _logger), | ||
| .. mcpTools.GetTools(citations) | ||
| ] | ||
| }; | ||
|
|
||
| history.Add(new ChatMessage(ChatRole.User, userText)); | ||
| await writer.SendInformativeUpdateAsync("Thinking…", cancellationToken); | ||
|
|
||
| StringBuilder fullText = new(); | ||
| await foreach (ChatResponseUpdate update in | ||
| _chatClient.GetStreamingResponseAsync(history, options, cancellationToken)) | ||
| { | ||
| if (!string.IsNullOrEmpty(update.Text)) | ||
| { | ||
| await writer.AppendResponseAsync(update.Text, cancellationToken); | ||
| fullText.Append(update.Text); | ||
| } | ||
| } | ||
|
|
||
| string fullTextStr = fullText.ToString(); | ||
| if (fullTextStr.Length > 0) | ||
| history.Add(new ChatMessage(ChatRole.Assistant, fullTextStr)); | ||
|
|
||
| List<SuggestedAction> followUpActions = await GenerateFollowUpsAsync(history, cancellationToken); | ||
|
|
||
| return new RunResult(fullTextStr, pendingCards, followUpActions, citations); | ||
| } | ||
| finally | ||
| { | ||
| gate.Release(); | ||
| } | ||
| } | ||
|
|
||
| // Runs after the streamed reply is in history. Forces structured JSON output matching | ||
| // the FollowUps shape so we always get exactly 2 suggestions to display as chips. | ||
| private async Task<List<SuggestedAction>> GenerateFollowUpsAsync( | ||
| IReadOnlyList<ChatMessage> history, | ||
| CancellationToken cancellationToken) | ||
| { | ||
| List<ChatMessage> messages = | ||
| [ | ||
| .. history, | ||
| new ChatMessage(ChatRole.System, FollowUpsPrompt) | ||
| ]; | ||
|
|
||
| ChatResponse<FollowUps> response = await _chatClient.GetResponseAsync<FollowUps>( | ||
| messages, | ||
| cancellationToken: cancellationToken); | ||
|
|
||
| if (!response.TryGetResult(out FollowUps? followUps) || followUps is null) | ||
| { | ||
| _logger.LogWarning("Follow-up generation did not return parseable JSON. Raw response: {Text}", response.Text); | ||
| return []; | ||
| } | ||
|
|
||
| return [ | ||
| new SuggestedAction(ActionType.IMBack, followUps.Prompt1), | ||
| new SuggestedAction(ActionType.IMBack, followUps.Prompt2) | ||
| ]; | ||
| } | ||
| } | ||
|
|
||
| readonly record struct RunResult( | ||
| string FullText, | ||
| IList<object> PendingCards, | ||
| IList<SuggestedAction> FollowUpActions, | ||
| CitationCollector Citations); | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,110 @@ | ||
| // Copyright (c) Microsoft Corporation. | ||
| // Licensed under the MIT License. | ||
|
|
||
| using System.Text.Json; | ||
| using System.Text.RegularExpressions; | ||
| using Microsoft.Teams.Apps.Schema.Entities; | ||
|
|
||
| namespace ExtAIBot; | ||
|
|
||
| // Parses MCP search results as they are returned by tools and accumulates citation metadata. | ||
| // After streaming completes, BuildEntities() returns Teams CitationEntity objects for any | ||
| // [N] references that appear in the final response text. | ||
| sealed class CitationCollector | ||
| { | ||
| private readonly ILogger _logger; | ||
| private readonly Dictionary<string, CitationEntry> _citations = []; | ||
|
|
||
| public CitationCollector(ILogger logger) | ||
| { | ||
| _logger = logger; | ||
| } | ||
|
|
||
| public void TryExtract(string result) | ||
| { | ||
| try | ||
| { | ||
| using JsonDocument doc = JsonDocument.Parse(result); | ||
| if (!TryFindResults(doc.RootElement, out JsonElement results)) return; | ||
|
|
||
| foreach (JsonElement item in results.EnumerateArray()) | ||
| { | ||
| string? url = GetString(item, "contentUrl") ?? GetString(item, "link"); | ||
| if (url is null || _citations.ContainsKey(url)) continue; | ||
|
|
||
| string snippet = GetString(item, "content") ?? GetString(item, "description") ?? ""; | ||
| _citations[url] = new CitationEntry( | ||
| Position: _citations.Count + 1, | ||
| Url: url, | ||
| Title: GetString(item, "title") ?? "", | ||
| Snippet: snippet.Length > 160 ? snippet[..160] : snippet); | ||
| } | ||
| } | ||
| catch (JsonException ex) | ||
| { | ||
| _logger.LogDebug(ex, "Skipped citation extraction because the tool result was not valid JSON."); | ||
| } | ||
| catch (FormatException ex) | ||
| { | ||
| _logger.LogDebug(ex, "Skipped citation extraction because the tool result had an unexpected format."); | ||
| } | ||
| } | ||
|
MehakBindra marked this conversation as resolved.
|
||
|
|
||
| public IList<Entity> BuildEntities(string fullText) | ||
| { | ||
| HashSet<int> used = []; | ||
| foreach (Match match in Regex.Matches(fullText, @"\[(\d+)\]")) | ||
| { | ||
| if (int.TryParse(match.Groups[1].Value, out int position)) | ||
| used.Add(position); | ||
| } | ||
|
|
||
| List<CitationClaim> claims = [.. _citations.Values | ||
| .Where(e => used.Contains(e.Position)) | ||
| .Select(e => new CitationClaim | ||
| { | ||
| Position = e.Position, | ||
| Appearance = new CitationAppearance | ||
| { | ||
| Name = string.IsNullOrEmpty(e.Title) | ||
| ? $"Source {e.Position}" | ||
| : e.Title[..Math.Min(80, e.Title.Length)], | ||
| Abstract = string.IsNullOrEmpty(e.Snippet) | ||
| ? "No description available." | ||
| : e.Snippet, | ||
| Url = Uri.TryCreate(e.Url, UriKind.Absolute, out Uri? uri) ? uri : null | ||
| }.ToDocument() | ||
| })]; | ||
|
|
||
| // TODO : work on Add Citations/feedback/AI label etc in builder | ||
| return claims.Count == 0 | ||
| ? [new OMessageEntity { AdditionalType = ["AIGeneratedContent"] }] | ||
| : [new CitationEntity { AdditionalType = ["AIGeneratedContent"], Citation = claims }]; | ||
| } | ||
|
|
||
| // MCP InvokeAsync returns a JsonElement of CallToolResult, not the raw server JSON. | ||
| // Results may be at root or nested one level deep (e.g. CallToolResult.structuredContent.results). | ||
| private static bool TryFindResults(JsonElement element, out JsonElement results) | ||
| { | ||
| if (element.TryGetProperty("results", out results) && results.ValueKind == JsonValueKind.Array) | ||
| return true; | ||
|
|
||
| foreach (JsonProperty prop in element.EnumerateObject()) | ||
| { | ||
| if (prop.Value.ValueKind == JsonValueKind.Object && | ||
| prop.Value.TryGetProperty("results", out results) && | ||
| results.ValueKind == JsonValueKind.Array) | ||
| return true; | ||
| } | ||
|
|
||
| results = default; | ||
| return false; | ||
| } | ||
|
|
||
| private static string? GetString(JsonElement el, string property) => | ||
| el.TryGetProperty(property, out JsonElement v) && v.ValueKind == JsonValueKind.String | ||
| ? v.GetString() | ||
| : null; | ||
| } | ||
|
|
||
| sealed record CitationEntry(int Position, string Url, string Title, string Snippet); | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,21 @@ | ||
| <Project Sdk="Microsoft.NET.Sdk.Web"> | ||
|
|
||
| <PropertyGroup> | ||
| <TargetFramework>net10.0</TargetFramework> | ||
| <Nullable>enable</Nullable> | ||
| <ImplicitUsings>enable</ImplicitUsings> | ||
| </PropertyGroup> | ||
|
|
||
| <ItemGroup> | ||
| <ProjectReference Include="..\..\src\Microsoft.Teams.Apps\Microsoft.Teams.Apps.csproj" /> | ||
| </ItemGroup> | ||
|
|
||
| <ItemGroup> | ||
| <PackageReference Include="Azure.AI.OpenAI" Version="2.1.0" /> | ||
| <PackageReference Include="Microsoft.Extensions.AI" Version="10.5.2" /> | ||
| <PackageReference Include="Microsoft.Extensions.AI.OpenAI" Version="10.5.2" /> | ||
| <PackageReference Include="Microsoft.Teams.Cards" Version="2.0.6" /> | ||
| <PackageReference Include="ModelContextProtocol" Version="1.2.0" /> | ||
| </ItemGroup> | ||
|
|
||
| </Project> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,56 @@ | ||
| // Copyright (c) Microsoft Corporation. | ||
| // Licensed under the MIT License. | ||
|
|
||
| using System.ComponentModel; | ||
| using System.Text.Json; | ||
| using System.Text.Json.Serialization; | ||
| using Microsoft.Extensions.AI; | ||
| using Microsoft.Teams.Cards; | ||
|
|
||
| namespace ExtAIBot; | ||
|
|
||
| // Provides local AIFunction definitions that the model can call during a turn. | ||
| static class LocalTools | ||
| { | ||
| // Returns a fresh AIFunction each turn; pendingCards is a per-turn accumulator | ||
| // captured by closure. | ||
|
|
||
| private static readonly JsonSerializerOptions SerializerOptions = new() | ||
| { | ||
| DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull | ||
| }; | ||
| public static AIFunction CreateClarificationCardTool(IList<object> pendingCards, ILogger logger) => | ||
| AIFunctionFactory.Create( | ||
| ([Description("The clarification question to ask the user.")] string question, | ||
| [Description("2–4 candidate interpretations the user can pick between.")] string[] options) => | ||
| { | ||
| logger.LogInformation("[tool] request_clarification(question={Question}, options=[{Options}])", | ||
| question, string.Join(", ", options)); | ||
| pendingCards.Add(BuildClarificationCard(question, options)); | ||
| return "Clarification card attached."; | ||
| }, | ||
| "request_clarification", | ||
| "Show an Adaptive Card asking the user to clarify their request when needed. " + | ||
| "The user picks one option and submits; their choice arrives as the next user turn."); | ||
|
|
||
| private static JsonElement BuildClarificationCard(string question, string[] options) | ||
| { | ||
| AdaptiveCard card = new AdaptiveCard( | ||
| new TextBlock(question) | ||
| .WithSize(TextSize.Medium) | ||
| .WithWeight(TextWeight.Bolder) | ||
| .WithWrap(true), | ||
| new ChoiceSetInput([.. options.Select(o => new Choice { Title = o, Value = o })]) | ||
| .WithId("clarificationChoice") | ||
| .WithIsRequired(true) | ||
| .WithErrorMessage("Please pick one option.")) | ||
| .WithVersion(Microsoft.Teams.Cards.Version.Version1_6) | ||
| .WithActions( | ||
| new ExecuteAction() | ||
| .WithTitle("Submit") | ||
| .WithVerb("clarification") | ||
| .WithAssociatedInputs(AssociatedInputs.Auto)); | ||
|
|
||
| return JsonSerializer.SerializeToElement(card, SerializerOptions); | ||
| } | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.