diff --git a/src/CommunityToolkit.Aspire.Hosting.McpInspector/CommunityToolkit.Aspire.Hosting.McpInspector.csproj b/src/CommunityToolkit.Aspire.Hosting.McpInspector/CommunityToolkit.Aspire.Hosting.McpInspector.csproj index de12cbf0..3aa5b4a8 100644 --- a/src/CommunityToolkit.Aspire.Hosting.McpInspector/CommunityToolkit.Aspire.Hosting.McpInspector.csproj +++ b/src/CommunityToolkit.Aspire.Hosting.McpInspector/CommunityToolkit.Aspire.Hosting.McpInspector.csproj @@ -6,6 +6,7 @@ + diff --git a/src/CommunityToolkit.Aspire.Hosting.McpInspector/McpInspectorOptions.cs b/src/CommunityToolkit.Aspire.Hosting.McpInspector/McpInspectorOptions.cs new file mode 100644 index 00000000..7932c679 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.McpInspector/McpInspectorOptions.cs @@ -0,0 +1,30 @@ +using Aspire.Hosting.ApplicationModel; + +namespace Aspire.Hosting; + +/// +/// Options for configuring the MCP Inspector resource. +/// +public class McpInspectorOptions +{ + /// + /// Gets or sets the port for the client application. Defaults to "6274". + /// + public int ClientPort { get; set; } = 6274; + + /// + /// Gets or sets the port for the server proxy application. Defaults to "6277". + /// + public int ServerPort { get; set; } = 6277; + + /// + /// Gets or sets the version of the Inspector app to use. Defaults to . + /// + public string InspectorVersion { get; set; } = McpInspectorResource.InspectorVersion; + + /// + /// Gets or sets the parameter used to provide the proxy authentication token for the MCP Inspector resource. + /// If a random token will be generated. + /// + public IResourceBuilder? ProxyToken { get; set; } +} diff --git a/src/CommunityToolkit.Aspire.Hosting.McpInspector/McpInspectorResource.cs b/src/CommunityToolkit.Aspire.Hosting.McpInspector/McpInspectorResource.cs index c014a7bf..16d99a33 100644 --- a/src/CommunityToolkit.Aspire.Hosting.McpInspector/McpInspectorResource.cs +++ b/src/CommunityToolkit.Aspire.Hosting.McpInspector/McpInspectorResource.cs @@ -53,6 +53,11 @@ public class McpInspectorResource(string name) : ExecutableResource(name, "npx", /// public McpServerMetadata? DefaultMcpServer => _defaultMcpServer; + /// + /// Gets or sets the parameter that contains the MCP proxy authentication token. + /// + public ParameterResource ProxyTokenParameter { get; set; } = default!; + internal void AddMcpServer(IResourceWithEndpoints mcpServer, bool isDefault, McpTransportType transportType) { if (_mcpServers.Any(s => s.Name == mcpServer.Name)) diff --git a/src/CommunityToolkit.Aspire.Hosting.McpInspector/McpInspectorResourceBuilderExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.McpInspector/McpInspectorResourceBuilderExtensions.cs index c5d062e8..1ab5405c 100644 --- a/src/CommunityToolkit.Aspire.Hosting.McpInspector/McpInspectorResourceBuilderExtensions.cs +++ b/src/CommunityToolkit.Aspire.Hosting.McpInspector/McpInspectorResourceBuilderExtensions.cs @@ -1,4 +1,5 @@ using Aspire.Hosting.ApplicationModel; +using Microsoft.Extensions.DependencyInjection; namespace Aspire.Hosting; @@ -15,16 +16,39 @@ public static class McpInspectorResourceBuilderExtensions /// The port for the client application. Defaults to 6274. /// The port for the server proxy application. Defaults to 6277. /// The version of the Inspector app to use + [Obsolete("Use the overload with McpInspectorOptions instead. This overload will be removed in the next version.")] public static IResourceBuilder AddMcpInspector(this IDistributedApplicationBuilder builder, [ResourceName] string name, int clientPort = 6274, int serverPort = 6277, string inspectorVersion = McpInspectorResource.InspectorVersion) { - return builder.AddResource(new McpInspectorResource(name)) - .WithArgs(["-y", $"@modelcontextprotocol/inspector@{inspectorVersion}"]) + ArgumentNullException.ThrowIfNull(builder); + + return AddMcpInspector(builder, name, options => + { + options.ClientPort = clientPort; + options.ServerPort = serverPort; + options.InspectorVersion = inspectorVersion; + }); + } + + /// + /// Adds a MCP Inspector container resource to the using an options object. + /// + /// The to which the MCP Inspector resource will be added. + /// The name of the MCP Inspector container resource. + /// The to configure the MCP Inspector resource. + /// A reference to the for further configuration. + public static IResourceBuilder AddMcpInspector(this IDistributedApplicationBuilder builder, [ResourceName] string name, McpInspectorOptions options) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(options); + + var proxyTokenParameter = options.ProxyToken?.Resource ?? ParameterResourceBuilderExtensions.CreateDefaultPasswordParameter(builder, $"{name}-proxyToken"); + + var resource = builder.AddResource(new McpInspectorResource(name)) + .WithArgs(["-y", $"@modelcontextprotocol/inspector@{options.InspectorVersion}"]) .ExcludeFromManifest() - .WithHttpEndpoint(isProxied: false, port: clientPort, env: "CLIENT_PORT", name: McpInspectorResource.ClientEndpointName) - .WithHttpEndpoint(isProxied: false, port: serverPort, env: "SERVER_PORT", name: McpInspectorResource.ServerProxyEndpointName) - .WithEnvironment("DANGEROUSLY_OMIT_AUTH", "true") + .WithHttpEndpoint(isProxied: false, port: options.ClientPort, env: "CLIENT_PORT", name: McpInspectorResource.ClientEndpointName) + .WithHttpEndpoint(isProxied: false, port: options.ServerPort, env: "SERVER_PORT", name: McpInspectorResource.ServerProxyEndpointName) .WithHttpHealthCheck("/", endpointName: McpInspectorResource.ClientEndpointName) - .WithHttpHealthCheck("/config", endpointName: McpInspectorResource.ServerProxyEndpointName) .WithEnvironment("MCP_AUTO_OPEN_ENABLED", "false") .WithUrlForEndpoint(McpInspectorResource.ClientEndpointName, annotation => { @@ -35,6 +59,7 @@ public static IResourceBuilder AddMcpInspector(this IDistr { annotation.DisplayText = "Server Proxy"; annotation.DisplayOrder = 1; + annotation.DisplayLocation = UrlDisplayLocation.DetailsOnly; }) .OnBeforeResourceStarted(async (inspectorResource, @event, ct) => { @@ -78,8 +103,76 @@ public static IResourceBuilder AddMcpInspector(this IDistr ctx.EnvironmentVariables["MCP_PROXY_FULL_ADDRESS"] = serverProxyEndpoint.Url; ctx.EnvironmentVariables["CLIENT_PORT"] = clientEndpoint.TargetPort?.ToString() ?? throw new InvalidOperationException("The MCP Inspector 'client' endpoint must have a target port defined."); ctx.EnvironmentVariables["SERVER_PORT"] = serverProxyEndpoint.TargetPort?.ToString() ?? throw new InvalidOperationException("The MCP Inspector 'server-proxy' endpoint must have a target port defined."); + ctx.EnvironmentVariables["MCP_PROXY_AUTH_TOKEN"] = proxyTokenParameter; }) - .WithDefaultArgs(); + .WithDefaultArgs() + .WithUrls(async context => + { + var token = await proxyTokenParameter.GetValueAsync(CancellationToken.None); + + foreach (var url in context.Urls) + { + if (url.Endpoint is not null) + { + var uriBuilder = new UriBuilder(url.Url) + { + Query = $"MCP_PROXY_AUTH_TOKEN={Uri.EscapeDataString(token!)}" + }; + url.Url = uriBuilder.ToString(); + } + } + }); + + resource.Resource.ProxyTokenParameter = proxyTokenParameter; + + // Add authenticated health check for server proxy /config endpoint + var healthCheckKey = $"{name}_proxy_config_check"; + builder.Services.AddHealthChecks().AddUrlGroup(options => + { + var serverProxyEndpoint = resource.GetEndpoint(McpInspectorResource.ServerProxyEndpointName); + var uri = serverProxyEndpoint.Url ?? throw new DistributedApplicationException("The MCP Inspector 'server-proxy' endpoint URL is not set. Ensure that the resource has been allocated before the health check is executed."); + var healthCheckUri = new Uri(new Uri(uri), "/config"); + options.AddUri(healthCheckUri, async setup => + { + var token = await proxyTokenParameter.GetValueAsync(CancellationToken.None); + setup.AddCustomHeader("X-MCP-Proxy-Auth", $"Bearer {token}"); + }); + }, healthCheckKey); + + return resource.WithHealthCheck(healthCheckKey); + } + + /// + /// Adds a MCP Inspector container resource to the using a configuration delegate. + /// + /// The to which the MCP Inspector resource will be added. + /// The name of the MCP Inspector container resource. + /// A delegate to configure the . + /// A reference to the for further configuration. + public static IResourceBuilder AddMcpInspector(this IDistributedApplicationBuilder builder, [ResourceName] string name, Action configureOptions) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(configureOptions); + + var options = new McpInspectorOptions(); + configureOptions(options); + + return builder.AddMcpInspector(name, options); + } + + /// + /// Adds a MCP Inspector container resource to the using a configuration delegate. + /// + /// The to which the MCP Inspector resource will be added. + /// The name of the MCP Inspector container resource. + /// A reference to the for further configuration. + public static IResourceBuilder AddMcpInspector(this IDistributedApplicationBuilder builder, [ResourceName] string name) + { + ArgumentNullException.ThrowIfNull(builder); + + var options = new McpInspectorOptions(); + + return builder.AddMcpInspector(name, options); } /// diff --git a/src/CommunityToolkit.Aspire.Hosting.McpInspector/README.md b/src/CommunityToolkit.Aspire.Hosting.McpInspector/README.md index cdc6dc86..975aacb6 100644 --- a/src/CommunityToolkit.Aspire.Hosting.McpInspector/README.md +++ b/src/CommunityToolkit.Aspire.Hosting.McpInspector/README.md @@ -23,7 +23,47 @@ var inspector = builder.AddMcpInspector("inspector") .WithMcpServer(mcpServer); ``` -You can specify the transport type (`StreamableHttp` or `Sse`) and set which server is the default for the inspector. +You can specify the transport type (`StreamableHttp`) and set which server is the default for the inspector. + +#### Using options for complex configurations + +For more complex configurations with multiple parameters, you can use the options-based approach: + +```csharp +var customToken = builder.AddParameter("custom-proxy-token", secret: true); + +var options = new McpInspectorOptions +{ + ClientPort = 6275, + ServerPort = 6278, + InspectorVersion = "0.16.2", + ProxyToken = customToken +}; + +var inspector = builder.AddMcpInspector("inspector", options) + .WithMcpServer(mcpServer); +``` + +Alternatively, you can use a configuration delegate for a more fluent approach: + +```csharp +var inspector = builder.AddMcpInspector("inspector", options => +{ + options.ClientPort = 6275; + options.ServerPort = 6278; + options.InspectorVersion = "0.16.2"; +}) + .WithMcpServer(mcpServer); +``` + +#### Configuration options + +The `McpInspectorOptions` class provides the following configuration properties: + +- `ClientPort`: Port for the client application (default: 6274 +- `ServerPort`: Port for the server proxy application (default: 6277) +- `InspectorVersion`: Version of the Inspector app to use (default: latest supported version) +- `ProxyToken`: Custom authentication token parameter (default: auto-generated) ## Additional Information @@ -31,4 +71,4 @@ See the [official documentation](https://learn.microsoft.com/dotnet/aspire/commu ## Feedback & contributing -https://github.com/CommunityToolkit/Aspire +[https://github.com/CommunityToolkit/Aspire](https://github.com/CommunityToolkit/Aspire) diff --git a/tests/CommunityToolkit.Aspire.Hosting.McpInspector.Tests/AppHostTests.cs b/tests/CommunityToolkit.Aspire.Hosting.McpInspector.Tests/AppHostTests.cs index 6ce1a0b3..054cf433 100644 --- a/tests/CommunityToolkit.Aspire.Hosting.McpInspector.Tests/AppHostTests.cs +++ b/tests/CommunityToolkit.Aspire.Hosting.McpInspector.Tests/AppHostTests.cs @@ -1,19 +1,41 @@ +using Aspire.Hosting.ApplicationModel; using CommunityToolkit.Aspire.Testing; namespace CommunityToolkit.Aspire.Hosting.McpInspector.Tests; public class AppHostTests(AspireIntegrationTestFixture fixture) : IClassFixture> { - [Theory] - [InlineData(McpInspectorResource.ClientEndpointName, "/")] - [InlineData(McpInspectorResource.ServerProxyEndpointName, "/config")] - public async Task ResourceStartsAndRespondsOk(string endpointName, string route) + [Fact] + public async Task ClientEndpointStartsAndRespondsOk() { var resourceName = "mcp-inspector"; await fixture.ResourceNotificationService.WaitForResourceHealthyAsync(resourceName).WaitAsync(TimeSpan.FromMinutes(5)); - var httpClient = fixture.CreateHttpClient(resourceName, endpointName: endpointName); + var httpClient = fixture.CreateHttpClient(resourceName, endpointName: McpInspectorResource.ClientEndpointName); - var response = await httpClient.GetAsync(route); + var response = await httpClient.GetAsync("/"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task ServerProxyConfigEndpointWithAuthRespondsOk() + { + var resourceName = "mcp-inspector"; + await fixture.ResourceNotificationService.WaitForResourceHealthyAsync(resourceName).WaitAsync(TimeSpan.FromMinutes(5)); + + // Get the MCP Inspector resource to access the proxy token parameter + var appModel = fixture.App.Services.GetRequiredService(); + var mcpInspectorResource = appModel.Resources.OfType().Single(r => r.Name == resourceName); + + // Get the token value + var token = await mcpInspectorResource.ProxyTokenParameter.GetValueAsync(CancellationToken.None); + + var httpClient = fixture.CreateHttpClient(resourceName, endpointName: McpInspectorResource.ServerProxyEndpointName); + + // Add the Bearer token header for authentication + httpClient.DefaultRequestHeaders.Add("X-MCP-Proxy-Auth", $"Bearer {token}"); + + var response = await httpClient.GetAsync("/config"); Assert.Equal(HttpStatusCode.OK, response.StatusCode); } diff --git a/tests/CommunityToolkit.Aspire.Hosting.McpInspector.Tests/McpInspectorResourceBuilderExtensionsTests.cs b/tests/CommunityToolkit.Aspire.Hosting.McpInspector.Tests/McpInspectorResourceBuilderExtensionsTests.cs index b89600ac..98a1b163 100644 --- a/tests/CommunityToolkit.Aspire.Hosting.McpInspector.Tests/McpInspectorResourceBuilderExtensionsTests.cs +++ b/tests/CommunityToolkit.Aspire.Hosting.McpInspector.Tests/McpInspectorResourceBuilderExtensionsTests.cs @@ -1,4 +1,5 @@ using Aspire.Hosting; +using Aspire.Hosting.ApplicationModel; namespace CommunityToolkit.Aspire.Hosting.McpInspector.Tests; @@ -89,4 +90,184 @@ public void WithMultipleMcpServersAddsAllServersToResource() Assert.NotNull(inspectorResource.DefaultMcpServer); Assert.Equal("mcpServer1", inspectorResource.DefaultMcpServer.Name); } + + [Fact] + public void AddMcpInspectorGeneratesProxyTokenParameter() + { + // Arrange + var appBuilder = DistributedApplication.CreateBuilder(); + + // Act + var inspector = appBuilder.AddMcpInspector("inspector"); + + using var app = appBuilder.Build(); + + // Assert + var appModel = app.Services.GetRequiredService(); + + var inspectorResource = Assert.Single(appModel.Resources.OfType()); + Assert.NotNull(inspectorResource.ProxyTokenParameter); + Assert.Equal("inspector-proxyToken", inspectorResource.ProxyTokenParameter.Name); + } + + [Fact] + public void AddMcpInspectorWithCustomProxyTokenUsesProvidedToken() + { + // Arrange + var appBuilder = DistributedApplication.CreateBuilder(); + var customToken = appBuilder.AddParameter("custom-token", secret: true); + + // Act + var inspector = appBuilder.AddMcpInspector("inspector", options => + { + options.ProxyToken = customToken; + }); + + using var app = appBuilder.Build(); + + // Assert + var appModel = app.Services.GetRequiredService(); + + var inspectorResource = Assert.Single(appModel.Resources.OfType()); + Assert.NotNull(inspectorResource.ProxyTokenParameter); + Assert.Equal("custom-token", inspectorResource.ProxyTokenParameter.Name); + Assert.Same(customToken.Resource, inspectorResource.ProxyTokenParameter); + } + + [Fact] + public void AddMcpInspectorSetsCorrectEnvironmentVariables() + { + // Arrange + var appBuilder = DistributedApplication.CreateBuilder(); + + // Act + var inspector = appBuilder.AddMcpInspector("inspector", options => + { + options.ClientPort = 1234; + options.ServerPort = 5678; + }); + + using var app = appBuilder.Build(); + + // Assert + var appModel = app.Services.GetRequiredService(); + var inspectorResource = Assert.Single(appModel.Resources.OfType()); + + // Verify endpoints are configured correctly + var clientEndpoint = inspectorResource.Annotations.OfType() + .Single(e => e.Name == McpInspectorResource.ClientEndpointName); + var serverEndpoint = inspectorResource.Annotations.OfType() + .Single(e => e.Name == McpInspectorResource.ServerProxyEndpointName); + + Assert.Equal(1234, clientEndpoint.Port); + Assert.Equal(5678, serverEndpoint.Port); + } + + [Fact] + public void AddMcpInspectorWithOptionsCreatesResourceCorrectly() + { + // Arrange + var appBuilder = DistributedApplication.CreateBuilder(); + var customToken = appBuilder.AddParameter("custom-token", secret: true); + + var options = new McpInspectorOptions + { + ClientPort = 1111, + ServerPort = 2222, + InspectorVersion = "0.15.0", + ProxyToken = customToken + }; + + // Act + var inspector = appBuilder.AddMcpInspector("inspector", options); + + using var app = appBuilder.Build(); + + // Assert + var appModel = app.Services.GetRequiredService(); + var inspectorResource = Assert.Single(appModel.Resources.OfType()); + + Assert.Equal("inspector", inspectorResource.Name); + Assert.Equal("custom-token", inspectorResource.ProxyTokenParameter.Name); + Assert.Same(customToken.Resource, inspectorResource.ProxyTokenParameter); + + // Verify endpoints are configured correctly + var clientEndpoint = inspectorResource.Annotations.OfType() + .Single(e => e.Name == McpInspectorResource.ClientEndpointName); + var serverEndpoint = inspectorResource.Annotations.OfType() + .Single(e => e.Name == McpInspectorResource.ServerProxyEndpointName); + + Assert.Equal(1111, clientEndpoint.Port); + Assert.Equal(2222, serverEndpoint.Port); + + // Verify version argument is set correctly + var argsAnnotation = inspectorResource.Annotations.OfType().First(); + // We can't easily test the args directly, but we can verify the structure is correct + Assert.NotNull(argsAnnotation); + } + + [Fact] + public void AddMcpInspectorWithOptionsUsesDefaultsWhenNotSpecified() + { + // Arrange + var appBuilder = DistributedApplication.CreateBuilder(); + var options = new McpInspectorOptions(); // Use all defaults + + // Act + var inspector = appBuilder.AddMcpInspector("inspector", options); + + using var app = appBuilder.Build(); + + // Assert + var appModel = app.Services.GetRequiredService(); + var inspectorResource = Assert.Single(appModel.Resources.OfType()); + + Assert.Equal("inspector", inspectorResource.Name); + Assert.Equal("inspector-proxyToken", inspectorResource.ProxyTokenParameter.Name); + + // Verify endpoints use default ports + var clientEndpoint = inspectorResource.Annotations.OfType() + .Single(e => e.Name == McpInspectorResource.ClientEndpointName); + var serverEndpoint = inspectorResource.Annotations.OfType() + .Single(e => e.Name == McpInspectorResource.ServerProxyEndpointName); + + Assert.Equal(6274, clientEndpoint.Port); + Assert.Equal(6277, serverEndpoint.Port); + } + + [Fact] + public void AddMcpInspectorWithConfigurationDelegateCreatesResourceCorrectly() + { + // Arrange + var appBuilder = DistributedApplication.CreateBuilder(); + var customToken = appBuilder.AddParameter("custom-token", secret: true); + + // Act + var inspector = appBuilder.AddMcpInspector("inspector", options => + { + options.ClientPort = 3333; + options.ServerPort = 4444; + options.InspectorVersion = "0.15.0"; + options.ProxyToken = customToken; + }); + + using var app = appBuilder.Build(); + + // Assert + var appModel = app.Services.GetRequiredService(); + var inspectorResource = Assert.Single(appModel.Resources.OfType()); + + Assert.Equal("inspector", inspectorResource.Name); + Assert.Equal("custom-token", inspectorResource.ProxyTokenParameter.Name); + Assert.Same(customToken.Resource, inspectorResource.ProxyTokenParameter); + + // Verify endpoints are configured correctly + var clientEndpoint = inspectorResource.Annotations.OfType() + .Single(e => e.Name == McpInspectorResource.ClientEndpointName); + var serverEndpoint = inspectorResource.Annotations.OfType() + .Single(e => e.Name == McpInspectorResource.ServerProxyEndpointName); + + Assert.Equal(3333, clientEndpoint.Port); + Assert.Equal(4444, serverEndpoint.Port); + } }