LowCodeHub.MinimalEndpoints 0.2.1

dotnet add package LowCodeHub.MinimalEndpoints --version 0.2.1
                    
NuGet\Install-Package LowCodeHub.MinimalEndpoints -Version 0.2.1
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="LowCodeHub.MinimalEndpoints" Version="0.2.1" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="LowCodeHub.MinimalEndpoints" Version="0.2.1" />
                    
Directory.Packages.props
<PackageReference Include="LowCodeHub.MinimalEndpoints" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add LowCodeHub.MinimalEndpoints --version 0.2.1
                    
#r "nuget: LowCodeHub.MinimalEndpoints, 0.2.1"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package LowCodeHub.MinimalEndpoints@0.2.1
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=LowCodeHub.MinimalEndpoints&version=0.2.1
                    
Install as a Cake Addin
#tool nuget:?package=LowCodeHub.MinimalEndpoints&version=0.2.1
                    
Install as a Cake Tool

LowCodeHub.MinimalEndpoints

Helpers for ASP.NET Core Minimal APIs that build on the native .NET 11 stack instead of replacing it: modular endpoint registration, a CQRS-lite IOperation dispatcher over Outcome<T> (from LowCodeHub.Outcomes), Outcome<T>.ToHttpResult() → native ProblemDetails, a JSON-file IStringLocalizer for the native request-localization pipeline, timezone awareness, correlation IDs, an in-memory/Redis domain event bus, and contract-only MCP integration.

Validation (sync + async, localized messages), ProblemDetails, exception handling, request logging and culture negotiation are the framework's own (AddValidation, AddProblemDetails, UseExceptionHandler, AddHttpLogging, UseRequestLocalization) — the package only adds glue where it adds value.

Requires net11.0. The package targets .NET 11 only (async validation and built-in validation-message localization are .NET 11 features).

NuGet License: MIT

Installation

dotnet add package LowCodeHub.MinimalEndpoints

Quick Start

using LowCodeHub.MinimalEndpoints.Extensions;

builder.Services.AddMinimalEndpoints();   // IOperation handlers + mappers in this assembly, AddProblemDetails()
builder.Services.AddValidation();         // native validation — call it in YOUR project (see below)
builder.Services.AddEventBus();           // optional: in-memory bus + IDomainEventHandler<T>s in this assembly

var app = builder.Build();

app.UseExceptionHandler();                // native: unhandled exceptions → 500 ProblemDetails
app.MapModules();                         // every IModule in this assembly

app.Run();
public sealed record CreateOrder([property: Required, StringLength(100)] string Name,
                                 [property: Range(1, 1000)] int Quantity) : IOperationRequest<OrderDto>;

public sealed class OrdersModule : IModule
{
    public static void AddRoutes(IEndpointRouteBuilder app)
    {
        var orders = app.MapGroup("/orders").WithStandardProblemResponses();

        // Invalid bodies never reach the handler: native validation answers 400 ValidationProblemDetails.
        orders.MapPost("/", async (CreateOrder request, IOperation op, CancellationToken ct) =>
        {
            Outcome<OrderDto> result = await op.ExecuteAsync(request, ct);
            return result.ToHttpResult();
        });
    }
}

Need more? Each piece is opt-in:

// Multi-instance: Redis-backed event bus (handlers from this assembly)
builder.Services.AddRedisEventBus("localhost:6379");
// or bound from the "RedisEventBus" configuration section
builder.Services.AddRedisEventBus(builder.Configuration);

// Features spread over several projects: pass assemblies (or a marker type) explicitly
builder.Services.AddMinimalEndpoints(typeof(UsersModule).Assembly, typeof(OrdersModule).Assembly);
builder.Services.AddEventBus(typeof(UsersModule).Assembly, typeof(OrdersModule).Assembly);
app.MapModules(typeof(UsersModule).Assembly, typeof(OrdersModule).Assembly);

// Localization: JSON files + the native request-localization middleware
builder.Services.AddJsonLocalization();                        // Resources/{culture}.json
builder.Services.AddRequestLocalization(o => o
    .SetDefaultCulture("en")
    .AddSupportedCultures("en", "ar")
    .AddSupportedUICultures("en", "ar")
    .AddHeaderCultureProvider("Language"));                    // optional custom header
app.UseRequestLocalization();

// Request context
builder.Services.AddCorrelationId();
builder.Services.AddTimeZoneAwareness();
builder.Services.AddTimeZoneAwareJson();         // every DateTime[Offset] projects into the caller's TZ
app.UseCorrelationId();
app.UseTimeZoneAwareness();

The individual registrations (AddOperations, AddManualMapper, AddDualMappers) remain available with <TScanner> and params Assembly[] overloads where they scan. All options are validated when the host starts (ValidateOnStart), so misconfiguration fails loudly.

Trimming / Native AOT: discovery (AddMinimalEndpoints, AddEventBus, MapModules, ...), the Redis envelope serializer and the event processor use reflection, and are annotated with [RequiresUnreferencedCode]. They are not trimming-safe. Native validation itself is source-generated.

What is native and what is this package

Concern Use Package role
Request validation AddValidation() (source-generated, sync + async rules) MCP contracts publish the same DataAnnotations
Validation-message localization AddValidation() + any IStringLocalizerFactory AddJsonLocalization() plugs JSON files in as that factory
Error responses AddProblemDetails() + IProblemDetailsService Outcome<T>.ToHttpResult() renders problems through native TypedResults.Problem; AddMinimalEndpoints() also registers AddProblemDetailsLocalization()
Unhandled exceptions UseExceptionHandler() / developer exception page —
Request logging AddHttpLogging() / UseHttpLogging(), DisableHttpLogging() Correlation ID is pushed into the logging scope
Culture negotiation AddRequestLocalization() + UseRequestLocalization() AddHeaderCultureProvider(...) (custom header)
Endpoint grouping MapGroup, endpoint filters, OpenAPI Produces* IModule / IMinimalEndpoint discovery, WithStandardProblemResponses()
Tracing Activity / W3C trace context (OTel semantic attributes are native in .NET 11) Correlation ID defaults to the W3C trace id
Timezones, CQRS-lite, mappers, event bus, MCP no native equivalent provided

Table of Contents


Modular Endpoint Registration

Define modules implementing IModule. Implementations are mapped by the reflection scanner using app.MapModules(), app.MapModules<TScanner>() or app.MapModules(...).

public sealed class OrdersModule : IModule
{
    public static void AddRoutes(IEndpointRouteBuilder app)
    {
        var group = app.MapGroup("/orders");

        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("/", () => TypedResults.Ok());
    }
}

public sealed class CreateOrderEndpoint : IMinimalEndpoint
{
    public static void AddRoute(IEndpointRouteBuilder app)
    {
        app.MapPost("/", (CreateOrderRequest request) => TypedResults.Created());   // validated natively
    }
}

Validation (native)

The package has no validation framework of its own: it uses the built-in Minimal API validation (Microsoft.Extensions.Validation, part of the Microsoft.AspNetCore.App shared framework — no extra package). Anything DataAnnotations can express works — including async rules — and failures become a native 400 HttpValidationProblemDetails written through IProblemDetailsService.

builder.Services.AddValidation();   // in the project that contains the MapGet/MapPost calls

public sealed record CreateOrder(
    [property: Required, StringLength(100)] string? Name,
    [property: Range(1, 1000)] int Quantity,
    [property: EmailAddress] string? ContactEmail,
    Address? ShipTo,                      // nested objects are validated ("ShipTo.City")
    List<OrderLine>? Lines)               // so are collection items ("Lines[0].Sku")
    : IValidatableObject                  // cross-field rules — run once property rules pass
{
    public IEnumerable<ValidationResult> Validate(ValidationContext context)
    {
        if (Lines is { Count: 0 })
            yield return new ValidationResult("At least one line is required.", [nameof(Lines)]);
    }
}

// Custom rules are plain ValidationAttributes (services are available via context.GetService)
public sealed class NotReservedAttribute : ValidationAttribute
{
    protected override ValidationResult? IsValid(object? value, ValidationContext context)
        => value is "admin" ? new ValidationResult($"{context.DisplayName} is reserved.", [context.MemberName!])
                            : ValidationResult.Success;
}
{
  "type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
  "title": "One or more validation errors occurred.",
  "status": 400,
  "errors": { "Name": ["The Name field is required."], "ShipTo.City": ["The City field is required."] },
  "traceId": "00-…"
}

Endpoint parameters are validated too, including nullable value types (([Range(1, 50)] int? size) => …).

Async rules (uniqueness, existence, remote checks)

Derive from AsyncValidationAttribute for a property/parameter rule, or implement IAsyncValidatableObject for a whole-object rule. Minimal APIs always run the async path, so the required sync member can simply throw. Resolve services from the ValidationContext and pass the cancellation token on. Async rules may run in parallel — don't depend on ordering. Sync attributes on the same model still run alongside, and all errors are aggregated into one response.

public sealed class UniqueEmailAttribute : AsyncValidationAttribute
{
    public UniqueEmailAttribute() => ErrorMessage = "{0} is already registered.";

    protected override async Task<ValidationResult?> IsValidAsync(object? value, ValidationContext context, CancellationToken ct)
    {
        if (value is not string email || string.IsNullOrWhiteSpace(email)) return ValidationResult.Success;   // [Required]'s job

        var users = context.GetRequiredService<IUserDirectory>();
        return await users.EmailExistsAsync(email, ct)
            ? new ValidationResult(FormatErrorMessage(context.DisplayName), [context.MemberName!])
            : ValidationResult.Success;
    }

    protected override ValidationResult? IsValid(object? value, ValidationContext context)
        => throw new InvalidOperationException("Synchronous validation isn't supported.");
}

public sealed class RegisterUser : IAsyncValidatableObject
{
    [Required, StringLength(10)] public string? UserName { get; set; }
    [Required, EmailAddress, UniqueEmail] public string? Email { get; set; }
    public string? InviteCode { get; set; }

    // Runs only after every property rule passed.
    public async IAsyncEnumerable<ValidationResult> ValidateAsync(ValidationContext context,
        [EnumeratorCancellation] CancellationToken ct = default)
    {
        if (InviteCode is not null && !await context.GetRequiredService<IUserDirectory>().InviteIsValidAsync(InviteCode, ct))
            yield return new ValidationResult("Invite code is not valid.", [nameof(InviteCode)]);
    }

    public IEnumerable<ValidationResult> Validate(ValidationContext context)
        => throw new InvalidOperationException("Synchronous validation isn't supported.");
}

users.MapPost("/", (RegisterUser request) => …);                                              // body
users.MapGet("/available", ([Required, EmailAddress, UniqueEmail] string email) => …);        // parameter

Rules that need the operation's own state (e.g. "order already shipped") still belong in the operation handler: return Problems.BadRequest(...) / Problems.Conflict(...) and ToHttpResult() renders them.

Localized validation messages

Native: whenever an IStringLocalizerFactory is registered, AddValidation() localizes messages for the request's CurrentUICulture (set by UseRequestLocalization()), with {0} = the (localized) display name and {1}, {2}… = the attribute's arguments. Lookup keys, first match wins:

  1. the attribute's ErrorMessage, used as a key ([Required(ErrorMessage = "Validation.NameRequired")]);
  2. {DeclaringType}_{Member}_{Attribute}_Error, e.g. RegisterUser_UserName_StringLengthAttribute_Error;
  3. {DeclaringType}_{Attribute}_Error;
  4. {Attribute}_Error, e.g. RequiredAttribute_Error — translates a built-in message app-wide.

No match → the attribute's built-in (English) message. [Display(Name = "UserName")] is looked up as a key too.

JSON files (this package): AddJsonLocalization() registers the JSON-backed factory, so the same Resources/{culture}.json files serve validation, problem detail localization and your own IStringLocalizer use:

builder.Services.AddJsonLocalization();   // Resources/{culture}.json — replaces any resx factory
builder.Services.AddValidation();
// Resources/ar.json
{
  "UserName": "اسم المستخدم",
  "RequiredAttribute_Error": "حقل {0} مطلوب",
  "RangeAttribute_Error": "{0} يجب أن يكون بين {1} و {2}",
  "RegisterUser_UserName_StringLengthAttribute_Error": "{0} طويل جدًا (الحد {1})",
  "Validation": { "NameRequired": "الاسم مطلوب" }
}

The JSON factory maps every resource type to the same files, so ValidationOptions.LocalizerProvider is not needed. Resx instead: use the native builder.Services.AddLocalization(o => o.ResourcesPath = "Resources") (per-type .resx files, or one shared file via AddValidation(o => o.LocalizerProvider = (_, f) => f.Create(typeof(ValidationMessages)))) and don't call AddJsonLocalization(). Attributes using ErrorMessageResourceType localize themselves either way.

How the source generator finds your types

AddValidation() is intercepted at compile time by the framework's ValidationsGenerator; it registers metadata for the parameter types of the endpoints mapped in the same project (types from referenced projects are fine as parameters) and for types marked [ValidatableType]. Consequences:

  • Call builder.Services.AddValidation() yourself — AddMinimalEndpoints() cannot do it for you, because a call inside this package's compiled code only sees this package's types.
  • If endpoints are mapped in several projects (e.g. one class library per module), call AddValidation() once in each of those projects (for example inside the module's own AddXxx() extension). Every call adds that project's resolver; they compose. A plain Microsoft.NET.Sdk class library needs a Microsoft.Extensions.Validation package reference for the generator to run there.
  • Opt out per endpoint with .DisableValidation(), per type/property/parameter with [SkipValidation].
  • Analyzers ASP0033–ASP0038 flag inaccessible types/properties and [ValidatableType] without AddValidation().

Operations (CQRS-Lite)

The operations pattern provides a structured way to define units of work that return Outcome<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<Outcome<OrderDto>> HandleAsync(
        CreateOrderRequest request, CancellationToken ct)
    {
        if (await repo.ExistsAsync(request.Name, ct))
            return Problems.Conflict("Order.Duplicate", "Order already exists");

        var order = await repo.CreateAsync(request, ct);
        return new OrderDto(order.Id, order.Name);
    }
}

Operation handlers (and the IOperation dispatcher) are registered by AddMinimalEndpoints() or AddOperations(...).

Use in Endpoints

app.MapPost("/orders", async (CreateOrderRequest req, IOperation op, CancellationToken ct) =>
{
    var result = await op.ExecuteAsync(req, ct);

    // Value -> 201 Created; problem -> RFC 9457 ProblemDetails (status from the problem)
    return result.ToHttpResult(order => TypedResults.Created($"/orders/{order.Id}", order));
});

Chain Operations

Short-circuit on the first problem using Bind:

public record SendConfirmationRequest(int OrderId) : IOperationRequest<Success>;

app.MapPost("/orders", async (CreateOrderRequest req, IOperation op, CancellationToken ct) =>
{
    Outcome<Success> result = await op.ExecuteAsync(req, ct)
        .Bind(order => op.ExecuteAsync(new SendConfirmationRequest(order.Id), ct));

    return result.ToHttpResult();   // 204 on success, problem otherwise
});

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), and TypedResults.NoContent() when you need a specific status.
  • Failure → ProblemDetails (RFC 9457) — return Outcome<T> from handlers and call ToHttpResult(); build problems with Problems.NotFound(...), Problems.Conflict(...), Problems.BadRequest(...), Problems.Unauthorized(...), Problems.Forbidden(...), Problems.Failure(...) (422), Problems.Unexpected(...) (500), Problems.TooManyRequests(...).
// Handler
return Problems.NotFound("Order.NotFound", $"No such order {id}", id);   // detail is the formatted fallback; args feed the localized template

// Endpoint
outcome.ToHttpResult();                                            // value -> 200, problem -> ProblemDetails
outcomeOfSuccess.ToHttpResult();                                   // Outcome<Success> -> 204 / problem
outcome.ToHttpResult(v => TypedResults.Created($"/orders/{v.Id}", v));
problem.ToHttpResult();                                            // a bare ProblemDetails
await op.ExecuteAsync(req, ct).ToHttpResult();                     // Task<Outcome<T>>

Problems are written through native TypedResults.Problem(...) / IProblemDetailsService, so they get the framework's default type, title and traceId, run your CustomizeProblemDetails callback, and carry an errorCode extension. AddMinimalEndpoints() registers AddProblemDetailsLocalization(): detail is looked up by errorCode (with the problem's args as format arguments), falls back to the original detail, and the args are stripped from the response. The localizer is the registered non-generic IStringLocalizer (AddJsonLocalization() registers one), or — e.g. for resx — whatever LocalizerProvider returns:

builder.Services.AddProblemDetailsLocalization(o => o.LocalizerProvider = f => f.Create(typeof(ErrorMessages)));
{ "type": "https://tools.ietf.org/html/rfc9110#section-15.5.5", "title": "Not Found", "status": 404,
  "detail": "No such order", "errorCode": "Order.NotFound", "traceId": "00-…" }

Request validation is unchanged: it stays the native AddValidation() pipeline (400 ValidationProblemDetails).


Domain Event Bus

In-Memory Event Bus

builder.Services.AddEventBus();                       // handlers from the calling assembly
builder.Services.AddEventBus<OrdersModule>();          // ...or from a marker type's assembly
builder.Services.AddEventBus(p => p.MaxConcurrency = 4, typeof(OrdersModule).Assembly);
  • Bounded channel (10,000 events) — Publish awaits when it is full
  • Hosted background processor; events still buffered at shutdown are lost (use Redis for durability)
  • An event with no registered handler is dropped with a one-time warning and the eventbus.events.unhandled metric
// 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 SyncPublish resolves handlers (and their scoped dependencies, e.g. a DbContext) in a new scope rather than sharing the caller's;
  • nothing is written to the bus — SyncPublish and Publish are 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 TEvent you pass, so call SyncPublish with the concrete event type rather than the IDomainEvent base.

Note — idempotency & delivery: SyncPublish executes 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 asynchronous Publish path, 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. Requires Redis 6.2+ (BLMOVE/LMOVE) on a standalone, replica or Sentinel deployment — Redis Cluster is not supported.

builder.Services.AddRedisEventBus("localhost:6379");     // defaults + handlers from the calling assembly

builder.Services.AddRedisEventBus(options =>
{
    options.ConnectionString = "localhost:6379";
    options.QueueKey = "orders-events";                  // one queue per service (see below)
    options.ConsumerName = Environment.MachineName;      // stable identity → own-list recovery on restart
    options.MaxRetryAttempts = 3;
},
processor =>
{
    processor.MaxConcurrency = 4;                        // default 1 = strict in-order processing
    processor.RetryDelay = TimeSpan.FromSeconds(2);      // linear backoff: delay * (retryCount + 1)
});

// From configuration ("RedisEventBus" section), handlers from explicit assemblies
builder.Services.AddRedisEventBus(builder.Configuration, configureProcessor: null, typeof(OrdersModule).Assembly);

Connection precedence: ConnectionMultiplexer option → ConnectionString (the bus creates and owns its connection) → an IConnectionMultiplexer already registered in DI. The bus never registers an IConnectionMultiplexer itself. Connections use abortConnect=false and connect lazily, so the app starts while Redis is down and reconnects in the background.

Semantics:

  • FIFO delivery — RPUSH + BLMOVE … LEFT
  • Competing consumers — each event goes to exactly one instance, never broadcast. Services with different handler sets must use different QueueKeys; an event whose type is unknown to the consuming process is dead-lettered with reason UnknownType
  • Per-consumer processing list {ProcessingQueueKey}:{ConsumerName} (ProcessingQueueKey defaults to {QueueKey}:processing)
  • Ack removes the event only once Redis confirms; nack requeues (to the back of the queue) or dead-letters in a single MULTI/EXEC transaction; ack/nack are retried briefly on transient errors
  • Crash recovery: each consumer recovers its own processing list on startup (with a stable ConsumerName); a background heartbeat (refreshed every HeartbeatTtl / 4, independent of handler duration) plus a periodic sweep reclaims the lists of dead consumers. The sweep reads a consumer registry set (no keyspace SCAN) and only one instance sweeps per interval
  • Consume-loop failures (connection loss, unsupported command, auth) are logged (rate-limited, with an actionable hint), counted in eventbus.consumer.errors, retried with exponential backoff, and surfaced by the health check
  • Everything is at-least-once — make handlers idempotent
Options
Option Default Description
ConnectionString — Redis connection string (see precedence above).
ConnectionMultiplexer — Use an existing multiplexer (not disposed by the bus).
QueueKey domain-events Queue list key. Dead letters live at {QueueKey}:deadletter.
ProcessingQueueKey {QueueKey}:processing Base key of the per-consumer processing lists.
ConsumerName {Machine}-{Guid} Consumer identity.
PopTimeoutSeconds 5 Blocking pop timeout (1–300).
MaxRetryAttempts 5 Retries before dead-lettering; 0 dead-letters on the first failure.
RecoverProcessingOnStartup true Requeue this consumer's stranded events on start.
HeartbeatTtl 2 min A consumer without a heartbeat for this long is considered dead.
StaleConsumerSweepInterval 1 min How often dead consumers are reclaimed.
UnhealthyAfterConsecutiveFailures 3 Consume failures before the health check turns Unhealthy (Degraded before).
Processor options

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. They expose raw payloads (which may contain personal data), so they require an explicit admin policy — a merely authenticated user is not enough. Mapping fails at startup if the policy is not defined.

builder.Services.AddAuthorizationBuilder()
    .AddPolicy(DeadLetterEndpointExtensions.DefaultPolicy, p => p.RequireRole("ops")); // "LowCodeHub.DeadLetterAdmin"

app.MapDeadLetterEndpoints();                                   // /admin/deadletter
app.MapDeadLetterEndpoints("/admin/dlq", policy: "AdminsOnly"); // or your own policy
Endpoint Description
GET / ?maxResults=50 { totalCount, items: [{ eventType, retryCount, reason, rawPayload }] } — oldest first; reason is MaxRetriesExceeded, UnknownType or Malformed
POST /reprocess ?count=10 Requeues up to count events (oldest first) with the retry count reset → { reprocessedCount, skippedCount }. Each item is moved atomically (LMOVE) into a staging list and committed with MULTI/EXEC; undeserializable items stay dead-lettered; an interrupted run is recovered by the next call — nothing is lost
DELETE / Purges the dead-letter queue → { purgedCount }

Event Bus Health Check

The Redis-backed event bus registers one readiness check:

Check Tags
eventbus-redis eventbus, redis, readiness
app.MapHealthChecks("/health/ready", new() { Predicate = hc => hc.Tags.Contains("readiness") });

It PINGs Redis and reports queueDepth, processingDepth, deadLetterDepth, latency and the consumer loop's state (consumerRunning, consecutiveConsumerFailures, lastError, lastPollAt). Status is Unhealthy when Redis is unreachable or the consumer has failed UnhealthyAfterConsecutiveFailures times in a row, Degraded while it is retrying. The in-memory bus registers no 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 (max retries exceeded, unknown type, malformed)
eventbus.events.unhandled Counter Consumed events with no registered handler
eventbus.consumer.errors Counter Failed Redis consume-loop iterations, tagged eventbus.error.kind
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 (IEventBus.SyncPublish)
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

Accept a caller-supplied correlation ID, echo it back, and attach it to logs and traces:

builder.Services.AddCorrelationId(options =>
{
    options.HeaderNames = ["X-Correlation-ID", "X-Request-ID"];   // checked in order
    options.ResponseHeaderName = "X-Correlation-ID";
});

app.UseCorrelationId();

If no header is present — or the incoming value is longer than MaxLength (default 128) or contains characters other than letters, digits and -_.:/+=@ — the request's W3C trace id (Activity.Current) is used instead, so the header, the logs and the distributed trace share one id. The middleware opens a logging scope with CorrelationId, tags the activity with correlation.id, and exposes the value via CorrelationContext.CorrelationId.


Localization

Culture negotiation is the native RequestLocalizationMiddleware; it sets CultureInfo.CurrentCulture / CurrentUICulture for the request (honouring q-values, the supported-culture allowlist and parent-culture fallback such as ar-EG → ar). The package adds a JSON-file IStringLocalizer and an optional custom-header culture provider:

builder.Services.AddJsonLocalization();                     // Resources/{culture}.json
// or: builder.Services.AddJsonLocalization(o => o.ResourcesPath = "i18n");

builder.Services.AddRequestLocalization(o => o
    .SetDefaultCulture("en")
    .AddSupportedCultures("en", "ar")
    .AddSupportedUICultures("en", "ar")
    .AddHeaderCultureProvider("Language", "X-Culture"));    // optional, checked before ?culture=, cookie, Accept-Language

var app = builder.Build();
app.UseRequestLocalization();

Resource files live under the content root (Resources/en.json, Resources/ar.json, optionally region files such as Resources/ar-EG.json — lookups walk the parent-culture chain like resx). Nested objects are addressed with dot-notation:

{ "Errors": { "USER_NOT_FOUND": "User not found" } }
app.MapGet("/hello", (IStringLocalizer localizer) => localizer["Errors.USER_NOT_FOUND"].Value);
var language = CultureInfo.CurrentUICulture.Name;          // the negotiated culture, anywhere in the request

The same files translate problem details (by errorCode) and native validation messages (by message / conventional key, with {0} formatting) — see Localized validation messages. AddJsonLocalization() replaces any previously registered IStringLocalizerFactory (e.g. from AddLocalization()), whatever the call order.


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<Outcome<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.


OpenAPI helper

WithStandardProblemResponses() documents the error responses this stack produces — 400 (HttpValidationProblemDetails), 401, 403, 404, 409, 422 and 500 (ProblemDetails) — on an endpoint or a whole route group. It is a shortcut over the native ProducesValidationProblem() / ProducesProblem(status), which you can keep using directly for individual codes.

app.MapGroup("/orders").WithStandardProblemResponses();
app.MapGet("/orders/{id}", GetOrder).ProducesProblem(StatusCodes.Status404NotFound);   // native

Exceptions and request logging

Both are native — the package intentionally ships nothing here:

builder.Services.AddMinimalEndpoints();          // includes AddProblemDetails()
builder.Services.AddHttpLogging(o => o.LoggingFields = HttpLoggingFields.RequestPropertiesAndHeaders
                                                     | HttpLoggingFields.ResponseStatusCode
                                                     | HttpLoggingFields.Duration);

var app = builder.Build();
if (!app.Environment.IsDevelopment())
{
    app.UseExceptionHandler();                   // 500 ProblemDetails, no exception details, traceId included
}                                                // Development: the developer exception page (with details)
app.UseHttpLogging();

app.MapGet("/health", () => "ok").DisableHttpLogging();   // opt a noisy endpoint out

The exception handler logs the exception, answers 499 when the client aborted, and writes ProblemDetails through IProblemDetailsService, so your CustomizeProblemDetails callback applies to validation errors, Outcome<T> problems and unhandled exceptions alike. ASP.NET Core's hosting logs already carry TraceId/SpanId scopes; UseCorrelationId() adds CorrelationId.


How It Works

┌─────────────────────────────────────────────────────────┐
│  ASP.NET Core Minimal API Pipeline                      │
│                                                         │
│  ┌─── Middleware ───────────────────────────────────┐   │
│  │ ExceptionHandler* → CorrelationId                │   │
│  │ → RequestLocalization* → TimeZone                │   │
│  └──────────────────────────────────────────────────┘   │
│                                                         │
│  ┌─── Endpoint ────────────────────────────────────┐    │
│  │ Validation* (AddValidation, DataAnnotations)    │    │
│  │     ↓                                           │    │
│  │ IOperation.ExecuteAsync(request)                │    │
│  │     ↓                                           │    │
│  │ IOperationHandler<TReq, TResult> → Outcome<T>   │    │
│  │     ↓                                           │    │
│  │ TypedResults.Ok(model) / outcome.ToHttpResult() │    │
│  └─────────────────────────────────────────────────┘    │
│                                                         │
│  ┌─── Background ─────────────────────────────────┐    │
│  │ EventBus (In-Memory / Redis)                    │    │
│  │ → IDomainEventHandler<TEvent>                   │    │
│  └─────────────────────────────────────────────────┘    │
│                                                         │
│  * native ASP.NET Core; ProblemDetails everywhere via   │
│    IProblemDetailsService                               │
└─────────────────────────────────────────────────────────┘

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.AspNetCore 2.3+), targeting the 2026-07-28 protocol revision: stateless Streamable HTTP with no initialize handshake and no Mcp-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, 16+ chars — startup fails otherwise
});

// ...or bind everything from the "McpApiCatalog" configuration section
// builder.Services.AddMcpApiCatalog(builder.Configuration);

// 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 <api key>. The key is the only gate: every key holder sees the full catalog.

The API-key authentication scheme is isolated to the MCP endpoint: it never becomes the host's default scheme (a host with a single JwtBearer scheme and no explicit default keeps it as the default) and the MCP key never satisfies your own [Authorize] / RequireAuthorization(). The MCP endpoint's policy names its own scheme, so it works regardless of your default.

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, including its DataAnnotations ([Required] → required, [StringLength]/[MinLength]/[MaxLength]/[Length] → length/item limits, [Range] → minimum/maximum, [RegularExpression] → pattern, [EmailAddress]/[Url]/[Base64String] → format, [AllowedValues] → enum, [DeniedValues] → not.enum) — the same rules native validation enforces on the endpoint. Custom and async (AsyncValidationAttribute) rules have no schema keyword and are enforced only by the endpoint
  • 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"; // relative to the content root; 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

mcp.tool.name is only ever a known tool name; calls to unknown tools are tagged unknown so clients cannot inflate metric cardinality.

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

An API key (ApiKey and/or ApiKeys, each at least 16 characters) is required, and MapMcpApiCatalog always applies the package's bearer API-key authorization policy (constant-time comparison of SHA-256 digests). Keys are read through IOptionsMonitor, so to rotate: add the new key to ApiKeys, roll clients over, then remove the old one — with a reloadable configuration source no restart is needed.

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 — Bearer API key protecting the MCP endpoint. At least one of ApiKey/ApiKeys is required (16+ chars).
ApiKeys empty Additional accepted keys (rotation).
ApiKeyAuthenticationScheme / ApiKeyAuthorizationPolicy LowCodeHubMcpApiKey Names of the isolated scheme and policy.
AuthFlowDocumentPath — Markdown file returned by get_auth_flow, relative to the content root (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. DataAnnotations are projected first; a custom TransformSchemaNode runs after.

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.

Options bind from the McpApiCatalog section with AddMcpApiCatalog(builder.Configuration) and are validated on start. Calling AddMcpApiCatalog more than once composes the configuration callbacks.


Requirements

  • .NET 11 (net11.0 only; Microsoft.AspNetCore.App shared framework — native sync/async validation, validation-message localization, ProblemDetails and request localization come from it)
  • LowCodeHub.Outcomes (included as a dependency)
  • StackExchange.Redis 3.4+ (included) — the Redis event bus needs a Redis 6.2+ server, non-cluster
  • ModelContextProtocol.AspNetCore 2.3+ (included — required for the MCP catalog)

License

MIT © Ahmed Abuelnour

Product Compatible and additional computed target framework versions.
.NET net11.0 is compatible. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
0.2.1 95 10/22/2026
0.2.0 92 10/22/2026
0.1.0 712 8/10/2026
0.0.12 150 8/6/2026
0.0.11 434 7/9/2026
0.0.10 188 6/21/2026
0.0.6 200 5/18/2026
0.0.5 135 5/13/2026
0.0.4 136 5/12/2026
0.0.3 183 4/23/2026
0.0.2 3,508 3/27/2026
0.0.1 139 3/26/2026