diff --git a/README.md b/README.md index a85ac26..7af0ade 100644 --- a/README.md +++ b/README.md @@ -243,6 +243,8 @@ Requires .NET 8 / 9 / 10. HTTP default: `MapBuildingBlocksMcp()` → `/mcp`. Cur After you add or rename tools, **restart the API and reload the MCP server in Cursor** (Aspire restart alone does not refresh Cursor’s cached `tools/list`). +![Cursor Configure featurefusion: MCP tools from the running lab API (products.list, orders.*, demo.echo, lab.ping, customers.*, catalog.products.list).](docs/medium/images/mcp-cursor-featurefusion-tools.png) + #### Quick start ```csharp diff --git a/docs/building-blocks/mcp.md b/docs/building-blocks/mcp.md index da9ac64..1d45f01 100644 --- a/docs/building-blocks/mcp.md +++ b/docs/building-blocks/mcp.md @@ -208,7 +208,7 @@ Development only (`http://localhost:5141/mcp`): | `products.list` | Scan + `ISender` (query) | products query | | `lab.ping` | `[McpTool]` + `.WithMcp(app)` on `LabPing` | `GET /api/v1/lab-ping` | -Lab Compose sets `ASPNETCORE_ENVIRONMENT=Development` so MCP is registered (map at `/mcp`). A Production-environment host does not register or map MCP. +Lab Compose and AppHost set `ASPNETCORE_ENVIRONMENT=Development` so HTTP MCP is mapped at `/mcp`. A Production-environment host still registers MCP services (so `.WithMcp` does not crash startup) but does not map `/mcp`. Workspace root for this Cursor window is `src/`. Put this in `src/.cursor/mcp.json` (gitignored; copy locally): diff --git a/src/BuildingBlocks/Mcp/Hosting/McpEndpointRouteBuilderExtensions.cs b/src/BuildingBlocks/Mcp/Hosting/McpEndpointRouteBuilderExtensions.cs index bd5ea05..4a35e4b 100644 --- a/src/BuildingBlocks/Mcp/Hosting/McpEndpointRouteBuilderExtensions.cs +++ b/src/BuildingBlocks/Mcp/Hosting/McpEndpointRouteBuilderExtensions.cs @@ -101,7 +101,10 @@ private static void Register( } var descriptor = McpMethodTool.FromMethod(method, attr, InferKindFromHttp(endpointBuilder)); - services.GetRequiredService().Add(descriptor); + var registry = services.GetService(); + if (registry is null) + return; + registry.Add(descriptor); } private static McpToolKind InferKindFromHttp(EndpointBuilder endpointBuilder) diff --git a/src/Lab/FeatureFusion.AppHost/Program.cs b/src/Lab/FeatureFusion.AppHost/Program.cs index 249fe49..eed5bed 100644 --- a/src/Lab/FeatureFusion.AppHost/Program.cs +++ b/src/Lab/FeatureFusion.AppHost/Program.cs @@ -1,5 +1,12 @@ using FeatureFusion.AppHost; +if (string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT")) + && string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT"))) +{ + Environment.SetEnvironmentVariable("ASPNETCORE_ENVIRONMENT", "Development"); + Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", "Development"); +} + // Force Docker when DCP autodetection flakes on Windows + Docker Desktop. var builder = DistributedApplication.CreateBuilder(DockerRuntime.Configure(args)); @@ -14,6 +21,7 @@ .WithHttpEndpoint(port: 5141, name: "http") .WithEndpoint(7762, targetPort: 5002, scheme: "https", name: "featurefusion-https") .WithUrl("/swagger/index.html?urls.primaryName=v1", "Swagger v1") + .WithEnvironment("ASPNETCORE_ENVIRONMENT", "Development") .WithInfrastructure(infra) .WithSigNozOtlpExporter(signoz); diff --git a/src/Lab/FeatureFusion/Program.cs b/src/Lab/FeatureFusion/Program.cs index 67ea0d3..7fa49eb 100644 --- a/src/Lab/FeatureFusion/Program.cs +++ b/src/Lab/FeatureFusion/Program.cs @@ -29,6 +29,13 @@ using System.Reflection; using static RedisSettings; +if (string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT")) + && string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT"))) +{ + Environment.SetEnvironmentVariable("ASPNETCORE_ENVIRONMENT", "Development"); + Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", "Development"); +} + var builder = WebApplication.CreateBuilder(args); builder.Configuration @@ -77,38 +84,35 @@ cfg.ValidateOnStartup = true; }); -if (builder.Environment.IsDevelopment()) +builder.Services.AddBuildingBlocksMcp(o => { - builder.Services.AddBuildingBlocksMcp(o => - { - o.ScanAssembly(Assembly.GetExecutingAssembly()); - o.UseTelemetry(t => t.IncludeExceptionDetails = true); - o.UseMemoryIdempotency(TimeSpan.FromHours(1)); - }).UseDispatcher(async (sp, msg, ct) => + o.ScanAssembly(Assembly.GetExecutingAssembly()); + o.UseTelemetry(t => t.IncludeExceptionDetails = true); + o.UseMemoryIdempotency(TimeSpan.FromHours(1)); +}).UseDispatcher(async (sp, msg, ct) => +{ + await using var scope = sp.CreateAsyncScope(); + var services = scope.ServiceProvider; + if (msg is FeatureFusion.Features.Orders.Commands.CreateOrderCommand createOrder) { - await using var scope = sp.CreateAsyncScope(); - var services = scope.ServiceProvider; - if (msg is FeatureFusion.Features.Orders.Commands.CreateOrderCommand createOrder) + var admission = services.GetRequiredService(); + var mcpContext = services.GetService(); + var decision = await FeatureFusion.Features.Admission.OrderCreateAdmissionGate.AdmitCreateOrderAsync( + admission, + createOrder, + FeatureFusion.Features.Admission.OrderCreateAdmissionGate.ResolveMcpRequestKey(mcpContext), + ct); + switch (decision) { - var admission = services.GetRequiredService(); - var mcpContext = services.GetService(); - var decision = await FeatureFusion.Features.Admission.OrderCreateAdmissionGate.AdmitCreateOrderAsync( - admission, - createOrder, - FeatureFusion.Features.Admission.OrderCreateAdmissionGate.ResolveMcpRequestKey(mcpContext), - ct); - switch (decision) - { - case FeatureFusion.Features.Admission.AdmissionDecision.Defer defer: - return Result.Success(defer.Pending); - case FeatureFusion.Features.Admission.AdmissionDecision.Deny deny: - return Result.Failure(deny.Error, deny.StatusCode); - } + case FeatureFusion.Features.Admission.AdmissionDecision.Defer defer: + return Result.Success(defer.Pending); + case FeatureFusion.Features.Admission.AdmissionDecision.Deny deny: + return Result.Failure(deny.Error, deny.StatusCode); } + } - return await services.GetRequiredService().Send(msg, ct); - }); -} + return await services.GetRequiredService().Send(msg, ct); +}); builder.Services.AddApiVersioningWithReader(); diff --git a/tests/BuildingBlocks/Mcp.Tests/CatalogAndInvokerTests.cs b/tests/BuildingBlocks/Mcp.Tests/CatalogAndInvokerTests.cs index 6943211..1cee632 100644 --- a/tests/BuildingBlocks/Mcp.Tests/CatalogAndInvokerTests.cs +++ b/tests/BuildingBlocks/Mcp.Tests/CatalogAndInvokerTests.cs @@ -4,6 +4,7 @@ using BuildingBlocks.Mcp.Hosting; using BuildingBlocks.Mcp.Invocation; using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; using Xunit; @@ -132,6 +133,17 @@ public async Task WithMcp_And_Scan_Dedupe_Same_Name() Assert.Equal("pong:Ada", result.Value); } + [Fact] + public async Task WithMcp_Without_AddBuildingBlocksMcp_Does_Not_Fail_Host_Start() + { + var builder = WebApplication.CreateSlimBuilder(); + builder.WebHost.UseUrls("http://127.0.0.1:0"); + await using var app = builder.Build(); + app.MapGet("/named-ping", WithMcpNamedPing).WithMcp(app, "tests.withmcp-no-add", "Named ping"); + await app.StartAsync(); + await app.StopAsync(); + } + [Fact] public async Task WithMcp_Named_Infers_Query_From_Get() {