LowCodeHub.MinimalEndpoints
0.1.0
dotnet add package LowCodeHub.MinimalEndpoints --version 0.1.0
NuGet\Install-Package LowCodeHub.MinimalEndpoints -Version 0.1.0
<PackageReference Include="LowCodeHub.MinimalEndpoints" Version="0.1.0" />
<PackageVersion Include="LowCodeHub.MinimalEndpoints" Version="0.1.0" />
<PackageReference Include="LowCodeHub.MinimalEndpoints" />
paket add LowCodeHub.MinimalEndpoints --version 0.1.0
#r "nuget: LowCodeHub.MinimalEndpoints, 0.1.0"
#:package LowCodeHub.MinimalEndpoints@0.1.0
#addin nuget:?package=LowCodeHub.MinimalEndpoints&version=0.1.0
#tool nuget:?package=LowCodeHub.MinimalEndpoints&version=0.1.0
LowCodeHub.MinimalEndpoints
A comprehensive utility library for ASP.NET Core Minimal APIs — modular endpoint registration, validation and logging filters, language/timezone awareness, JSON localization, in-memory and Redis-backed event bus, CQRS-lite operation dispatcher, ProblemDetails helpers, and a global exception handler. Everything you need to build production Minimal APIs without boilerplate.
Why This Library?
| Feature | LowCodeHub.MinimalEndpoints | Raw Minimal APIs | MediatR |
|---|---|---|---|
| Modular registration | Reflection scanner over marker or explicit assemblies | Manual MapGet/Post |
Manual |
| Validation | Endpoint filter — sync + async | Manual | Pipeline behavior |
| Operations (CQRS) | Built-in IOperation + ErrorOr<T> |
Manual | Full MediatR |
| Event bus | In-memory + Redis with DLQ | Manual | INotification |
| Manual mapper | IMapper with DI handler registration |
Manual | AutoMapper |
| Localization | JSON-based IStringLocalizer |
Resource files | N/A |
| Language/timezone | Middleware — header-driven context | Manual | N/A |
| Correlation ID | Middleware — multi-header support | Manual | N/A |
| Success responses | Return your model or ASP.NET Core typed results | Results.Ok() etc. |
N/A |
| Failure responses | RFC 9457 ProblemDetails with IProblemDetailsService |
Manual | N/A |
| Observability | OpenTelemetry metrics + traces for event bus | Manual | N/A |
Installation
dotnet add package LowCodeHub.MinimalEndpoints
Quick Start
Discovery is reflection-based. You can scan the assembly that contains a marker type, or pass explicit assemblies when endpoints and handlers live in multiple projects.
using LowCodeHub.MinimalEndpoints.Extensions;
// Register only the pieces your app uses.
builder.Services.AddValidators<Program>();
builder.Services.AddOperations<Program>();
builder.Services.AddManualMapper<Program>();
builder.Services.AddDualMappers<Program>();
// Or pass multiple feature assemblies explicitly to each registration method.
// builder.Services.AddOperations(
// typeof(UsersModule).Assembly,
// typeof(OrdersModule).Assembly);
// Choose your event-bus backend.
builder.Services.AddEventBus(); // in-memory
// OR
builder.Services.AddRedisEventBus(o => // multi-instance
{
o.ConnectionString = "localhost:6379";
o.QueueKey = "domain-events";
});
builder.Services.AddCorrelationId();
builder.Services.AddLanguageAwareness(defaultLanguage: "en");
builder.Services.AddTimeZoneAwareness();
builder.Services.AddTimeZoneAwareJson(); // optional — every DateTime[Offset] projects into caller's TZ
builder.Services.AddDefault500ExceptionHandler();
var app = builder.Build();
app.UseExceptionHandler();
app.UseCorrelationId();
app.UseLanguageAwareness();
app.UseTimeZoneAwareness();
// Maps every IModule in the API assembly.
app.MapModules<Program>();
// Or map multiple feature assemblies explicitly.
// app.MapModules(
// typeof(UsersModule).Assembly,
// typeof(OrdersModule).Assembly);
Table of Contents
- Modular Endpoint Registration
- Endpoint Filters
- Operations (CQRS-Lite)
- Typed Results
- Domain Event Bus
- Manual Mapper
- Correlation ID
- Language Awareness
- Timezone Awareness
- JSON Localization
- Global Exception Handler
- How It Works
- MCP — expose API contracts to AI agents
- Requirements
- License
Modular Endpoint Registration
Define modules implementing IModule. Implementations are mapped by the reflection scanner using
app.MapModules<TScanner>() or app.MapModules(...).
public sealed class OrdersModule : IModule
{
public static void AddRoutes(IEndpointRouteBuilder app)
{
var group = app.MapGroup("/orders")
.AddLogging();
group.MapEndpoint<GetOrdersEndpoint>();
group.MapEndpoint<CreateOrderEndpoint>();
}
}
Use IMinimalEndpoint for endpoint classes that are owned by a module. Endpoint classes are not
scanned globally; this keeps grouping, prefixes, authorization, filters, and OpenAPI metadata under
the module's control.
public sealed class GetOrdersEndpoint : IMinimalEndpoint
{
public static void AddRoute(IEndpointRouteBuilder app)
{
app.MapGet("/", () => Results.Ok());
}
}
public sealed class CreateOrderEndpoint : IMinimalEndpoint
{
public static void AddRoute(IEndpointRouteBuilder app)
{
app.MapPost("/", (CreateOrderRequest request) => Results.Created())
.AddValidator<CreateOrderRequest>();
}
}
Endpoint Filters
Validation Filter
Register validators with AddValidators<TScanner>() or AddValidators(...). Apply them as endpoint filters:
// Implement a validator
public sealed class CreateOrderValidator : IMinimalValidator<CreateOrderRequest>
{
public IEnumerable<ValidationFailure> Validate(CreateOrderRequest request)
{
if (string.IsNullOrWhiteSpace(request.Name))
yield return new ValidationFailure { PropertyName = "Name", ErrorMessage = "Name is required" };
}
}
// Async validators are also supported
public sealed class UniqueOrderValidator : IAsyncMinimalValidator<CreateOrderRequest>
{
public async IAsyncEnumerable<ValidationFailure> ValidateAsync(
CreateOrderRequest request, CancellationToken ct)
{
if (await _repo.ExistsAsync(request.Name, ct))
yield return new ValidationFailure { PropertyName = "Name", ErrorMessage = "Order already exists" };
}
}
// Apply to endpoints
app.MapPost("/orders", CreateOrder)
.AddValidator<CreateOrderRequest>();
Logging Filter
Adds structured request logging with timing, status code, and a logger scope enriched with the correlation ID, language and timezone of the current request. Failures (exceptions thrown by the handler) are logged with the elapsed time and re-thrown so the global exception handler can take over.
// Per-endpoint
app.MapPost("/orders", CreateOrder).AddLogging();
// Group-wide (every endpoint underneath inherits the filter)
app.MapGroup("/orders").AddLogging()
.MapPost("/", CreateOrder);
// Opt out for a noisy endpoint
[NoLogging]
public static IResult Health() => Results.Ok();
Output looks like:
info: LowCodeHub.MinimalEndpoints.Endpoint[0]
POST /orders → 201 in 12.4 ms (HTTP: POST /orders)
EndpointName: HTTP: POST /orders, CorrelationId: 0d…, Language: ar, TimeZone: Africa/Cairo
Operations (CQRS-Lite)
The operations pattern provides a structured way to define units of work that return ErrorOr<TResult>. Each operation is a self-contained handler with single responsibility, dispatched through a single IOperation service.
Define Request and Handler
// Request defines the return type
public record CreateOrderRequest(string Name, List<Item> Items) : IOperationRequest<OrderDto>;
// One handler per operation
public class CreateOrderHandler(IOrderRepository repo) : IOperationHandler<CreateOrderRequest, OrderDto>
{
public async Task<ErrorOr<OrderDto>> HandleAsync(
CreateOrderRequest request, CancellationToken ct)
{
if (await repo.ExistsAsync(request.Name, ct))
return Error.Conflict("Order.Duplicate", "Order already exists");
var order = await repo.CreateAsync(request, ct);
return new OrderDto(order.Id, order.Name);
}
}
Operation handlers are registered by AddOperations<TScanner>() or AddOperations(...).
Use in Endpoints
app.MapPost("/orders", async (CreateOrderRequest req, IOperation op, CancellationToken ct) =>
{
var result = await op.ExecuteAsync(req, ct);
return result.IsError
? result.FirstError.ToProblem() // RFC 9457 ProblemDetails
: TypedResults.Created($"/orders/{result.Value.Id}", result.Value);
});
Chain Operations
Short-circuit on first error using ThenAsync:
app.MapPost("/orders", async (CreateOrderRequest req, IOperation op, CancellationToken ct) =>
{
var result = await op.ExecuteAsync(req, ct)
.ThenAsync(order => op.ExecuteAsync(new SendConfirmationRequest(order.Id), ct));
return result.IsError
? result.ToProblem()
: TypedResults.Ok();
});
Responses — success vs. failure
The package draws a hard line between the two:
- Success → your model or ASP.NET Core typed results — return the payload directly when the default 200 OK is enough, or use
TypedResults.Ok(value),TypedResults.Created(location, value),TypedResults.Accepted(location, value), andTypedResults.NoContent()when you need a specific status. - Failure → ProblemDetails (RFC 9457) —
Error.ToProblem(),.ToBadRequest(),.ToNotFound(),.ToConflict(),.ToUnprocessableEntity(),.ToBusinessFailure(),.ToUnauthorized(),.ToForbidden(),.ToLocked(),.ToInternalServerError().
// Success
order; // Minimal APIs serialize returned models as 200 OK
TypedResults.Ok(order);
TypedResults.Created($"/orders/{order.Id}", order);
TypedResults.NoContent();
// Failure — every helper writes through IProblemDetailsService so app-wide CustomizeProblemDetails fires.
error.ToProblem(); // status auto-mapped from Error.Type
error.ToBadRequest();
error.ToNotFound();
error.ToConflict();
error.ToUnprocessableEntity();
error.ToBusinessFailure(); // alias for 422
// ErrorOr<T> shortcut
result.ToProblem(); // == result.FirstError.ToProblem()
The status auto-mapping for ToProblem() follows the standard ErrorOr → HTTP convention:
Validation → 400, Unauthorized → 401, Forbidden → 403, NotFound → 404, Conflict → 409,
Failure → 422, Unexpected → 500.
Domain Event Bus
In-Memory Event Bus
builder.Services.AddEventBus();
- Bounded channel for backpressure
- Hosted background processor
- Handlers can be registered with
AddEventBus<TScanner>()orAddEventBus(assemblies)
// Define events and handlers
public record OrderCreated(Guid OrderId) : IDomainEvent;
public sealed class OrderCreatedHandler : IDomainEventHandler<OrderCreated>
{
public ValueTask Handle(OrderCreated domainEvent, CancellationToken ct)
{
// Handle event...
return ValueTask.CompletedTask;
}
}
// Publish
await eventBus.Publish(new OrderCreated(orderId), ct);
Synchronous Publish (wait for results)
IEventBus.Publish is fire-and-forget: it enqueues the event and returns before any handler runs.
When you need to push an event and wait — run its handlers inline and (optionally) get their
return values back — call SyncPublish on the same IEventBus. It is the multi-handler companion
to IOperation (which is single-handler request/response).
The generic arity of the call decides which handlers run:
| Call | Handlers invoked | Returns |
|---|---|---|
SyncPublish<TEvent>(e) |
IDomainEventHandler<TEvent> |
nothing (await for completion) |
SyncPublish<TEvent, TResult>(e) |
IDomainEventHandler<TEvent, TResult> |
IReadOnlyList<TResult> |
In both cases:
- handlers run inline, sequentially, in registration order, and the call completes only after the last one;
- exceptions propagate to the caller (no background retry / dead-lettering);
- handlers run in a fresh DI scope — like the background processor, the bus is a singleton and cannot
see the caller's scope, so each
SyncPublishresolves handlers (and their scoped dependencies, e.g. aDbContext) in a new scope rather than sharing the caller's; - nothing is written to the bus —
SyncPublishandPublishare independent, so pick one per call.
Wait for completion — no result:
public sealed class AuditOrder : IDomainEventHandler<OrderCreated>
{
public ValueTask Handle(OrderCreated e, CancellationToken ct) { /* ... */ return ValueTask.CompletedTask; }
}
await eventBus.SyncPublish(new OrderCreated(orderId), ct);
// every IDomainEventHandler<OrderCreated> has run; side effects committed, exceptions surfaced
Collect results — supplying the TResult type argument selects the result-returning handler
IDomainEventHandler<TEvent, TResult>, and you get one entry per registered handler:
public record StockCheck(string Sku) : IDomainEvent;
public sealed class WarehouseStock : IDomainEventHandler<StockCheck, bool>
{
public ValueTask<bool> Handle(StockCheck e, CancellationToken ct) => ValueTask.FromResult(/* in stock? */ true);
}
IReadOnlyList<bool> results = await eventBus.SyncPublish<StockCheck, bool>(new StockCheck(sku), ct);
bool available = results.All(x => x);
Handlers are resolved for the concrete
TEventyou pass, so callSyncPublishwith the concrete event type rather than theIDomainEventbase.
Note — idempotency & delivery:
SyncPublishexecutes each handler exactly once, inline, on both transports (it does not go through the Redis queue). Still make result-returning handlers with side effects idempotent, so a caller retry is safe. If the same work is also dispatched through the asynchronousPublishpath, prefer the Redis-backed bus: its durable, competing-consumer delivery ensures a single instance processes each event (at-least-once), which the in-memory bus cannot coordinate across processes.
Redis-Backed Event Bus
For multi-instance deployments:
builder.Services.AddRedisEventBus(options =>
{
options.ConnectionString = "localhost:6379";
options.QueueKey = "domain-events";
options.ProcessingQueueKey = "domain-events:processing"; // base key — each consumer gets its own list
options.ConsumerName = "orders-api-1"; // optional stable identity (default: machine + random GUID)
options.PopTimeoutSeconds = 5;
options.MaxRetryAttempts = 3;
},
processor =>
{
processor.MaxConcurrency = 4; // default 1 = strict in-order processing
processor.RetryDelay = TimeSpan.FromSeconds(2); // linear backoff: delay * (retryCount + 1)
});
Redis mode includes:
- FIFO delivery — events are published with
RPUSHand consumed withBLMOVE … LEFT, so they are processed in publish order - Competing-consumer semantics — each event is delivered to exactly one consumer instance, never broadcast. Services with different handler sets must use different
QueueKeyvalues; an event whose type is not registered in the consuming process is dead-lettered with reasonUnknownType - Reliable per-consumer processing list (
{ProcessingQueueKey}:{ConsumerName}) so concurrent instances never steal each other's in-flight events - Ack on success
- Nack + requeue on failure — retried events are pushed to the back of the queue so a poison message cannot block the queue head
- Crash recovery: on startup each consumer recovers its own processing list (effective when
ConsumerNameis stable across restarts), and a periodic heartbeat-based sweep reclaims the processing lists of dead consumers back onto the main queue. All recovery paths yield at-least-once delivery — handlers should be idempotent
Processor options
Both AddEventBus(...) and AddRedisEventBus(...) accept an optional Action<EventProcessorOptions>:
| Option | Default | Description |
|---|---|---|
MaxConcurrency |
1 |
Number of events processed in parallel. 1 preserves strict ordering. |
RetryDelay |
2s |
Base delay before a failed event is nacked; grows linearly with the retry count. The delay occupies a worker slot. |
Dead Letter Queue
Map admin endpoints for inspecting and reprocessing failed events:
app.MapDeadLetterEndpoints("/admin/dlq");
Provides GET (list), DELETE (remove), and POST (reprocess) endpoints. The group requires
authorization by default; pass requireAuthorization: false only for trusted/internal hosts, and
use the configure hook for custom policies:
app.MapDeadLetterEndpoints(
"/admin/dlq",
requireAuthorization: true,
configure: group => group.RequireAuthorization("AdminsOnly"));
Listed items include the dead-letter Reason (Malformed payload vs UnknownType — see
competing-consumer semantics above) alongside the retry count and raw payload.
Event Bus Health Check
The Redis-backed event bus registers a health check automatically:
| Check | Tags |
|---|---|
eventbus-redis |
eventbus, redis, readiness |
app.MapHealthChecks("/health/ready", new() { Predicate = hc => hc.Tags.Contains("readiness") });
The health check verifies that the Redis connection is available. The in-memory event bus does not register a health check.
Event Bus Observability
Built-in OpenTelemetry instrumentation under the LowCodeHub.MinimalEndpoints.EventBus source:
Metrics
| Metric | Type | Description |
|---|---|---|
eventbus.events.published |
Counter | Domain events published to the bus |
eventbus.events.publish.failed |
Counter | Events that failed to publish |
eventbus.events.processed |
Counter | Events successfully processed by all handlers |
eventbus.events.failed |
Counter | Events where at least one handler failed |
eventbus.events.retried |
Counter | Events requeued for retry |
eventbus.events.deadlettered |
Counter | Events sent to the DLQ after exceeding max retries |
eventbus.events.processing.duration |
Histogram (ms) | Handler processing duration |
Traces
| Activity | Kind | Description |
|---|---|---|
eventbus.publish |
Producer | Publishing a domain event |
eventbus.process |
Consumer | Processing a domain event |
eventbus.dispatch |
Internal | Synchronous in-process dispatch (IEventDispatcher) |
builder.Services.AddOpenTelemetry()
.WithTracing(t => t.AddSource("LowCodeHub.MinimalEndpoints.EventBus"))
.WithMetrics(m => m.AddMeter("LowCodeHub.MinimalEndpoints.EventBus"));
Manual Mapper
A lightweight DI-based mapping system for converting between types without reflection-based mappers. All handlers are auto-discovered from assemblies at startup.
IMapper and Handlers
Define mapping logic by implementing IMapHandler<TSource, TDestination> (sync) or IMapAsyncHandler<TSource, TDestination> (async):
// Sync handler
public sealed class OrderToOrderDtoHandler : IMapHandler<Order, OrderDto>
{
public OrderDto Handler(Order source)
=> new OrderDto(source.Id, source.Name, source.Total);
}
// Async handler (e.g., needs DB lookup)
public sealed class UserToUserDtoHandler : IMapAsyncHandler<User, UserDto>
{
private readonly IPermissionRepository _repo;
public UserToUserDtoHandler(IPermissionRepository repo) => _repo = repo;
public async Task<UserDto> Handler(User source, CancellationToken ct)
{
var permissions = await _repo.GetPermissionsAsync(source.Id, ct);
return new UserDto(source.Id, source.Name, permissions);
}
}
Map handlers are registered by AddManualMapper<TScanner>() or AddManualMapper(...). Inject IMapper
and call Map or MapAsync:
public class OrderService(IMapper mapper)
{
public OrderDto ToDto(Order order)
=> mapper.Map<Order, OrderDto>(order);
public async Task<UserDto> ToDtoAsync(User user, CancellationToken ct)
=> await mapper.MapAsync<User, UserDto>(user, ct);
}
IDualMapper
For bidirectional mapping between two types, implement IDualMapper<TFrom, TTo>:
public sealed class OrderDualMapper : IDualMapper<Order, OrderDto>
{
public OrderDto MapTo(Order from)
=> new OrderDto(from.Id, from.Name, from.Total);
public Order MapFrom(OrderDto to)
=> new Order { Id = to.Id, Name = to.Name, Total = to.Total };
}
Dual mappers are registered by AddDualMappers<TScanner>() or AddDualMappers(...). Inject the specific
IDualMapper<TFrom, TTo> interface:
public class OrderController(IDualMapper<Order, OrderDto> mapper)
{
public OrderDto ToDto(Order order) => mapper.MapTo(order);
public Order FromDto(OrderDto dto) => mapper.MapFrom(dto);
}
Correlation ID
Track requests across services with correlation IDs:
builder.Services.AddCorrelationId(options =>
{
options.HeaderNames = ["X-Correlation-ID", "X-Request-ID"];
options.ResponseHeaderName = "X-Correlation-ID";
});
app.UseCorrelationId();
If no header is present, a new correlation ID is generated. Access it anywhere via CorrelationContext.CorrelationId.
Language Awareness
Reads language from request headers and sets the request culture. Honours full RFC 7231
Accept-Language syntax — comma-separated languages with q-values are ranked correctly,
and an optional allowlist constrains the result to languages your app actually supports.
// Simple form
builder.Services.AddLanguageAwareness(defaultLanguage: "en");
// Full options
builder.Services.AddLanguageAwareness(opts =>
{
opts.DefaultLanguage = "en";
opts.SupportedLanguages = ["en", "ar", "fr"]; // anything else falls back to default
opts.NormalizeToBaseLanguage = true; // ar-EG → ar (default), set false for pt-BR vs pt-PT
opts.HeaderNames = ["Language", "Accept-Language", "X-Culture"];
});
app.UseLanguageAwareness();
Access the current language via LanguageContext.Language (never null — falls back to the
configured default).
The library also registers LanguageRequestCultureProvider, an ASP.NET Core IRequestCultureProvider
that resolves the request culture from the same headers and applies the same allowlist. It plugs
into the .NET localization pipeline when UseJsonLocalization or UseRequestLocalization is used.
Timezone Awareness
Reads the timezone from request headers and stores it in request context. The resolver accepts
both IANA IDs (Africa/Cairo) and Windows IDs (Egypt Standard Time) — and translates between
them — so the same client code works regardless of whether the server runs on Linux or Windows.
Fixed offsets like +03:00 and the literals UTC / GMT / Z are also supported.
// Simple form — uses defaults: ["X-TimeZone", "TimeZone"]
builder.Services.AddTimeZoneAwareness();
// Custom headers
builder.Services.AddTimeZoneAwareness("X-TimeZone", "TimeZone");
// Full options
builder.Services.AddTimeZoneAwareness(opts =>
{
opts.HeaderNames = ["X-TimeZone"];
opts.DefaultZone = TimeZoneInfo.FindSystemTimeZoneById("Africa/Cairo");
});
app.UseTimeZoneAwareness();
Access the current timezone via TimeZoneContext.Zone (or .TimeZone — same thing).
For a higher-level API, inject IRequestClock — it gives you UTC, request-local time, and conversions
all using the request's timezone:
public sealed class CreateOrderHandler(IRequestClock clock) : IOperationHandler<CreateOrderRequest, OrderDto>
{
public Task<ErrorOr<OrderDto>> HandleAsync(CreateOrderRequest req, CancellationToken ct)
{
var localCutoff = clock.Now.Date.AddHours(17); // 5pm in the caller's timezone
// …
}
}
Timezone-aware JSON
One-line wire-up — every DateTime / DateTime? / DateTimeOffset / DateTimeOffset? in your API
response is projected into the caller's timezone:
builder.Services.AddTimeZoneAwareJson();
If you want this to be opt-in per property instead of global, decorate the property with
[TimeZoneAware] and register the resolver:
builder.Services.AddTimeZoneAwareJsonResolver();
public record OrderDto(Guid Id, [property: TimeZoneAware] DateTime CreatedAt);
When deserializing, naive timestamps without an offset are interpreted in the request's timezone
(rather than silently treated as UTC), so a payload of "2026-05-04T17:00:00" from a client in
Africa/Cairo becomes 2026-05-04T17:00:00+03:00 on the server.
JSON Localization
JSON-based IStringLocalizer implementation:
builder.Services.AddJsonLocalization(options =>
{
options.ResourcesPath = "Resources";
});
app.UseJsonLocalization("en", "ar");
Resource files:
Resources/en.jsonResources/ar.json
Supports nested keys with dot notation:
{
"Errors": {
"USER_NOT_FOUND": "User not found"
}
}
Access: localizer["Errors.USER_NOT_FOUND"]
OpenAPI helpers
Document the standard error responses the package's filters and exception handler emit:
app.MapPost("/orders", CreateOrder)
.AddValidator<CreateOrderRequest>()
.WithStandardProblemResponses(); // adds 400, 401, 403, 404, 409, 422, 500
Or pick individual ones:
app.MapGet("/orders/{id}", GetOrder)
.ProducesProblem(404)
.ProducesProblem(401);
Global Exception Handler
Register a ProblemDetails-based global exception handler:
builder.Services.AddDefault500ExceptionHandler();
// …
app.UseExceptionHandler();
Unhandled exceptions are caught and returned as RFC 9457 ProblemDetails with a 500 status code —
no stack-trace leakage in production. The handler routes through IProblemDetailsService, so any
app-wide services.AddProblemDetails(opts => opts.CustomizeProblemDetails = …) callbacks you
register will run on these responses too. The validation filter follows the same path, so your
ProblemDetails customizations apply uniformly.
How It Works
┌─────────────────────────────────────────────────────────┐
│ ASP.NET Core Minimal API Pipeline │
│ │
│ ┌─── Middleware ───────────────────────────────────┐ │
│ │ CorrelationId → Language → TimeZone │ │
│ └──────────────────────────────────────────────────┘ │
│ │
│ ┌─── Endpoint Filters ────────────────────────────┐ │
│ │ Validation Filter → Logging Filter │ │
│ └──────────────────────────────────────────────────┘ │
│ │
│ ┌─── Endpoint Handler ───────────────────────────┐ │
│ │ IOperation.ExecuteAsync(request) │ │
│ │ ↓ │ │
│ │ IOperationHandler<TReq, TResult>.HandleAsync() │ │
│ │ ↓ │ │
│ │ ErrorOr<TResult> │ │
│ │ ↓ │ │
│ │ TypedResults.Ok(model) / Error.ToProblem() │ │
│ └─────────────────────────────────────────────────┘ │
│ │
│ ┌─── Background ─────────────────────────────────┐ │
│ │ EventBus (In-Memory / Redis) │ │
│ │ → IDomainEventHandler<TEvent> │ │
│ └─────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘
MCP — expose API contracts to AI agents
Built in (no extra package): expose selected API contracts as Model Context Protocol (MCP) tools over ASP.NET Core Streamable HTTP. AI agents connect to a single /mcp endpoint behind one API key, discover your APIs by name, and get everything they need to implement calls against them: description, input/output JSON Schemas, HTTP method and route, auth requirements, and optional examples.
The server is contract-only by design. Tool calls return API metadata and never execute application logic — the agent (e.g. a frontend developer's coding assistant) reads the contract and writes code that calls your real HTTP API through the normal user flow. This replaces hand-maintained Postman collections and spec exports for agent-driven backend/frontend handoff: the contract lives on the endpoint itself.
How the call is shaped — method, route, content type, and where every parameter travels — is derived from ASP.NET Core's own endpoint metadata, so it tracks the handler automatically. The payload schemas still come from the TInput/TOutput types you name on EnableMcp; startup verifies that those types can at least address the endpoint's route (see Contract validation).
Built on the official MCP C# SDK v2 (
ModelContextProtocol.AspNetCore2.1+), targeting the 2026-07-28 protocol revision: stateless Streamable HTTP with noinitializehandshake and noMcp-Session-Id, so the endpoint scales across instances without sticky routing.
How to use it
Three steps: register the MCP server, opt endpoints in with EnableMcp, and map the MCP endpoint.
using LowCodeHub.MinimalEndpoints.Mcp.Extensions;
// 1. Register
builder.Services.AddMcpApiCatalog(options =>
{
options.ServerName = "orders-api";
options.ServerTitle = "Orders API";
options.ApiKey = builder.Configuration["Mcp:ApiKey"]; // required — startup fails without it
});
// 2. Opt endpoints in — nothing is exposed by default
app.MapPut("/orders/{orderId:guid}", HandleUpdateOrder)
.EnableMcp<UpdateOrderMcpInput, OrderDto>("orders_update", "Update an order by id.", options =>
{
options.ReadOnly = false;
options.Destructive = false;
options.Idempotent = true;
});
// 3. Map the endpoint
app.MapMcpApiCatalog("/mcp");
TInput carries the values the agent needs — route params, query params, and body data. TOutput is the response shape. Both are turned into JSON Schemas automatically. Where each value goes on the wire is not your job to declare; it comes from the endpoint:
public sealed record UpdateOrderMcpInput(
Guid OrderId,
UpdateOrderBody Body);
public sealed record UpdateOrderBody(
string CustomerName,
IReadOnlyList<UpdateOrderItem> Items);
The endpoint always expects Authorization: Bearer <Mcp:ApiKey>. The key is the only gate: every key holder sees the full catalog.
Connect an MCP client
Any Streamable HTTP MCP client works. For Claude Code:
claude mcp add --transport http orders-api https://api.example.com/mcp \
--header "Authorization: Bearer <your-api-key>"
For clients configured via JSON (mcpServers):
{
"mcpServers": {
"orders-api": {
"type": "http",
"url": "https://api.example.com/mcp",
"headers": { "Authorization": "Bearer <your-api-key>" }
}
}
}
Smoke-test the endpoint without an MCP client:
curl -s https://api.example.com/mcp \
-H "Authorization: Bearer <your-api-key>" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
What agents see
For the tool above, tools/list advertises:
- tool name:
orders_update - description:
Update an order by id. [HTTP PUT /orders/{orderId}, requires authentication] - input schema: generated from
UpdateOrderMcpInput - output schema: generated from
OrderDto - annotations: read-only / destructive / idempotent / open-world hints
_meta["lowcodehub.io/http"]: the same call info as structured data — method, route, content type, auth requirement, and parameter locations
The HTTP suffix is added automatically from the endpoint's route, method, and authorization metadata (attribute-style, inline policies, and the global fallback policy all count), so the agent knows how to invoke the real API — including whether its generated code needs to send user credentials. The route is reported without inline constraints ({orderId}, not {orderId:guid}) because it is a template the agent substitutes into; constraints are reported per parameter instead.
Because _meta carries the machine-readable version, a client generating bindings can read everything it needs off tools/list without one tools/call per endpoint.
Tool calls return contracts
Calling a tool returns the full API contract as structured content:
{
"toolName": "orders_update",
"description": "Update an order by id.",
"execution": "contract_only",
"http": {
"method": "PUT",
"route": "/orders/{orderId}",
"contentType": "application/json",
"requiresAuthentication": true,
"parameters": [
{ "name": "orderId", "in": "path", "required": true, "constraint": "guid" },
{ "name": "body", "in": "body", "required": true, "constraint": null }
]
},
"inputSchema": { "type": "object", "properties": { } },
"outputSchema": { "type": "object", "properties": { } },
"errorSchema": { "type": "object", "properties": { } },
"requestExample": { },
"responseExample": { },
"errorResponseExample": { }
}
The agent reads it; your deterministic frontend code makes the real HTTP call. Nothing the agent does through MCP can touch your data.
Parameter locations
http.parameters tells the agent where each value belongs, using OpenAPI's in vocabulary — path, query, header, body, form. Without it an agent has to guess from the route template, and it will get query parameters wrong.
These are derived from the endpoint, not declared by you:
| Source | Used for |
|---|---|
[FromRoute] / [FromQuery] / [FromHeader] / [FromForm] / [FromBody] |
Explicit location, including the wire name when the attribute renames it ([FromHeader(Name = "X-Tenant-Id")]) |
| The route pattern | Any parameter whose name matches a route token → path, plus its constraint |
IAcceptsMetadata |
The type minimal APIs inferred as the request body → body |
| Everything else | query |
Framework-bound parameters — HttpContext, CancellationToken, ClaimsPrincipal, [FromServices] values — are omitted, since the agent neither can nor should supply them.
The one case derivation cannot see is a type with a custom BindAsync, which minimal APIs expose no binding source for; it defaults to query. Override it when that is wrong:
app.MapGet("/orders/search", (SearchCriteria criteria) => /* custom BindAsync */)
.EnableMcp<SearchOrdersMcpInput, OrderDto[]>("orders_search", "Search orders.", options =>
{
options.ParameterLocations["criteria"] = McpParameterLocation.Header;
});
Contract validation
At startup, every MCP-enabled endpoint is checked to confirm that its TInput type declares a property for each of the endpoint's route tokens. A missing one is provable drift — the agent gets a route template with a hole it has no value to fill, so every call it generates is wrong — and it fails the host with the tool name, the route, and the missing parameters:
MCP tool 'widgets_get' describes GET /api/widgets/{widgetId}, but its input type
'WidgetInput' declares no property for the route parameter(s) widgetId. An agent
cannot build the request URL from this contract.
Set ValidateContractParameters = false to skip the check.
Catalog caching
An API catalog changes when you redeploy, not per agent session, so tools/list advertises the MCP 2026-07-28 cache hints (ttlMs + cacheScope). Clients keep their copy instead of re-fetching it, which matters most for large catalogs.
builder.Services.AddMcpApiCatalog(options =>
{
options.CatalogCacheTimeToLive = TimeSpan.FromMinutes(5); // default; null disables the hint
options.CatalogCacheScope = CacheScope.Public; // default
});
Public is accurate here because the API key is the only gate and every key holder sees the identical catalog.
The trade-off is discovery latency: an endpoint added at runtime by a dynamic EndpointDataSource stays invisible to clients holding a cached list until it expires. Stateless servers cannot push notifications/tools/list_changed at all, so shortening the TTL is the only lever — which is why this package does not advertise the listChanged capability.
Explorer mode
With many APIs, advertising one tool per endpoint bloats the agent's context — every tool definition is sent to the model on each request. Explorer mode keeps the context cost constant no matter how large the catalog grows:
builder.Services.AddMcpApiCatalog(options =>
{
options.ExposureMode = McpToolExposureMode.Explorer;
});
Instead of N tools, the agent sees a fixed surface:
| Meta-tool | Purpose |
|---|---|
search_apis(query?) |
Search the catalog by intent or keyword (all terms matched against name/title/description, case-insensitive). Returns name, description, and HTTP info per match, capped at ListToolsPageSize with a truncated flag. No query lists everything. |
describe_api(name) |
Returns the full contract — schemas, HTTP info, examples. |
get_auth_flow() |
Returns the authentication guide (see below). Also present in IndividualTools mode. |
The agent's flow becomes: search by intent → read one contract → implement the HTTP call. Endpoints keep using EnableMcp exactly as before — only the MCP surface changes.
Request/response examples
Concrete examples significantly improve the accuracy of agent-generated calls:
.EnableMcp<UpdateOrderMcpInput, OrderDto>("orders_update", "Update an order by id.", options =>
{
options.RequestExample = new
{
orderId = "0d6f3c1e-58a7-4f9f-9f3a-2f6f0e9a3b21",
body = new { customerName = "Acme", items = new[] { new { sku = "A-100", quantity = 2 } } }
};
options.ResponseExample = new { id = "0d6f3c1e-58a7-4f9f-9f3a-2f6f0e9a3b21", status = "Updated" };
});
Error contracts
Agents implement better failure handling when the contract describes errors, not just the happy path. ErrorResponseType is turned into an errorSchema JSON Schema, and ErrorResponseExample ships a concrete failure payload:
.EnableMcp<CreateOrderMcpInput, OrderDto>("orders_create", "Create a new customer order.", options =>
{
options.ErrorResponseType = typeof(HttpValidationProblemDetails);
options.ErrorResponseExample = new
{
title = "One or more validation errors occurred.",
status = 400,
errors = new Dictionary<string, string[]>
{
["customerName"] = ["Customer name is required."]
}
};
});
Both appear in the contract (errorSchema / errorResponseExample) and are omitted as null when not configured. If all your APIs share one error shape (e.g. RFC 7807 ProblemDetails), setting these on your most-used endpoints and mentioning the convention in ServerInstructions covers the rest.
Authentication guidance for agents
The contract's requiresAuthentication flag tells the agent that credentials are needed, not how to get them. For that, every server exposes a fixed get_auth_flow tool — in both exposure modes — that returns your authentication guide as markdown. Because it is an MCP tool, it works in every agent environment, including sandboxes that allow MCP tool calls but no arbitrary HTTP requests.
Point it at your guide file:
builder.Services.AddMcpApiCatalog(options =>
{
options.AuthFlowDocumentPath = "docs/auth-flow.md"; // read per call; missing file fails at startup
// or inline: options.AuthFlowDocument = "...markdown..."; (takes precedence)
});
When neither is set, a built-in template is served — clearly marked as a placeholder — covering the standard sections: token endpoint, header format, refresh, roles, test users, and 401/403 semantics. Fill those in for your auth provider and the loop closes.
The default ServerInstructions already tell agents to call get_auth_flow before implementing any API with requiresAuthentication=true, so the flow is automatic: the agent reads a contract, sees the flag, calls the tool, and generates frontend code that authenticates correctly — down to role-specific test users for local verification.
get_auth_flow, search_apis, and describe_api are reserved names — EnableMcp rejects them for your own endpoints.
Example docs/auth-flow.md
A filled-in guide for a Keycloak-protected API looks like this — concrete URLs, real role names, and dev-only test users an agent can use to verify its generated code:
# Orders API — Authentication Flow
All endpoints whose MCP contract reports `requiresAuthentication: true`
expect this header on every request:
Authorization: Bearer <access_token>
## 1. Obtain a token
POST https://auth.example.com/realms/orders/protocol/openid-connect/token
Content-Type: application/x-www-form-urlencoded
grant_type=password&client_id=orders-frontend&username=<USER>&password=<PASS>
The JSON response contains `access_token` (expires in 300 seconds) and `refresh_token`.
## 2. Refresh
Before the access token expires, POST the same endpoint with:
grant_type=refresh_token&client_id=orders-frontend&refresh_token=<REFRESH_TOKEN>
## 3. Roles
| Role | Can call |
|----------------|-------------------------------------------|
| `customer` | orders_list, orders_get, orders_create |
| `orders-admin` | everything, including orders_delete |
Test users (development environment only):
| Role | Username | Password |
|----------------|-----------------|------------|
| `customer` | `test-customer` | `Test123!` |
| `orders-admin` | `test-admin` | `Test123!` |
## 4. Error semantics
- `401 Unauthorized` — token missing, expired, or invalid: refresh, then re-authenticate.
- `403 Forbidden` — token valid but lacks the required role; do not retry with the same user.
## 5. Frontend convention
Use one shared API client (`src/lib/api-client.ts`): it attaches the header,
refreshes on 401, and retries once. Do not hand-roll fetch calls per page.
Wire it up and every agent gets it through get_auth_flow:
options.AuthFlowDocumentPath = "docs/auth-flow.md";
MCP observability
Tool calls are measured through a System.Diagnostics.Metrics meter named LowCodeHub.MinimalEndpoints.Mcp — subscribe to it with OpenTelemetry (AddMeter("LowCodeHub.MinimalEndpoints.Mcp")) or dotnet-counters:
| Instrument | Type | Tags |
|---|---|---|
lowcodehub.mcp.tool_calls |
counter | mcp.tool.name, mcp.tool.outcome (success/error) |
lowcodehub.mcp.tool_call.duration |
histogram (seconds) | mcp.tool.name, mcp.tool.outcome |
Paging and catalog lifecycle
tools/list is paginated via the standard MCP cursor. Page size defaults to 100 and is configurable with LowCodeHubMcpOptions.ListToolsPageSize. The catalog is ordered by tool name and cursors are name-based, so pagination stays stable even if the catalog changes between pages.
The tool catalog is built once and cached with an indexed name lookup. It is invalidated automatically when endpoint data sources change (e.g. dynamically added endpoints). Duplicate tool names fail fast at application startup instead of on the first agent request.
MCP security notes
LowCodeHubMcpOptions.ApiKey is required, and MapMcpApiCatalog always applies the package's bearer API-key authorization policy (constant-time key comparison).
The server only ever returns API metadata — schemas, routes, descriptions, examples. It never executes your endpoints, so a leaked key exposes your API shapes, not your data. Still, treat the key as a credential: prefer distributing it to developers/tools, not embedding it in shipped frontends.
MCP tool annotations and the requiresAuthentication flag are hints for clients. They do not replace server-side authorization on your real HTTP APIs — the agent-generated code authenticates against your API exactly like any other client.
MCP options reference
| Option | Default | Purpose |
|---|---|---|
ServerName / ServerTitle / ServerVersion |
lowcodehub-minimalendpoints |
MCP server identity reported to clients. |
ServerDescription / ServerWebsiteUrl / ServerIcons |
— | Optional identity details shown by clients that present a server picker. |
ServerInstructions |
derived from exposure mode | Instructions shown to MCP clients. When null, a contract-only default matching ExposureMode is generated. |
ApiKey |
— (required) | Bearer API key protecting the MCP endpoint. Startup fails when missing. |
AuthFlowDocumentPath |
— | Location of the markdown file returned by get_auth_flow (read per call; missing file fails at startup). |
AuthFlowDocument |
built-in template | Inline markdown returned by get_auth_flow; takes precedence over the path. |
ExposureMode |
IndividualTools |
One tool per endpoint, or the fixed search_apis/describe_api/get_auth_flow explorer surface. |
ListToolsPageSize |
100 |
Tools per tools/list page (and max search_apis results). |
CatalogCacheTimeToLive |
5 minutes |
How long clients may cache the tool list. null advertises no hint. |
CatalogCacheScope |
Public |
Whether a cached tool list may be shared between callers. |
ValidateContractParameters |
true |
Fail startup when a tool's input type cannot address its endpoint's route. |
SerializerOptions / SchemaExporterOptions |
web defaults | JSON and JSON Schema generation settings. |
Streamable HTTP is stateless, per the 2026-07-28 protocol revision. The Stateless option was removed in 0.1.0 — v2 makes it the default and marks every stateful transport knob obsolete. If you need to reach one anyway, AddMcpApiCatalog still takes a configureTransport callback onto HttpServerTransportOptions.
Requirements
- .NET 10 or later
ErrorOr2.0+ (included as a dependency)StackExchange.Redis2.12+ (included — required for Redis event bus)ModelContextProtocol.AspNetCore2.1+ (included — required for the MCP catalog)
License
MIT © Ahmed Abuelnour
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- ErrorOr (>= 2.1.1)
- ModelContextProtocol.AspNetCore (>= 2.1.0)
- StackExchange.Redis (>= 3.0.11)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.