Skip to content

Commit 8a0e921

Browse files
feat: Add MCP server with JWT authentication
- Add McpTokenService with JWT token generation/validation - Add McpTokenController for token management UI - Add MCP Access page to account management - Configure MCP server with BookSearchTool - Update ModelContextProtocol packages to 0.9.0-preview.2 - Add Microsoft.AspNetCore.Authentication.JwtBearer - Fix MCP client API: use concrete McpClient type - Add toolCallDepth guard to prevent infinite recursion Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 68b9f55 commit 8a0e921

16 files changed

Lines changed: 595 additions & 36 deletions

File tree

Directory.Packages.props

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@
5252
<PackageVersion Include="Microsoft.Extensions.AI.OpenAI" Version="10.0.1-preview.1.25571.5" />
5353
<PackageVersion Include="ModelContextProtocol" Version="0.3.0-preview.4" />
5454
<PackageVersion Include="ModelContextProtocol.AspNetCore" Version="0.3.0-preview.4" />
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.6" />

EssentialCSharp.Chat.Shared/Services/AIChatService.cs

Lines changed: 77 additions & 31 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,
@@ -102,7 +102,7 @@ public AIChatService(IOptions<AIOptions> options, AISearchService searchService,
102102
options: responseOptions,
103103
cancellationToken: cancellationToken);
104104

105-
await foreach (var result in ProcessStreamingUpdatesAsync(streamingUpdates, responseOptions, mcpClient, cancellationToken))
105+
await foreach (var result in ProcessStreamingUpdatesAsync(streamingUpdates, responseOptions, mcpClient, toolCallDepth: 0, cancellationToken))
106106
{
107107
yield return result;
108108
}
@@ -146,7 +146,8 @@ private async Task<string> EnrichPromptWithContext(string prompt, bool enableCon
146146
IAsyncEnumerable<StreamingResponseUpdate> streamingUpdates,
147147
ResponseCreationOptions responseOptions,
148148
#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.
149-
IMcpClient? mcpClient,
149+
McpClient? mcpClient,
150+
int toolCallDepth = 0,
150151
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default)
151152
{
152153
await foreach (var update in streamingUpdates.WithCancellation(cancellationToken))
@@ -163,8 +164,11 @@ private async Task<string> EnrichPromptWithContext(string prompt, bool enableCon
163164
// Check if this is a function call that needs to be executed
164165
if (itemDone.Item is FunctionCallResponseItem functionCallItem && mcpClient != null)
165166
{
167+
if (toolCallDepth >= 10)
168+
throw new InvalidOperationException("Maximum tool call depth exceeded.");
169+
166170
// Execute the function call and stream its response
167-
await foreach (var functionResult in ExecuteFunctionCallAsync(functionCallItem, responseOptions, mcpClient, cancellationToken))
171+
await foreach (var functionResult in ExecuteFunctionCallAsync(functionCallItem, responseOptions, mcpClient, toolCallDepth + 1, cancellationToken))
168172
{
169173
if (functionResult.responseId != null)
170174
{
@@ -194,7 +198,8 @@ private async Task<string> EnrichPromptWithContext(string prompt, bool enableCon
194198
FunctionCallResponseItem functionCallItem,
195199
ResponseCreationOptions responseOptions,
196200
#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.
197-
IMcpClient mcpClient,
201+
McpClient mcpClient,
202+
int toolCallDepth,
198203
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default)
199204
{
200205
// A dictionary of arguments to pass to the tool. Each key represents a parameter name, and its associated value represents the argument value.
@@ -247,7 +252,7 @@ private async Task<string> EnrichPromptWithContext(string prompt, bool enableCon
247252
responseOptions,
248253
cancellationToken);
249254

250-
await foreach (var result in ProcessStreamingUpdatesAsync(functionResponseStream, responseOptions, mcpClient, cancellationToken))
255+
await foreach (var result in ProcessStreamingUpdatesAsync(functionResponseStream, responseOptions, mcpClient, toolCallDepth, cancellationToken))
251256
{
252257
yield return result;
253258
}
@@ -261,7 +266,7 @@ private static async Task<ResponseCreationOptions> CreateResponseOptionsAsync(
261266
string? previousResponseId = null,
262267
IEnumerable<ResponseTool>? tools = null,
263268
ResponseReasoningEffortLevel? reasoningEffortLevel = null,
264-
IMcpClient? mcpClient = null,
269+
McpClient? mcpClient = null,
265270
CancellationToken cancellationToken = default
266271
)
267272
{
@@ -285,7 +290,8 @@ private static async Task<ResponseCreationOptions> CreateResponseOptionsAsync(
285290

286291
if (mcpClient is not null)
287292
{
288-
await foreach (McpClientTool tool in mcpClient.EnumerateToolsAsync(cancellationToken: cancellationToken))
293+
var mcpTools = await mcpClient.ListToolsAsync(cancellationToken: cancellationToken);
294+
foreach (McpClientTool tool in mcpTools)
289295
{
290296
#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.
291297
options.Tools.Add(ResponseTool.CreateFunctionTool(tool.Name, functionDescription: tool.Description, strictModeEnabled: true, functionParameters: BinaryData.FromString(tool.JsonSchema.GetRawText())));
@@ -316,44 +322,84 @@ private static async Task<ResponseCreationOptions> CreateResponseOptionsAsync(
316322
ResponseCreationOptions responseOptions,
317323
#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.
318324
string? systemPrompt = null,
325+
McpClient? mcpClient = null,
319326
CancellationToken cancellationToken = default)
320327
{
321-
// Construct the user input with system context if provided
322328
var systemContext = systemPrompt ?? _Options.SystemPrompt;
323329

324-
// Create the streaming response using the Responses API
325330
#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.
326331
List<ResponseItem> responseItems = [ResponseItem.CreateUserMessageItem(prompt)];
327332
if (systemContext is not null)
328333
{
329-
responseItems.Add(
330-
ResponseItem.CreateSystemMessageItem(systemContext));
334+
responseItems.Add(ResponseItem.CreateSystemMessageItem(systemContext));
331335
}
332336
#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.
333337

334-
// Create the response using the Responses API
335-
var response = await _ResponseClient.CreateResponseAsync(
336-
responseItems,
337-
options: responseOptions,
338-
cancellationToken: cancellationToken);
338+
const int MaxToolCallIterations = 10;
339+
for (int iteration = 0; iteration < MaxToolCallIterations; iteration++)
340+
{
341+
var response = await _ResponseClient.CreateResponseAsync(
342+
responseItems,
343+
options: responseOptions,
344+
cancellationToken: cancellationToken);
339345

340-
// Extract the message content and response ID
341-
string responseText = string.Empty;
342-
string responseId = response.Value.Id;
346+
string responseId = response.Value.Id;
343347

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

350-
if (assistantMessage is not null)
351-
{
352-
responseText = assistantMessage.Content?.FirstOrDefault()?.Text ?? string.Empty;
353-
}
351+
if (functionCalls.Count > 0 && mcpClient != null)
352+
{
353+
foreach (var functionCallItem in functionCalls)
354+
{
355+
var jsonResponse = functionCallItem.FunctionArguments.ToString();
356+
var jsonArguments = System.Text.Json.JsonSerializer.Deserialize<Dictionary<string, object?>>(jsonResponse) ?? new Dictionary<string, object?>();
357+
358+
Dictionary<string, object?> arguments = [];
359+
foreach (var kvp in jsonArguments)
360+
{
361+
if (kvp.Value is System.Text.Json.JsonElement jsonElement)
362+
{
363+
arguments[kvp.Key] = jsonElement.ValueKind switch
364+
{
365+
System.Text.Json.JsonValueKind.String => jsonElement.GetString(),
366+
System.Text.Json.JsonValueKind.Number => jsonElement.GetDecimal(),
367+
System.Text.Json.JsonValueKind.True => true,
368+
System.Text.Json.JsonValueKind.False => false,
369+
System.Text.Json.JsonValueKind.Null => null,
370+
_ => jsonElement.ToString()
371+
};
372+
}
373+
else
374+
{
375+
arguments[kvp.Key] = kvp.Value;
376+
}
377+
}
378+
379+
var toolResult = await mcpClient.CallToolAsync(
380+
functionCallItem.FunctionName,
381+
arguments: arguments,
382+
cancellationToken: cancellationToken);
383+
384+
responseItems.Add(functionCallItem);
385+
responseItems.Add(new FunctionCallOutputResponseItem(
386+
functionCallItem.CallId,
387+
string.Join("", toolResult.Content.Where(x => x.Type == "text").OfType<TextContentBlock>().Select(x => x.Text))));
388+
}
389+
continue;
390+
}
391+
392+
var assistantMessage = response.Value.OutputItems
393+
.OfType<MessageResponseItem>()
394+
.FirstOrDefault(m => m.Role == MessageRole.Assistant &&
395+
!string.IsNullOrEmpty(m.Content?.FirstOrDefault()?.Text));
396+
397+
string responseText = assistantMessage?.Content?.FirstOrDefault()?.Text ?? string.Empty;
354398
#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.
399+
return (responseText, responseId);
400+
}
355401

356-
return (responseText, responseId);
402+
throw new InvalidOperationException("Maximum tool call iterations exceeded.");
357403
}
358404

359405
// TODO: Look into using UserSecurityContext (https://learn.microsoft.com/en-us/azure/defender-for-cloud/gain-end-user-context-ai)
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
using System.Net;
2+
using System.Net.Http.Headers;
3+
using System.Text;
4+
using EssentialCSharp.Web.Services;
5+
using Microsoft.AspNetCore.Mvc.Testing;
6+
using Microsoft.Extensions.DependencyInjection;
7+
using System.Threading.Tasks;
8+
9+
namespace EssentialCSharp.Web.Tests;
10+
11+
public class McpTests
12+
{
13+
[Fact]
14+
public async Task McpTokenEndpoint_WithoutAuth_Returns401()
15+
{
16+
using WebApplicationFactory factory = new();
17+
HttpClient client = factory.CreateClient(new WebApplicationFactoryClientOptions
18+
{
19+
AllowAutoRedirect = false
20+
});
21+
22+
using HttpResponseMessage response = await client.PostAsync("/api/McpToken", null);
23+
24+
// [ApiController] returns 401 directly; it does not redirect to login like Razor Pages
25+
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
26+
}
27+
28+
[Fact]
29+
public async Task McpEndpoint_WithoutToken_Returns401()
30+
{
31+
using WebApplicationFactory factory = new();
32+
HttpClient client = factory.CreateClient();
33+
34+
var request = CreateMcpInitializeRequest("/mcp");
35+
using HttpResponseMessage response = await client.SendAsync(request);
36+
37+
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
38+
}
39+
40+
[Fact]
41+
public async Task McpEndpoint_WithValidToken_Returns200AndListsTools()
42+
{
43+
using WebApplicationFactory factory = new();
44+
45+
McpTokenService? tokenService = factory.Services.GetService<McpTokenService>();
46+
Assert.NotNull(tokenService);
47+
48+
var (token, _) = tokenService.GenerateToken("test-user-id", "testuser", "test@example.com");
49+
50+
HttpClient client = factory.CreateClient();
51+
52+
// Step 1: Initialize the MCP session
53+
var initRequest = CreateMcpInitializeRequest("/mcp");
54+
initRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
55+
56+
using HttpResponseMessage initResponse = await client.SendAsync(initRequest);
57+
Assert.Equal(HttpStatusCode.OK, initResponse.StatusCode);
58+
59+
string sessionId = initResponse.Headers.GetValues("Mcp-Session-Id").First();
60+
61+
// Step 2: List tools
62+
var listToolsRequest = new HttpRequestMessage(HttpMethod.Post, "/mcp")
63+
{
64+
Content = new StringContent(
65+
"""{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}""",
66+
Encoding.UTF8, "application/json")
67+
};
68+
listToolsRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
69+
listToolsRequest.Headers.Accept.ParseAdd("application/json");
70+
listToolsRequest.Headers.Accept.ParseAdd("text/event-stream");
71+
listToolsRequest.Headers.Add("Mcp-Session-Id", sessionId);
72+
73+
using HttpResponseMessage toolsResponse = await client.SendAsync(
74+
listToolsRequest, HttpCompletionOption.ResponseHeadersRead);
75+
Assert.Equal(HttpStatusCode.OK, toolsResponse.StatusCode);
76+
77+
// SSE streams arrive line-by-line; read until we find the data line or timeout
78+
using Stream stream = await toolsResponse.Content.ReadAsStreamAsync();
79+
using StreamReader reader = new(stream);
80+
using CancellationTokenSource cts = new(TimeSpan.FromSeconds(10));
81+
string body = "";
82+
string? line;
83+
while ((line = await reader.ReadLineAsync(cts.Token)) is not null)
84+
{
85+
body += line + "\n";
86+
if (body.Contains("search_book_content") && body.Contains("get_chapter_list"))
87+
break;
88+
}
89+
90+
// The MCP C# SDK converts PascalCase method names to snake_case for the wire protocol
91+
Assert.Contains("search_book_content", body);
92+
Assert.Contains("get_chapter_list", body);
93+
}
94+
95+
private static HttpRequestMessage CreateMcpInitializeRequest(string path)
96+
{
97+
var request = new HttpRequestMessage(HttpMethod.Post, path)
98+
{
99+
Content = new StringContent(
100+
"""
101+
{
102+
"jsonrpc": "2.0",
103+
"id": 1,
104+
"method": "initialize",
105+
"params": {
106+
"protocolVersion": "2024-11-05",
107+
"capabilities": {},
108+
"clientInfo": { "name": "test-client", "version": "1.0" }
109+
}
110+
}
111+
""",
112+
Encoding.UTF8, "application/json")
113+
};
114+
// MCP Streamable HTTP transport requires both content types in Accept
115+
request.Headers.Accept.ParseAdd("application/json");
116+
request.Headers.Accept.ParseAdd("text/event-stream");
117+
return request;
118+
}
119+
}

EssentialCSharp.Web.Tests/WebApplicationFactory.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,11 @@ public Task InitializeAsync()
2828

2929
protected override void ConfigureWebHost(IWebHostBuilder builder)
3030
{
31+
// Inject a stable test signing key so MCP services are registered during
32+
// service registration in Program.cs (which reads configuration["Mcp:SigningKey"]
33+
// before builder.Build() is called — ConfigureAppConfiguration fires too late).
34+
builder.UseSetting("Mcp:SigningKey", "TestOnly-EssentialCSharp-MCP-SigningKey-For-Integration-Tests!");
35+
3136
builder.ConfigureServices(services =>
3237
{
3338
ServiceDescriptor? dbContextDescriptor = services.SingleOrDefault(

EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/ManageNavPages.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ public static class ManageNavPages
2626

2727
public static string Referrals => "Referrals";
2828

29+
public static string McpAccess => "McpAccess";
30+
2931
public static string? IndexNavClass(ViewContext viewContext) => PageNavClass(viewContext, Index);
3032

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

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

49+
public static string? McpAccessNavClass(ViewContext viewContext) => PageNavClass(viewContext, McpAccess);
50+
4751
public static string? PageNavClass(ViewContext viewContext, string page)
4852
{
4953
string? activePage = viewContext.ViewData["ActivePage"] as string

0 commit comments

Comments
 (0)