Skip to content

Commit 37f03d5

Browse files
MCP Server
1 parent 8a0e921 commit 37f03d5

16 files changed

Lines changed: 994 additions & 202 deletions

Directory.Packages.props

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,8 +50,8 @@
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" />
5555
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.3" />
5656
<PackageVersion Include="Moq" Version="4.20.72" />
5757
<PackageVersion Include="Moq.AutoMock" Version="4.0.2" />

EssentialCSharp.Web.Tests/McpTests.cs

Lines changed: 122 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,62 +1,80 @@
11
using System.Net;
22
using System.Net.Http.Headers;
33
using System.Text;
4+
using EssentialCSharp.Web.Areas.Identity.Data;
5+
using EssentialCSharp.Web.Data;
46
using EssentialCSharp.Web.Services;
57
using Microsoft.AspNetCore.Mvc.Testing;
68
using Microsoft.Extensions.DependencyInjection;
7-
using System.Threading.Tasks;
89

910
namespace EssentialCSharp.Web.Tests;
1011

11-
public class McpTests
12+
[NotInParallel("McpTests")]
13+
[ClassDataSource<WebApplicationFactory>(Shared = SharedType.PerClass)]
14+
public class McpTests(WebApplicationFactory factory)
1215
{
13-
[Fact]
16+
[Test]
1417
public async Task McpTokenEndpoint_WithoutAuth_Returns401()
1518
{
16-
using WebApplicationFactory factory = new();
1719
HttpClient client = factory.CreateClient(new WebApplicationFactoryClientOptions
1820
{
1921
AllowAutoRedirect = false
2022
});
2123

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

24-
// [ApiController] returns 401 directly; it does not redirect to login like Razor Pages
25-
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
26+
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Unauthorized);
2627
}
2728

28-
[Fact]
29+
[Test]
2930
public async Task McpEndpoint_WithoutToken_Returns401()
3031
{
31-
using WebApplicationFactory factory = new();
3232
HttpClient client = factory.CreateClient();
3333

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

37-
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
37+
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Unauthorized);
3838
}
3939

40-
[Fact]
40+
[Test]
4141
public async Task McpEndpoint_WithValidToken_Returns200AndListsTools()
4242
{
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");
43+
// Seed a minimal user row to satisfy the FK on McpApiToken.UserId, then
44+
// create an opaque token via McpApiTokenService (replaces old JWT path).
45+
string testUserId = Guid.NewGuid().ToString();
46+
string rawToken;
47+
using (var scope = factory.Services.CreateScope())
48+
{
49+
var db = scope.ServiceProvider.GetRequiredService<EssentialCSharpWebContext>();
50+
db.Users.Add(new EssentialCSharpWebUser
51+
{
52+
Id = testUserId,
53+
UserName = "mcp-testuser",
54+
NormalizedUserName = "MCP-TESTUSER",
55+
Email = "mcp-test@example.com",
56+
NormalizedEmail = "MCP-TEST@EXAMPLE.COM",
57+
SecurityStamp = Guid.NewGuid().ToString(),
58+
});
59+
await db.SaveChangesAsync();
60+
61+
var tokenService = scope.ServiceProvider.GetRequiredService<McpApiTokenService>();
62+
(rawToken, _) = await tokenService.CreateTokenAsync(testUserId, "integration-test");
63+
}
4964

5065
HttpClient client = factory.CreateClient();
5166

5267
// Step 1: Initialize the MCP session
5368
var initRequest = CreateMcpInitializeRequest("/mcp");
54-
initRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
69+
initRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", rawToken);
5570

5671
using HttpResponseMessage initResponse = await client.SendAsync(initRequest);
57-
Assert.Equal(HttpStatusCode.OK, initResponse.StatusCode);
72+
await Assert.That(initResponse.StatusCode).IsEqualTo(HttpStatusCode.OK);
5873

59-
string sessionId = initResponse.Headers.GetValues("Mcp-Session-Id").First();
74+
// Session ID is optional in stateless transport mode
75+
string? sessionId = null;
76+
if (initResponse.Headers.TryGetValues("Mcp-Session-Id", out IEnumerable<string>? sessionIdValues))
77+
sessionId = sessionIdValues.First();
6078

6179
// Step 2: List tools
6280
var listToolsRequest = new HttpRequestMessage(HttpMethod.Post, "/mcp")
@@ -65,31 +83,106 @@ public async Task McpEndpoint_WithValidToken_Returns200AndListsTools()
6583
"""{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}""",
6684
Encoding.UTF8, "application/json")
6785
};
68-
listToolsRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
86+
listToolsRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", rawToken);
6987
listToolsRequest.Headers.Accept.ParseAdd("application/json");
7088
listToolsRequest.Headers.Accept.ParseAdd("text/event-stream");
71-
listToolsRequest.Headers.Add("Mcp-Session-Id", sessionId);
89+
if (sessionId is not null)
90+
listToolsRequest.Headers.Add("Mcp-Session-Id", sessionId);
7291

7392
using HttpResponseMessage toolsResponse = await client.SendAsync(
7493
listToolsRequest, HttpCompletionOption.ResponseHeadersRead);
75-
Assert.Equal(HttpStatusCode.OK, toolsResponse.StatusCode);
94+
await Assert.That(toolsResponse.StatusCode).IsEqualTo(HttpStatusCode.OK);
7695

77-
// SSE streams arrive line-by-line; read until we find the data line or timeout
96+
// Streamable HTTP response: read until we find the tool names or timeout
7897
using Stream stream = await toolsResponse.Content.ReadAsStreamAsync();
7998
using StreamReader reader = new(stream);
8099
using CancellationTokenSource cts = new(TimeSpan.FromSeconds(10));
81-
string body = "";
100+
var body = new StringBuilder();
82101
string? line;
83102
while ((line = await reader.ReadLineAsync(cts.Token)) is not null)
84103
{
85-
body += line + "\n";
86-
if (body.Contains("search_book_content") && body.Contains("get_chapter_list"))
104+
body.AppendLine(line);
105+
if (body.ToString().Contains("search_book_content") &&
106+
body.ToString().Contains("get_chapter_list"))
87107
break;
88108
}
89109

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);
110+
string bodyText = body.ToString();
111+
await Assert.That(bodyText).Contains("search_book_content");
112+
await Assert.That(bodyText).Contains("get_chapter_list");
113+
}
114+
115+
[Test]
116+
public async Task McpEndpoint_WithInvalidToken_Returns401()
117+
{
118+
HttpClient client = factory.CreateClient();
119+
var request = CreateMcpInitializeRequest("/mcp");
120+
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "mcp_invalid_token_that_does_not_exist");
121+
using HttpResponseMessage response = await client.SendAsync(request);
122+
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Unauthorized);
123+
}
124+
125+
[Test]
126+
public async Task McpEndpoint_WithRevokedToken_Returns401()
127+
{
128+
string testUserId = Guid.NewGuid().ToString();
129+
string rawToken;
130+
using (var scope = factory.Services.CreateScope())
131+
{
132+
var db = scope.ServiceProvider.GetRequiredService<EssentialCSharpWebContext>();
133+
db.Users.Add(new EssentialCSharpWebUser
134+
{
135+
Id = testUserId,
136+
UserName = $"revoked-user-{testUserId[..8]}",
137+
NormalizedUserName = $"REVOKED-USER-{testUserId[..8].ToUpperInvariant()}",
138+
Email = $"revoked-{testUserId[..8]}@example.com",
139+
NormalizedEmail = $"REVOKED-{testUserId[..8].ToUpperInvariant()}@EXAMPLE.COM",
140+
SecurityStamp = Guid.NewGuid().ToString(),
141+
});
142+
await db.SaveChangesAsync();
143+
144+
var tokenService = scope.ServiceProvider.GetRequiredService<McpApiTokenService>();
145+
(rawToken, var entity) = await tokenService.CreateTokenAsync(testUserId, "revoke-test");
146+
await tokenService.RevokeTokenAsync(entity.Id, testUserId);
147+
}
148+
149+
HttpClient client = factory.CreateClient();
150+
var request = CreateMcpInitializeRequest("/mcp");
151+
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", rawToken);
152+
using HttpResponseMessage response = await client.SendAsync(request);
153+
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Unauthorized);
154+
}
155+
156+
[Test]
157+
public async Task McpEndpoint_WithExpiredToken_Returns401()
158+
{
159+
string testUserId = Guid.NewGuid().ToString();
160+
string rawToken;
161+
using (var scope = factory.Services.CreateScope())
162+
{
163+
var db = scope.ServiceProvider.GetRequiredService<EssentialCSharpWebContext>();
164+
db.Users.Add(new EssentialCSharpWebUser
165+
{
166+
Id = testUserId,
167+
UserName = $"expired-user-{testUserId[..8]}",
168+
NormalizedUserName = $"EXPIRED-USER-{testUserId[..8].ToUpperInvariant()}",
169+
Email = $"expired-{testUserId[..8]}@example.com",
170+
NormalizedEmail = $"EXPIRED-{testUserId[..8].ToUpperInvariant()}@EXAMPLE.COM",
171+
SecurityStamp = Guid.NewGuid().ToString(),
172+
});
173+
await db.SaveChangesAsync();
174+
175+
var tokenService = scope.ServiceProvider.GetRequiredService<McpApiTokenService>();
176+
// Create with an expiry in the past (1 second ago)
177+
DateTime pastExpiry = DateTime.UtcNow.AddSeconds(-1);
178+
(rawToken, _) = await tokenService.CreateTokenAsync(testUserId, "expired-test", pastExpiry);
179+
}
180+
181+
HttpClient client = factory.CreateClient();
182+
var request = CreateMcpInitializeRequest("/mcp");
183+
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", rawToken);
184+
using HttpResponseMessage response = await client.SendAsync(request);
185+
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Unauthorized);
93186
}
94187

95188
private static HttpRequestMessage CreateMcpInitializeRequest(string path)

EssentialCSharp.Web.Tests/WebApplicationFactory.cs

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -28,11 +28,6 @@ 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-
3631
builder.ConfigureServices(services =>
3732
{
3833
ServiceDescriptor? dbContextDescriptor = services.SingleOrDefault(

EssentialCSharp.Web/Areas/Identity/Data/EssentialCSharpWebContext.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using EssentialCSharp.Web.Areas.Identity.Data;
2+
using EssentialCSharp.Web.Models;
23
using Microsoft.AspNetCore.DataProtection.EntityFrameworkCore;
34
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
45
using Microsoft.EntityFrameworkCore;
@@ -9,6 +10,7 @@ public class EssentialCSharpWebContext(DbContextOptions<EssentialCSharpWebContex
910
: IdentityDbContext<EssentialCSharpWebUser>(options), IDataProtectionKeyContext
1011
{
1112
public DbSet<DataProtectionKey> DataProtectionKeys { get; set; } = null!;
13+
public DbSet<McpApiToken> McpApiTokens { get; set; } = null!;
1214

1315
protected override void OnModelCreating(ModelBuilder builder)
1416
{

0 commit comments

Comments
 (0)