Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/building-blocks/mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -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):

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,10 @@ private static void Register(
}

var descriptor = McpMethodTool.FromMethod(method, attr, InferKindFromHttp(endpointBuilder));
services.GetRequiredService<McpEndpointToolRegistry>().Add(descriptor);
var registry = services.GetService<McpEndpointToolRegistry>();
if (registry is null)
return;
registry.Add(descriptor);
}

private static McpToolKind InferKindFromHttp(EndpointBuilder endpointBuilder)
Expand Down
8 changes: 8 additions & 0 deletions src/Lab/FeatureFusion.AppHost/Program.cs
Original file line number Diff line number Diff line change
@@ -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));

Expand All @@ -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);

Expand Down
58 changes: 31 additions & 27 deletions src/Lab/FeatureFusion/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<FeatureFusion.Features.Admission.ICapabilityAdmission>();
var mcpContext = services.GetService<BuildingBlocks.Mcp.IMcpInvokeContextAccessor>();
var decision = await FeatureFusion.Features.Admission.OrderCreateAdmissionGate.AdmitCreateOrderAsync(
admission,
createOrder,
FeatureFusion.Features.Admission.OrderCreateAdmissionGate.ResolveMcpRequestKey(mcpContext),
ct);
switch (decision)
{
var admission = services.GetRequiredService<FeatureFusion.Features.Admission.ICapabilityAdmission>();
var mcpContext = services.GetService<BuildingBlocks.Mcp.IMcpInvokeContextAccessor>();
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<FeatureFusion.Features.Admission.AdmissionPendingResponse>.Success(defer.Pending);
case FeatureFusion.Features.Admission.AdmissionDecision.Deny deny:
return Result<FeatureFusion.Features.Admission.AdmissionPendingResponse>.Failure(deny.Error, deny.StatusCode);
}
case FeatureFusion.Features.Admission.AdmissionDecision.Defer defer:
return Result<FeatureFusion.Features.Admission.AdmissionPendingResponse>.Success(defer.Pending);
case FeatureFusion.Features.Admission.AdmissionDecision.Deny deny:
return Result<FeatureFusion.Features.Admission.AdmissionPendingResponse>.Failure(deny.Error, deny.StatusCode);
}
}

return await services.GetRequiredService<ISender>().Send(msg, ct);
});
}
return await services.GetRequiredService<ISender>().Send(msg, ct);
});


builder.Services.AddApiVersioningWithReader();
Expand Down
12 changes: 12 additions & 0 deletions tests/BuildingBlocks/Mcp.Tests/CatalogAndInvokerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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()
{
Expand Down
Loading