Skip to content

Commit 144582d

Browse files
committed
Refactor MCP server implementation and add unit tests for option parsing
1 parent 341f802 commit 144582d

6 files changed

Lines changed: 387 additions & 18 deletions

File tree

BlazorExpress.ChartJS.MCP.Tests/BlazorExpress.ChartJS.MCP.Tests.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
</PropertyGroup>
99

1010
<ItemGroup>
11+
<PackageReference Include="Microsoft.AspNetCore.TestHost" Version="10.0.0" />
1112
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
1213
<PackageReference Include="xunit" Version="2.9.2" />
1314
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
using Microsoft.AspNetCore.TestHost;
2+
3+
namespace BlazorExpress.ChartJS.MCP.Tests;
4+
5+
public class McpApplicationTests
6+
{
7+
[Fact]
8+
public void ParseOptions_NoArgs_Selects_Http()
9+
{
10+
var options = McpApplication.ParseOptions([]);
11+
12+
Assert.Equal(McpTransportMode.Http, options.Transport);
13+
Assert.Empty(options.RemainingArgs);
14+
}
15+
16+
[Theory]
17+
[InlineData("--stdio")]
18+
[InlineData("--transport", "stdio")]
19+
public void ParseOptions_StdioArgs_Select_Stdio(params string[] args)
20+
{
21+
var options = McpApplication.ParseOptions(args);
22+
23+
Assert.Equal(McpTransportMode.Stdio, options.Transport);
24+
Assert.Empty(options.RemainingArgs);
25+
}
26+
27+
[Fact]
28+
public void ParseOptions_HttpArgs_Select_Http()
29+
{
30+
var options = McpApplication.ParseOptions(["--http"]);
31+
32+
Assert.Equal(McpTransportMode.Http, options.Transport);
33+
Assert.Empty(options.RemainingArgs);
34+
}
35+
36+
[Fact]
37+
public void CreateHttpApp_Defaults_To_Localhost_5000()
38+
{
39+
using var app = McpApplication.CreateHttpApp([], "Development");
40+
41+
Assert.Contains(McpApplication.DefaultHttpUrl, app.Urls);
42+
}
43+
44+
[Fact]
45+
public async Task Health_Returns_Success()
46+
{
47+
await using var app = CreateTestHttpApp("Development");
48+
await app.StartAsync();
49+
50+
var response = await app.GetTestClient().GetAsync(McpApplication.HealthPath);
51+
52+
Assert.True(response.IsSuccessStatusCode);
53+
}
54+
55+
[Fact]
56+
public async Task Development_Mcp_Allows_Missing_Bearer_Token()
57+
{
58+
await using var app = CreateTestHttpApp("Development");
59+
await app.StartAsync();
60+
61+
var response = await app.GetTestClient().GetAsync(McpApplication.McpPath);
62+
63+
Assert.NotEqual(System.Net.HttpStatusCode.Unauthorized, response.StatusCode);
64+
}
65+
66+
[Fact]
67+
public void Production_Http_Requires_Token_At_Startup()
68+
{
69+
var exception = Assert.Throws<InvalidOperationException>(() => CreateTestHttpApp("Production"));
70+
71+
Assert.Contains(McpApplication.TokenConfigurationKey, exception.Message);
72+
}
73+
74+
[Theory]
75+
[InlineData(null)]
76+
[InlineData("wrong-token")]
77+
public async Task Production_Mcp_Rejects_Missing_Or_Wrong_Bearer_Token(string? token)
78+
{
79+
await using var app = CreateTestHttpApp("Production", "correct-token");
80+
await app.StartAsync();
81+
using var request = new HttpRequestMessage(HttpMethod.Get, McpApplication.McpPath);
82+
if (token is not null)
83+
request.Headers.Authorization = new("Bearer", token);
84+
85+
var response = await app.GetTestClient().SendAsync(request);
86+
87+
Assert.Equal(System.Net.HttpStatusCode.Unauthorized, response.StatusCode);
88+
}
89+
90+
[Fact]
91+
public async Task Production_Mcp_Allows_Correct_Bearer_Token()
92+
{
93+
await using var app = CreateTestHttpApp("Production", "correct-token");
94+
await app.StartAsync();
95+
using var request = new HttpRequestMessage(HttpMethod.Get, McpApplication.McpPath);
96+
request.Headers.Authorization = new("Bearer", "correct-token");
97+
98+
var response = await app.GetTestClient().SendAsync(request);
99+
100+
Assert.NotEqual(System.Net.HttpStatusCode.Unauthorized, response.StatusCode);
101+
}
102+
103+
private static Microsoft.AspNetCore.Builder.WebApplication CreateTestHttpApp(string environmentName, string? token = null)
104+
{
105+
var configuration = token is null
106+
? null
107+
: new Dictionary<string, string?> { [McpApplication.TokenConfigurationKey] = token };
108+
109+
return McpApplication.CreateHttpApp(
110+
[],
111+
environmentName,
112+
webHost => webHost.UseTestServer(),
113+
configuration);
114+
}
115+
}

BlazorExpress.ChartJS.MCP/BlazorExpress.ChartJS.MCP.csproj

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,9 @@
2121
</PropertyGroup>
2222

2323
<ItemGroup>
24-
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.0" />
24+
<FrameworkReference Include="Microsoft.AspNetCore.App" />
2525
<PackageReference Include="ModelContextProtocol" Version="1.4.0" />
26+
<PackageReference Include="ModelContextProtocol.AspNetCore" Version="1.4.0" />
2627
</ItemGroup>
2728

2829
<ItemGroup>
Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
using Microsoft.AspNetCore.Builder;
2+
using Microsoft.AspNetCore.Hosting;
3+
using Microsoft.AspNetCore.Http;
4+
using Microsoft.Extensions.Configuration;
5+
using Microsoft.Extensions.DependencyInjection;
6+
using Microsoft.Extensions.Hosting;
7+
using ModelContextProtocol.AspNetCore;
8+
using ModelContextProtocol.Server;
9+
10+
namespace BlazorExpress.ChartJS.MCP;
11+
12+
public static class McpApplication
13+
{
14+
public const string DefaultHttpUrl = "http://localhost:5000";
15+
public const string McpPath = "/mcp";
16+
public const string HealthPath = "/health";
17+
public const string TokenConfigurationKey = "CHARTJS_MCP_TOKEN";
18+
19+
public static Task RunAsync(string[] args, CancellationToken cancellationToken = default)
20+
{
21+
var options = ParseOptions(args);
22+
23+
return options.Transport == McpTransportMode.Stdio
24+
? RunStdioAsync(options.RemainingArgs, cancellationToken)
25+
: RunHttpAsync(options.RemainingArgs, cancellationToken);
26+
}
27+
28+
public static McpHostOptions ParseOptions(IReadOnlyList<string> args)
29+
{
30+
var transport = McpTransportMode.Http;
31+
var remainingArgs = new List<string>();
32+
33+
for (var i = 0; i < args.Count; i++)
34+
{
35+
var arg = args[i];
36+
37+
if (string.Equals(arg, "--stdio", StringComparison.OrdinalIgnoreCase))
38+
{
39+
transport = McpTransportMode.Stdio;
40+
continue;
41+
}
42+
43+
if (string.Equals(arg, "--http", StringComparison.OrdinalIgnoreCase))
44+
{
45+
transport = McpTransportMode.Http;
46+
continue;
47+
}
48+
49+
if (string.Equals(arg, "--transport", StringComparison.OrdinalIgnoreCase))
50+
{
51+
if (i + 1 >= args.Count)
52+
throw new ArgumentException("Missing value for --transport. Use 'http' or 'stdio'.");
53+
54+
transport = ParseTransport(args[++i]);
55+
continue;
56+
}
57+
58+
if (arg.StartsWith("--transport=", StringComparison.OrdinalIgnoreCase))
59+
{
60+
transport = ParseTransport(arg["--transport=".Length..]);
61+
continue;
62+
}
63+
64+
remainingArgs.Add(arg);
65+
}
66+
67+
return new McpHostOptions(transport, remainingArgs.ToArray());
68+
}
69+
70+
public static WebApplication CreateHttpApp(
71+
string[] args,
72+
string? environmentName = null,
73+
Action<IWebHostBuilder>? configureWebHost = null,
74+
IEnumerable<KeyValuePair<string, string?>>? configuration = null)
75+
{
76+
var builder = WebApplication.CreateBuilder(new WebApplicationOptions
77+
{
78+
Args = args,
79+
EnvironmentName = environmentName,
80+
});
81+
82+
if (configuration is not null)
83+
builder.Configuration.AddInMemoryCollection(configuration);
84+
85+
var useDefaultHttpUrl = !HasUrlOverride(args);
86+
87+
configureWebHost?.Invoke(builder.WebHost);
88+
89+
RegisterCoreServices(builder.Services);
90+
builder.Services
91+
.AddMcpServer()
92+
.WithHttpTransport(options =>
93+
{
94+
options.Stateless = true;
95+
})
96+
.AddAuthorizationFilters()
97+
.WithToolsFromAssembly();
98+
99+
var token = builder.Configuration[TokenConfigurationKey];
100+
if (!builder.Environment.IsDevelopment() && string.IsNullOrWhiteSpace(token))
101+
throw new InvalidOperationException($"{TokenConfigurationKey} must be configured when HTTP MCP is running outside Development.");
102+
103+
var app = builder.Build();
104+
105+
if (useDefaultHttpUrl)
106+
app.Urls.Add(DefaultHttpUrl);
107+
108+
app.MapGet(HealthPath, () => Results.Ok(new { status = "healthy" }));
109+
110+
if (!app.Environment.IsDevelopment())
111+
app.UseBearerTokenProtection(token!);
112+
113+
app.MapMcp(McpPath);
114+
115+
return app;
116+
}
117+
118+
private static async Task RunHttpAsync(string[] args, CancellationToken cancellationToken)
119+
{
120+
var app = CreateHttpApp(args);
121+
await app.RunAsync(cancellationToken);
122+
}
123+
124+
private static async Task RunStdioAsync(string[] args, CancellationToken cancellationToken)
125+
{
126+
var builder = Host.CreateEmptyApplicationBuilder(new HostApplicationBuilderSettings { Args = args });
127+
128+
RegisterCoreServices(builder.Services);
129+
builder.Services
130+
.AddMcpServer()
131+
.WithStdioServerTransport()
132+
.WithToolsFromAssembly();
133+
134+
await builder.Build().RunAsync(cancellationToken);
135+
}
136+
137+
private static void RegisterCoreServices(IServiceCollection services)
138+
{
139+
services
140+
.AddSingleton<ChartExampleGenerator>()
141+
.AddSingleton<ProjectIntegrationService>();
142+
}
143+
144+
private static McpTransportMode ParseTransport(string value) =>
145+
value.ToLowerInvariant() switch
146+
{
147+
"http" => McpTransportMode.Http,
148+
"stdio" => McpTransportMode.Stdio,
149+
_ => throw new ArgumentException($"Unsupported MCP transport '{value}'. Use 'http' or 'stdio'."),
150+
};
151+
152+
private static bool HasUrlOverride(IEnumerable<string> args) =>
153+
args.Any(arg =>
154+
string.Equals(arg, "--urls", StringComparison.OrdinalIgnoreCase)
155+
|| arg.StartsWith("--urls=", StringComparison.OrdinalIgnoreCase))
156+
|| !string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("ASPNETCORE_URLS"));
157+
}
158+
159+
internal static class McpApplicationBuilderExtensions
160+
{
161+
public static IApplicationBuilder UseBearerTokenProtection(this IApplicationBuilder app, string token) =>
162+
app.Use(async (context, next) =>
163+
{
164+
if (!context.Request.Path.StartsWithSegments(McpApplication.McpPath))
165+
{
166+
await next(context);
167+
return;
168+
}
169+
170+
var expectedHeader = $"Bearer {token}";
171+
var actualHeader = context.Request.Headers.Authorization.ToString();
172+
173+
if (!string.Equals(actualHeader, expectedHeader, StringComparison.Ordinal))
174+
{
175+
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
176+
return;
177+
}
178+
179+
await next(context);
180+
});
181+
}
182+
183+
public sealed record McpHostOptions(McpTransportMode Transport, string[] RemainingArgs);
184+
185+
public enum McpTransportMode
186+
{
187+
Http,
188+
Stdio,
189+
}
Lines changed: 1 addition & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,3 @@
11
using BlazorExpress.ChartJS.MCP;
2-
using Microsoft.Extensions.DependencyInjection;
3-
using Microsoft.Extensions.Hosting;
4-
using ModelContextProtocol.Server;
52

6-
var builder = Host.CreateEmptyApplicationBuilder(new HostApplicationBuilderSettings { Args = args });
7-
8-
builder.Services
9-
.AddSingleton<ChartExampleGenerator>()
10-
.AddSingleton<ProjectIntegrationService>()
11-
.AddMcpServer()
12-
.WithStdioServerTransport()
13-
.WithToolsFromAssembly();
14-
15-
await builder.Build().RunAsync();
3+
await McpApplication.RunAsync(args);

0 commit comments

Comments
 (0)