Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,9 @@
<PackageVersion Include="Microsoft.SourceLink.GitHub" Version="10.0.103" />
<PackageVersion Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="1.23.0" />
<PackageVersion Include="Microsoft.VisualStudio.Web.CodeGeneration.Design" Version="10.0.2" />
<PackageVersion Include="ModelContextProtocol" Version="0.3.0-preview.4" />
<PackageVersion Include="ModelContextProtocol.AspNetCore" Version="0.3.0-preview.4" />
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.3" />
<PackageVersion Include="ModelContextProtocol" Version="0.9.0-preview.2" />
<PackageVersion Include="ModelContextProtocol.AspNetCore" Version="0.9.0-preview.2" />
<PackageVersion Include="Moq" Version="4.20.72" />
<PackageVersion Include="Moq.AutoMock" Version="3.6.1" />
<PackageVersion Include="System.CommandLine" Version="2.0.3" />
Expand All @@ -52,5 +53,6 @@
<PackageVersion Include="DotnetSitemapGenerator" Version="2.0.0" />
<PackageVersion Include="xunit" Version="2.9.3" />
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.5" />

</ItemGroup>
</Project>
108 changes: 77 additions & 31 deletions EssentialCSharp.Chat.Shared/Services/AIChatService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ public AIChatService(IOptions<AIOptions> options, AISearchService searchService,
string prompt,
string? systemPrompt = null,
string? previousResponseId = null,
IMcpClient? mcpClient = null,
McpClient? mcpClient = null,
#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.
IEnumerable<ResponseTool>? tools = null,
ResponseReasoningEffortLevel? reasoningEffortLevel = null,
Expand All @@ -56,7 +56,7 @@ public AIChatService(IOptions<AIOptions> options, AISearchService searchService,
{
var responseOptions = await CreateResponseOptionsAsync(previousResponseId, tools, reasoningEffortLevel, mcpClient: mcpClient, cancellationToken: cancellationToken);
var enrichedPrompt = await EnrichPromptWithContext(prompt, enableContextualSearch, cancellationToken);
return await GetChatCompletionCore(enrichedPrompt, responseOptions, systemPrompt, cancellationToken);
return await GetChatCompletionCore(enrichedPrompt, responseOptions, systemPrompt, mcpClient, cancellationToken);
}

/// <summary>
Expand All @@ -74,7 +74,7 @@ public AIChatService(IOptions<AIOptions> options, AISearchService searchService,
string prompt,
string? systemPrompt = null,
string? previousResponseId = null,
IMcpClient? mcpClient = null,
McpClient? mcpClient = null,
#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.
IEnumerable<ResponseTool>? tools = null,
ResponseReasoningEffortLevel? reasoningEffortLevel = null,
Expand Down Expand Up @@ -102,7 +102,7 @@ public AIChatService(IOptions<AIOptions> options, AISearchService searchService,
options: responseOptions,
cancellationToken: cancellationToken);

await foreach (var result in ProcessStreamingUpdatesAsync(streamingUpdates, responseOptions, mcpClient, cancellationToken))
await foreach (var result in ProcessStreamingUpdatesAsync(streamingUpdates, responseOptions, mcpClient, toolCallDepth: 0, cancellationToken))
{
yield return result;
}
Expand Down Expand Up @@ -146,7 +146,8 @@ private async Task<string> EnrichPromptWithContext(string prompt, bool enableCon
IAsyncEnumerable<StreamingResponseUpdate> streamingUpdates,
ResponseCreationOptions responseOptions,
#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.
IMcpClient? mcpClient,
McpClient? mcpClient,
int toolCallDepth = 0,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default)
{
await foreach (var update in streamingUpdates.WithCancellation(cancellationToken))
Expand All @@ -163,8 +164,11 @@ private async Task<string> EnrichPromptWithContext(string prompt, bool enableCon
// Check if this is a function call that needs to be executed
if (itemDone.Item is FunctionCallResponseItem functionCallItem && mcpClient != null)
{
if (toolCallDepth >= 10)
throw new InvalidOperationException("Maximum tool call depth exceeded.");

// Execute the function call and stream its response
await foreach (var functionResult in ExecuteFunctionCallAsync(functionCallItem, responseOptions, mcpClient, cancellationToken))
await foreach (var functionResult in ExecuteFunctionCallAsync(functionCallItem, responseOptions, mcpClient, toolCallDepth + 1, cancellationToken))
{
if (functionResult.responseId != null)
{
Expand Down Expand Up @@ -194,7 +198,8 @@ private async Task<string> EnrichPromptWithContext(string prompt, bool enableCon
FunctionCallResponseItem functionCallItem,
ResponseCreationOptions responseOptions,
#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.
IMcpClient mcpClient,
McpClient mcpClient,
int toolCallDepth,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default)
{
// A dictionary of arguments to pass to the tool. Each key represents a parameter name, and its associated value represents the argument value.
Expand Down Expand Up @@ -247,7 +252,7 @@ private async Task<string> EnrichPromptWithContext(string prompt, bool enableCon
responseOptions,
cancellationToken);

await foreach (var result in ProcessStreamingUpdatesAsync(functionResponseStream, responseOptions, mcpClient, cancellationToken))
await foreach (var result in ProcessStreamingUpdatesAsync(functionResponseStream, responseOptions, mcpClient, toolCallDepth, cancellationToken))
{
yield return result;
}
Expand All @@ -261,7 +266,7 @@ private static async Task<ResponseCreationOptions> CreateResponseOptionsAsync(
string? previousResponseId = null,
IEnumerable<ResponseTool>? tools = null,
ResponseReasoningEffortLevel? reasoningEffortLevel = null,
IMcpClient? mcpClient = null,
McpClient? mcpClient = null,
CancellationToken cancellationToken = default
)
{
Expand All @@ -285,7 +290,8 @@ private static async Task<ResponseCreationOptions> CreateResponseOptionsAsync(

if (mcpClient is not null)
{
await foreach (McpClientTool tool in mcpClient.EnumerateToolsAsync(cancellationToken: cancellationToken))
var mcpTools = await mcpClient.ListToolsAsync(cancellationToken: cancellationToken);
foreach (McpClientTool tool in mcpTools)
{
#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.
options.Tools.Add(ResponseTool.CreateFunctionTool(tool.Name, functionDescription: tool.Description, strictModeEnabled: true, functionParameters: BinaryData.FromString(tool.JsonSchema.GetRawText())));
Expand Down Expand Up @@ -316,44 +322,84 @@ private static async Task<ResponseCreationOptions> CreateResponseOptionsAsync(
ResponseCreationOptions responseOptions,
#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.
string? systemPrompt = null,
McpClient? mcpClient = null,
CancellationToken cancellationToken = default)
{
// Construct the user input with system context if provided
var systemContext = systemPrompt ?? _Options.SystemPrompt;

// Create the streaming response using the Responses API
#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.
List<ResponseItem> responseItems = [ResponseItem.CreateUserMessageItem(prompt)];
if (systemContext is not null)
{
responseItems.Add(
ResponseItem.CreateSystemMessageItem(systemContext));
responseItems.Add(ResponseItem.CreateSystemMessageItem(systemContext));
}
#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.

// Create the response using the Responses API
var response = await _ResponseClient.CreateResponseAsync(
responseItems,
options: responseOptions,
cancellationToken: cancellationToken);
const int MaxToolCallIterations = 10;
for (int iteration = 0; iteration < MaxToolCallIterations; iteration++)
{
var response = await _ResponseClient.CreateResponseAsync(
responseItems,
options: responseOptions,
cancellationToken: cancellationToken);

// Extract the message content and response ID
string responseText = string.Empty;
string responseId = response.Value.Id;
string responseId = response.Value.Id;

#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.
var assistantMessage = response.Value.OutputItems
.OfType<MessageResponseItem>()
.FirstOrDefault(m => m.Role == MessageRole.Assistant &&
!string.IsNullOrEmpty(m.Content?.FirstOrDefault()?.Text));
var functionCalls = response.Value.OutputItems.OfType<FunctionCallResponseItem>().ToList();

if (assistantMessage is not null)
{
responseText = assistantMessage.Content?.FirstOrDefault()?.Text ?? string.Empty;
}
if (functionCalls.Count > 0 && mcpClient != null)
{
foreach (var functionCallItem in functionCalls)
{
var jsonResponse = functionCallItem.FunctionArguments.ToString();
var jsonArguments = System.Text.Json.JsonSerializer.Deserialize<Dictionary<string, object?>>(jsonResponse) ?? new Dictionary<string, object?>();

Dictionary<string, object?> arguments = [];
foreach (var kvp in jsonArguments)
{
if (kvp.Value is System.Text.Json.JsonElement jsonElement)
{
arguments[kvp.Key] = jsonElement.ValueKind switch
{
System.Text.Json.JsonValueKind.String => jsonElement.GetString(),
System.Text.Json.JsonValueKind.Number => jsonElement.GetDecimal(),
System.Text.Json.JsonValueKind.True => true,
System.Text.Json.JsonValueKind.False => false,
System.Text.Json.JsonValueKind.Null => null,
_ => jsonElement.ToString()
};
}
else
{
arguments[kvp.Key] = kvp.Value;
}
Comment on lines +361 to +376
}

var toolResult = await mcpClient.CallToolAsync(
functionCallItem.FunctionName,
arguments: arguments,
cancellationToken: cancellationToken);

responseItems.Add(functionCallItem);
responseItems.Add(new FunctionCallOutputResponseItem(
functionCallItem.CallId,
string.Join("", toolResult.Content.Where(x => x.Type == "text").OfType<TextContentBlock>().Select(x => x.Text))));
}
continue;
}

var assistantMessage = response.Value.OutputItems
.OfType<MessageResponseItem>()
.FirstOrDefault(m => m.Role == MessageRole.Assistant &&
!string.IsNullOrEmpty(m.Content?.FirstOrDefault()?.Text));

string responseText = assistantMessage?.Content?.FirstOrDefault()?.Text ?? string.Empty;
#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.
return (responseText, responseId);
}

return (responseText, responseId);
throw new InvalidOperationException("Maximum tool call iterations exceeded.");
}

// TODO: Look into using UserSecurityContext (https://learn.microsoft.com/en-us/azure/defender-for-cloud/gain-end-user-context-ai)
Expand Down
119 changes: 119 additions & 0 deletions EssentialCSharp.Web.Tests/McpTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
using System.Net;
using System.Net.Http.Headers;
using System.Text;
using EssentialCSharp.Web.Services;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.DependencyInjection;
using System.Threading.Tasks;

namespace EssentialCSharp.Web.Tests;

public class McpTests
{
[Fact]
public async Task McpTokenEndpoint_WithoutAuth_Returns401()
{
using WebApplicationFactory factory = new();
HttpClient client = factory.CreateClient(new WebApplicationFactoryClientOptions
{
AllowAutoRedirect = false
});

using HttpResponseMessage response = await client.PostAsync("/api/McpToken", null);

// [ApiController] returns 401 directly; it does not redirect to login like Razor Pages
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}

[Fact]
public async Task McpEndpoint_WithoutToken_Returns401()
{
using WebApplicationFactory factory = new();
HttpClient client = factory.CreateClient();

var request = CreateMcpInitializeRequest("/mcp");
using HttpResponseMessage response = await client.SendAsync(request);

Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}

[Fact]
public async Task McpEndpoint_WithValidToken_Returns200AndListsTools()
{
using WebApplicationFactory factory = new();

McpTokenService? tokenService = factory.Services.GetService<McpTokenService>();
Assert.NotNull(tokenService);

var (token, _) = tokenService.GenerateToken("test-user-id", "testuser", "test@example.com");

HttpClient client = factory.CreateClient();

// Step 1: Initialize the MCP session
var initRequest = CreateMcpInitializeRequest("/mcp");
initRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);

using HttpResponseMessage initResponse = await client.SendAsync(initRequest);
Assert.Equal(HttpStatusCode.OK, initResponse.StatusCode);

string sessionId = initResponse.Headers.GetValues("Mcp-Session-Id").First();

// Step 2: List tools
var listToolsRequest = new HttpRequestMessage(HttpMethod.Post, "/mcp")
{
Content = new StringContent(
"""{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}""",
Encoding.UTF8, "application/json")
};
Comment on lines +62 to +67
listToolsRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
listToolsRequest.Headers.Accept.ParseAdd("application/json");
listToolsRequest.Headers.Accept.ParseAdd("text/event-stream");
listToolsRequest.Headers.Add("Mcp-Session-Id", sessionId);

using HttpResponseMessage toolsResponse = await client.SendAsync(
listToolsRequest, HttpCompletionOption.ResponseHeadersRead);
Assert.Equal(HttpStatusCode.OK, toolsResponse.StatusCode);

// SSE streams arrive line-by-line; read until we find the data line or timeout
using Stream stream = await toolsResponse.Content.ReadAsStreamAsync();
using StreamReader reader = new(stream);
using CancellationTokenSource cts = new(TimeSpan.FromSeconds(10));
string body = "";
string? line;
while ((line = await reader.ReadLineAsync(cts.Token)) is not null)
{
body += line + "\n";
if (body.Contains("search_book_content") && body.Contains("get_chapter_list"))
break;
}

// The MCP C# SDK converts PascalCase method names to snake_case for the wire protocol
Assert.Contains("search_book_content", body);
Assert.Contains("get_chapter_list", body);
}

private static HttpRequestMessage CreateMcpInitializeRequest(string path)
{
var request = new HttpRequestMessage(HttpMethod.Post, path)
{
Content = new StringContent(
"""
{
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2024-11-05",
"capabilities": {},
"clientInfo": { "name": "test-client", "version": "1.0" }
}
}
""",
Encoding.UTF8, "application/json")
};
// MCP Streamable HTTP transport requires both content types in Accept
request.Headers.Accept.ParseAdd("application/json");
request.Headers.Accept.ParseAdd("text/event-stream");
return request;
}
}
5 changes: 5 additions & 0 deletions EssentialCSharp.Web.Tests/WebApplicationFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@ public sealed class WebApplicationFactory : WebApplicationFactory<Program>

protected override void ConfigureWebHost(IWebHostBuilder builder)
{
// Inject a stable test signing key so MCP services are registered during
// service registration in Program.cs (which reads configuration["Mcp:SigningKey"]
// before builder.Build() is called — ConfigureAppConfiguration fires too late).
builder.UseSetting("Mcp:SigningKey", "TestOnly-EssentialCSharp-MCP-SigningKey-For-Integration-Tests!");

builder.ConfigureServices(services =>
{
ServiceDescriptor? dbContextDescriptor = services.SingleOrDefault(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ public static class ManageNavPages

public static string Referrals => "Referrals";

public static string McpAccess => "McpAccess";

public static string? IndexNavClass(ViewContext viewContext) => PageNavClass(viewContext, Index);

public static string? EmailNavClass(ViewContext viewContext) => PageNavClass(viewContext, Email);
Expand All @@ -44,6 +46,8 @@ public static class ManageNavPages

public static string? ReferralsNavClass(ViewContext viewContext) => PageNavClass(viewContext, Referrals);

public static string? McpAccessNavClass(ViewContext viewContext) => PageNavClass(viewContext, McpAccess);

public static string? PageNavClass(ViewContext viewContext, string page)
{
string? activePage = viewContext.ViewData["ActivePage"] as string
Expand Down
Loading
Loading