Skip to content

Commit 6c7f353

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 59e02b7 commit 6c7f353

16 files changed

Lines changed: 594 additions & 33 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: 76 additions & 28 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.
@@ -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,83 @@ 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+
if (kvp.Value is System.Text.Json.JsonElement jsonElement)
358+
{
359+
arguments[kvp.Key] = jsonElement.ValueKind switch
360+
{
361+
System.Text.Json.JsonValueKind.String => jsonElement.GetString(),
362+
System.Text.Json.JsonValueKind.Number => jsonElement.GetDecimal(),
363+
System.Text.Json.JsonValueKind.True => true,
364+
System.Text.Json.JsonValueKind.False => false,
365+
System.Text.Json.JsonValueKind.Null => null,
366+
_ => jsonElement.ToString()
367+
};
368+
}
369+
else
370+
{
371+
arguments[kvp.Key] = kvp.Value;
372+
}
373+
}
374+
375+
var toolResult = await mcpClient.CallToolAsync(
376+
functionCallItem.FunctionName,
377+
arguments: arguments,
378+
cancellationToken: cancellationToken);
379+
380+
responseItems.Add(functionCallItem);
381+
responseItems.Add(new FunctionCallOutputResponseItem(
382+
functionCallItem.CallId,
383+
string.Join("", toolResult.Content.Where(x => x.Type == "text").OfType<TextContentBlock>().Select(x => x.Text))));
384+
}
385+
continue;
386+
}
387+
388+
var assistantMessage = response.Value.OutputItems
389+
.OfType<MessageResponseItem>()
390+
.FirstOrDefault(m => m.Role == MessageRole.Assistant &&
391+
!string.IsNullOrEmpty(m.Content?.FirstOrDefault()?.Text));
392+
393+
string responseText = assistantMessage?.Content?.FirstOrDefault()?.Text ?? string.Empty;
348394
#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.
395+
return (responseText, responseId);
396+
}
349397

350-
return (responseText, responseId);
398+
throw new InvalidOperationException("Maximum tool call iterations exceeded.");
351399
}
352400

353401
// 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)