Skip to content

Commit 87779d1

Browse files
feat: Add MCP server (#876)
Fixes #761 --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 24c204e commit 87779d1

49 files changed

Lines changed: 4519 additions & 60 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

Directory.Packages.props

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,8 +50,9 @@
5050
<PackageVersion Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="1.23.0" />
5151
<PackageVersion Include="Microsoft.VisualStudio.Web.CodeGeneration.Design" Version="10.0.2" />
5252
<PackageVersion Include="Microsoft.Extensions.AI.OpenAI" Version="10.0.1-preview.1.25571.5" />
53-
<PackageVersion Include="ModelContextProtocol" Version="0.3.0-preview.4" />
54-
<PackageVersion Include="ModelContextProtocol.AspNetCore" Version="0.3.0-preview.4" />
53+
<PackageVersion Include="ModelContextProtocol" Version="1.2.0" />
54+
<PackageVersion Include="ModelContextProtocol.AspNetCore" Version="1.2.0" />
55+
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.3" />
5556
<PackageVersion Include="Moq" Version="4.20.72" />
5657
<PackageVersion Include="Moq.AutoMock" Version="4.0.2" />
5758
<PackageVersion Include="System.CommandLine" Version="2.0.7" />

EssentialCSharp.Chat.Shared/Services/AIChatService.cs

Lines changed: 72 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ public AIChatService(IOptions<AIOptions> options, AISearchService searchService,
4646
string prompt,
4747
string? systemPrompt = null,
4848
string? previousResponseId = null,
49-
IMcpClient? mcpClient = null,
49+
McpClient? mcpClient = null,
5050
#pragma warning disable OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
5151
IEnumerable<ResponseTool>? tools = null,
5252
ResponseReasoningEffortLevel? reasoningEffortLevel = null,
@@ -56,7 +56,7 @@ public AIChatService(IOptions<AIOptions> options, AISearchService searchService,
5656
{
5757
var responseOptions = await CreateResponseOptionsAsync(previousResponseId, tools, reasoningEffortLevel, mcpClient: mcpClient, cancellationToken: cancellationToken);
5858
var enrichedPrompt = await EnrichPromptWithContext(prompt, enableContextualSearch, cancellationToken);
59-
return await GetChatCompletionCore(enrichedPrompt, responseOptions, systemPrompt, cancellationToken);
59+
return await GetChatCompletionCore(enrichedPrompt, responseOptions, systemPrompt, mcpClient, cancellationToken);
6060
}
6161

6262
/// <summary>
@@ -74,7 +74,7 @@ public AIChatService(IOptions<AIOptions> options, AISearchService searchService,
7474
string prompt,
7575
string? systemPrompt = null,
7676
string? previousResponseId = null,
77-
IMcpClient? mcpClient = null,
77+
McpClient? mcpClient = null,
7878
#pragma warning disable OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
7979
IEnumerable<ResponseTool>? tools = null,
8080
ResponseReasoningEffortLevel? reasoningEffortLevel = null,
@@ -99,7 +99,7 @@ public AIChatService(IOptions<AIOptions> options, AISearchService searchService,
9999
options: responseOptions,
100100
cancellationToken: cancellationToken);
101101

102-
await foreach (var result in ProcessStreamingUpdatesAsync(streamingUpdates, responseOptions, mcpClient, cancellationToken))
102+
await foreach (var result in ProcessStreamingUpdatesAsync(streamingUpdates, responseOptions, mcpClient, toolCallDepth: 0, cancellationToken))
103103
{
104104
yield return result;
105105
}
@@ -143,7 +143,8 @@ private async Task<string> EnrichPromptWithContext(string prompt, bool enableCon
143143
IAsyncEnumerable<StreamingResponseUpdate> streamingUpdates,
144144
ResponseCreationOptions responseOptions,
145145
#pragma warning restore OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
146-
IMcpClient? mcpClient,
146+
McpClient? mcpClient,
147+
int toolCallDepth = 0,
147148
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default)
148149
{
149150
await foreach (var update in streamingUpdates.WithCancellation(cancellationToken))
@@ -160,8 +161,11 @@ private async Task<string> EnrichPromptWithContext(string prompt, bool enableCon
160161
// Check if this is a function call that needs to be executed
161162
if (itemDone.Item is FunctionCallResponseItem functionCallItem && mcpClient != null)
162163
{
164+
if (toolCallDepth >= 10)
165+
throw new InvalidOperationException("Maximum tool call depth exceeded.");
166+
163167
// Execute the function call and stream its response
164-
await foreach (var functionResult in ExecuteFunctionCallAsync(functionCallItem, responseOptions, mcpClient, cancellationToken))
168+
await foreach (var functionResult in ExecuteFunctionCallAsync(functionCallItem, responseOptions, mcpClient, toolCallDepth + 1, cancellationToken))
165169
{
166170
if (functionResult.responseId != null)
167171
{
@@ -191,7 +195,8 @@ private async Task<string> EnrichPromptWithContext(string prompt, bool enableCon
191195
FunctionCallResponseItem functionCallItem,
192196
ResponseCreationOptions responseOptions,
193197
#pragma warning restore OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
194-
IMcpClient mcpClient,
198+
McpClient mcpClient,
199+
int toolCallDepth,
195200
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default)
196201
{
197202
// A dictionary of arguments to pass to the tool. Each key represents a parameter name, and its associated value represents the argument value.
@@ -234,7 +239,7 @@ private async Task<string> EnrichPromptWithContext(string prompt, bool enableCon
234239
var inputItems = new List<ResponseItem>
235240
{
236241
functionCallItem, // The original function call
237-
new FunctionCallOutputResponseItem(functionCallItem.CallId, string.Join("", toolResult.Content.Where(x => x.Type == "text").OfType<TextContentBlock>().Select(x => x.Text)))
242+
new FunctionCallOutputResponseItem(functionCallItem.CallId, McpToolResultFormatter.GetModelInput(toolResult))
238243
};
239244
#pragma warning restore OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
240245

@@ -244,7 +249,7 @@ private async Task<string> EnrichPromptWithContext(string prompt, bool enableCon
244249
responseOptions,
245250
cancellationToken);
246251

247-
await foreach (var result in ProcessStreamingUpdatesAsync(functionResponseStream, responseOptions, mcpClient, cancellationToken))
252+
await foreach (var result in ProcessStreamingUpdatesAsync(functionResponseStream, responseOptions, mcpClient, toolCallDepth, cancellationToken))
248253
{
249254
yield return result;
250255
}
@@ -258,7 +263,7 @@ private static async Task<ResponseCreationOptions> CreateResponseOptionsAsync(
258263
string? previousResponseId = null,
259264
IEnumerable<ResponseTool>? tools = null,
260265
ResponseReasoningEffortLevel? reasoningEffortLevel = null,
261-
IMcpClient? mcpClient = null,
266+
McpClient? mcpClient = null,
262267
CancellationToken cancellationToken = default
263268
)
264269
{
@@ -282,7 +287,8 @@ private static async Task<ResponseCreationOptions> CreateResponseOptionsAsync(
282287

283288
if (mcpClient is not null)
284289
{
285-
await foreach (McpClientTool tool in mcpClient.EnumerateToolsAsync(cancellationToken: cancellationToken))
290+
var mcpTools = await mcpClient.ListToolsAsync(cancellationToken: cancellationToken);
291+
foreach (McpClientTool tool in mcpTools)
286292
{
287293
#pragma warning disable OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
288294
options.Tools.Add(ResponseTool.CreateFunctionTool(tool.Name, functionDescription: tool.Description, strictModeEnabled: true, functionParameters: BinaryData.FromString(tool.JsonSchema.GetRawText())));
@@ -313,41 +319,78 @@ private static async Task<ResponseCreationOptions> CreateResponseOptionsAsync(
313319
ResponseCreationOptions responseOptions,
314320
#pragma warning restore OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
315321
string? systemPrompt = null,
322+
McpClient? mcpClient = null,
316323
CancellationToken cancellationToken = default)
317324
{
318325
// Construct the user input with system context if provided
319326
var systemContext = !string.IsNullOrWhiteSpace(systemPrompt) ? systemPrompt : _Options.SystemPrompt;
320327

321-
// Create the streaming response using the Responses API
322328
#pragma warning disable OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
323329
List<ResponseItem> responseItems = systemContext is not null
324330
? [ResponseItem.CreateSystemMessageItem(systemContext), ResponseItem.CreateUserMessageItem(prompt)]
325331
: [ResponseItem.CreateUserMessageItem(prompt)];
326332
#pragma warning restore OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
327333

328-
// Create the response using the Responses API
329-
var response = await _ResponseClient.CreateResponseAsync(
330-
responseItems,
331-
options: responseOptions,
332-
cancellationToken: cancellationToken);
334+
const int MaxToolCallIterations = 10;
335+
for (int iteration = 0; iteration < MaxToolCallIterations; iteration++)
336+
{
337+
var response = await _ResponseClient.CreateResponseAsync(
338+
responseItems,
339+
options: responseOptions,
340+
cancellationToken: cancellationToken);
333341

334-
// Extract the message content and response ID
335-
string responseText = string.Empty;
336-
string responseId = response.Value.Id;
342+
string responseId = response.Value.Id;
337343

338344
#pragma warning disable OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
339-
var assistantMessage = response.Value.OutputItems
340-
.OfType<MessageResponseItem>()
341-
.FirstOrDefault(m => m.Role == MessageRole.Assistant &&
342-
!string.IsNullOrEmpty(m.Content?.FirstOrDefault()?.Text));
345+
var functionCalls = response.Value.OutputItems.OfType<FunctionCallResponseItem>().ToList();
343346

344-
if (assistantMessage is not null)
345-
{
346-
responseText = assistantMessage.Content?.FirstOrDefault()?.Text ?? string.Empty;
347-
}
347+
if (functionCalls.Count > 0 && mcpClient != null)
348+
{
349+
foreach (var functionCallItem in functionCalls)
350+
{
351+
var jsonResponse = functionCallItem.FunctionArguments.ToString();
352+
var jsonArguments = System.Text.Json.JsonSerializer.Deserialize<Dictionary<string, object?>>(jsonResponse) ?? new Dictionary<string, object?>();
353+
354+
Dictionary<string, object?> arguments = [];
355+
foreach (var kvp in jsonArguments)
356+
{
357+
arguments[kvp.Key] = kvp.Value is System.Text.Json.JsonElement jsonElement
358+
? jsonElement.ValueKind switch
359+
{
360+
System.Text.Json.JsonValueKind.String => jsonElement.GetString(),
361+
System.Text.Json.JsonValueKind.Number => jsonElement.GetDecimal(),
362+
System.Text.Json.JsonValueKind.True => true,
363+
System.Text.Json.JsonValueKind.False => false,
364+
System.Text.Json.JsonValueKind.Null => null,
365+
_ => (object?)jsonElement.ToString()
366+
}
367+
: kvp.Value;
368+
}
369+
370+
var toolResult = await mcpClient.CallToolAsync(
371+
functionCallItem.FunctionName,
372+
arguments: arguments,
373+
cancellationToken: cancellationToken);
374+
375+
responseItems.Add(functionCallItem);
376+
responseItems.Add(new FunctionCallOutputResponseItem(
377+
functionCallItem.CallId,
378+
McpToolResultFormatter.GetModelInput(toolResult)));
379+
}
380+
continue;
381+
}
382+
383+
var assistantMessage = response.Value.OutputItems
384+
.OfType<MessageResponseItem>()
385+
.FirstOrDefault(m => m.Role == MessageRole.Assistant &&
386+
!string.IsNullOrEmpty(m.Content?.FirstOrDefault()?.Text));
387+
388+
string responseText = assistantMessage?.Content?.FirstOrDefault()?.Text ?? string.Empty;
348389
#pragma warning restore OPENAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
390+
return (responseText, responseId);
391+
}
349392

350-
return (responseText, responseId);
393+
throw new InvalidOperationException("Maximum tool call iterations exceeded.");
351394
}
352395

353396
// TODO: Look into using UserSecurityContext (https://learn.microsoft.com/en-us/azure/defender-for-cloud/gain-end-user-context-ai)

EssentialCSharp.Chat.Shared/Services/AISearchService.cs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,13 @@ public class AISearchService(
1313
{
1414
// TODO: Implement Hybrid Search functionality, may need to switch db providers to support full text search?
1515

16+
public const int DefaultSearchTop = 5;
17+
public const int MaxSearchTop = 10;
18+
1619
public async Task<IReadOnlyList<VectorSearchResult<BookContentChunk>>> ExecuteVectorSearch(
17-
string query, string? collectionName = null, CancellationToken cancellationToken = default)
20+
string query, string? collectionName = null, int top = DefaultSearchTop, CancellationToken cancellationToken = default)
1821
{
22+
top = Math.Clamp(top, 1, MaxSearchTop);
1923
collectionName ??= EmbeddingService.CollectionName;
2024

2125
VectorStoreCollection<string, BookContentChunk> collection = vectorStore.GetCollection<string, BookContentChunk>(collectionName);
@@ -32,7 +36,7 @@ public async Task<IReadOnlyList<VectorSearchResult<BookContentChunk>>> ExecuteVe
3236
try
3337
{
3438
var results = new List<VectorSearchResult<BookContentChunk>>();
35-
await foreach (var result in collection.SearchAsync(searchVector, options: vectorSearchOptions, top: 3, cancellationToken: cancellationToken))
39+
await foreach (var result in collection.SearchAsync(searchVector, options: vectorSearchOptions, top: top, cancellationToken: cancellationToken))
3640
{
3741
results.Add(result);
3842
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
using ModelContextProtocol.Protocol;
2+
3+
namespace EssentialCSharp.Chat.Common.Services;
4+
5+
public static class McpToolResultFormatter
6+
{
7+
public static string GetModelInput(CallToolResult toolResult)
8+
{
9+
if (toolResult.StructuredContent is { } structuredContent)
10+
{
11+
return structuredContent.GetRawText();
12+
}
13+
14+
return GetPrimaryTextContent(toolResult.Content);
15+
}
16+
17+
public static string GetPrimaryTextContent(IEnumerable<ContentBlock> contentBlocks) =>
18+
contentBlocks
19+
.Where(x => x.Type == "text")
20+
.OfType<TextContentBlock>()
21+
.Select(x => x.Text)
22+
.FirstOrDefault(text => !string.IsNullOrWhiteSpace(text))
23+
?? string.Empty;
24+
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
using System.Text.Json;
2+
using EssentialCSharp.Chat.Common.Services;
3+
using ModelContextProtocol.Protocol;
4+
5+
namespace EssentialCSharp.Chat.Tests;
6+
7+
public class McpToolResultFormatterTests
8+
{
9+
[Test]
10+
public async Task GetModelInput_PrefersStructuredContent_WhenAvailable()
11+
{
12+
JsonElement structuredContent = JsonSerializer.SerializeToElement(new
13+
{
14+
diagnostic = "CS8600",
15+
relevantSections = Array.Empty<object>()
16+
});
17+
18+
CallToolResult toolResult = new()
19+
{
20+
Content =
21+
[
22+
new TextContentBlock { Text = "# Book Help for: CS8600" },
23+
new TextContentBlock { Text = "{\"diagnostic\":\"CS8600\"}" }
24+
],
25+
StructuredContent = structuredContent
26+
};
27+
28+
string modelInput = McpToolResultFormatter.GetModelInput(toolResult);
29+
30+
await Assert.That(modelInput).IsEqualTo(structuredContent.GetRawText());
31+
}
32+
33+
[Test]
34+
public async Task GetModelInput_FallsBackToFirstTextBlock_WhenStructuredContentIsMissing()
35+
{
36+
CallToolResult toolResult = new()
37+
{
38+
Content =
39+
[
40+
new TextContentBlock { Text = "# Readable content" },
41+
new TextContentBlock { Text = "{\"json\":\"fallback\"}" }
42+
]
43+
};
44+
45+
string modelInput = McpToolResultFormatter.GetModelInput(toolResult);
46+
47+
await Assert.That(modelInput).IsEqualTo("# Readable content");
48+
}
49+
}

0 commit comments

Comments
 (0)