< Summary - Kestrun — Combined Coverage

Information
Class: Kestrun.Mcp.KestrunRuntimeInspector
Assembly: Kestrun
File(s): /home/runner/work/Kestrun/Kestrun/src/CSharp/Kestrun/Mcp/KestrunMcpServices.cs
Tag: Kestrun/Kestrun@fd20e9dbe5c2c9fa4dfb9f27bd0a5c4b911dd8bd
Line coverage
91%
Covered lines: 32
Uncovered lines: 3
Coverable lines: 35
Total lines: 1093
Line coverage: 91.4%
Branch coverage
25%
Covered branches: 1
Total branches: 4
Branch coverage: 25%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Coverage history

Coverage history 0 25 50 75 100 04/23/2026 - 14:35:41 Line coverage: 91.4% (32/35) Branch coverage: 25% (1/4) Total lines: 1093 Tag: Kestrun/Kestrun@2fdbb120ca2faaa9acf2b8d2a34a7d64b067edbe 04/23/2026 - 14:35:41 Line coverage: 91.4% (32/35) Branch coverage: 25% (1/4) Total lines: 1093 Tag: Kestrun/Kestrun@2fdbb120ca2faaa9acf2b8d2a34a7d64b067edbe

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
Inspect(...)100%11100%
ResolveStatus(...)25%7440%
CreateListener(...)100%11100%
BuildSafeConfiguration(...)100%11100%

File(s)

/home/runner/work/Kestrun/Kestrun/src/CSharp/Kestrun/Mcp/KestrunMcpServices.cs

#LineLine coverage
 1using System.Net;
 2using System.Runtime.CompilerServices;
 3using System.Text;
 4using System.Text.Json;
 5using System.Text.Json.Nodes;
 6using System.Text.RegularExpressions;
 7using Kestrun.Hosting;
 8using Kestrun.Hosting.Options;
 9using Kestrun.OpenApi;
 10using Kestrun.Runtime;
 11using Kestrun.Utilities;
 12using Microsoft.AspNetCore.Routing.Template;
 13using Microsoft.Net.Http.Headers;
 14using Microsoft.OpenApi;
 15
 16namespace Kestrun.Mcp;
 17
 18/// <summary>
 19/// Default route inspector implementation backed by <see cref="KestrunHost"/>.
 20/// </summary>
 21public sealed class KestrunRouteInspector : IKestrunRouteInspector
 22{
 23    internal static readonly IEqualityComparer<MapRouteOptions> RouteReferenceComparer = new MapRouteOptionsReferenceCom
 24
 25    /// <inheritdoc />
 26    public IReadOnlyList<KestrunRouteSummary> ListRoutes(KestrunHost host)
 27    {
 28        ArgumentNullException.ThrowIfNull(host);
 29        return [.. host.RegisteredRoutes.Values
 30            .Distinct(RouteReferenceComparer)
 31            .Select(CreateSummary)
 32            .OrderBy(static route => route.Pattern, StringComparer.OrdinalIgnoreCase)];
 33    }
 34
 35    /// <inheritdoc />
 36    public KestrunRouteDetail GetRoute(KestrunHost host, string? pattern = null, string? operationId = null)
 37    {
 38        ArgumentNullException.ThrowIfNull(host);
 39
 40        var matches = FindMatchingRoutes(host, pattern, operationId);
 41        if (matches.Count == 0)
 42        {
 43            return new KestrunRouteDetail
 44            {
 45                Route = EmptyRoute(pattern, operationId),
 46                RequestSchemas = new Dictionary<string, JsonNode?>(StringComparer.OrdinalIgnoreCase),
 47                Responses = new Dictionary<string, KestrunRouteResponseSchema>(StringComparer.OrdinalIgnoreCase),
 48                Error = new KestrunMcpError(
 49                    "route_not_found",
 50                    "No route matched the requested pattern/operation id.",
 51                    new Dictionary<string, object?> { ["pattern"] = pattern, ["operationId"] = operationId })
 52            };
 53        }
 54
 55        if (matches.Count > 1)
 56        {
 57            return new KestrunRouteDetail
 58            {
 59                Route = CreateSummary(matches[0]),
 60                RequestSchemas = new Dictionary<string, JsonNode?>(StringComparer.OrdinalIgnoreCase),
 61                Responses = new Dictionary<string, KestrunRouteResponseSchema>(StringComparer.OrdinalIgnoreCase),
 62                Error = new KestrunMcpError(
 63                    "ambiguous_route",
 64                    "More than one route matched the request. Refine the selection with a unique pattern or operation id
 65                    new Dictionary<string, object?>
 66                    {
 67                        ["pattern"] = pattern,
 68                        ["operationId"] = operationId,
 69                        ["matches"] = matches.Select(CreateSummary).ToArray()
 70                    })
 71            };
 72        }
 73
 74        var route = matches[0];
 75        return new KestrunRouteDetail
 76        {
 77            Route = CreateSummary(route),
 78            RequestSchemas = BuildRequestSchemas(route),
 79            Responses = BuildResponseSchemas(route),
 80            Error = null
 81        };
 82    }
 83
 84    /// <summary>
 85    /// Finds matching routes by pattern and/or operation id.
 86    /// </summary>
 87    /// <param name="host">The Kestrun host.</param>
 88    /// <param name="pattern">Optional route pattern.</param>
 89    /// <param name="operationId">Optional operation id.</param>
 90    /// <returns>The matching route options.</returns>
 91    private static List<MapRouteOptions> FindMatchingRoutes(KestrunHost host, string? pattern, string? operationId)
 92    {
 93        var routes = host.RegisteredRoutes.Values.Distinct(RouteReferenceComparer);
 94
 95        if (!string.IsNullOrWhiteSpace(pattern))
 96        {
 97            routes = routes.Where(route => string.Equals(route.Pattern, pattern, StringComparison.OrdinalIgnoreCase));
 98        }
 99
 100        if (!string.IsNullOrWhiteSpace(operationId))
 101        {
 102            routes = routes.Where(route => route.OpenAPI.Values.Any(meta => string.Equals(meta.OperationId, operationId,
 103        }
 104
 105        return [.. routes];
 106    }
 107
 108    /// <summary>
 109    /// Builds a summarized route descriptor.
 110    /// </summary>
 111    /// <param name="route">The route options.</param>
 112    /// <returns>The route summary.</returns>
 113    internal static KestrunRouteSummary CreateSummary(MapRouteOptions route)
 114    {
 115        var metadata = route.OpenAPI.Values.FirstOrDefault();
 116        var responses = route.DefaultResponseContentType?
 117            .SelectMany(static entry => entry.Value.Select(value => value.ContentType))
 118            .Distinct(StringComparer.OrdinalIgnoreCase)
 119            .OrderBy(static value => value, StringComparer.OrdinalIgnoreCase)
 120            .ToArray() ?? [];
 121
 122        return new KestrunRouteSummary
 123        {
 124            Pattern = route.Pattern ?? "/",
 125            Verbs = [.. route.HttpVerbs.Select(static verb => verb.ToMethodString())],
 126            Tags = [.. metadata?.Tags ?? []],
 127            Summary = metadata?.Summary,
 128            Description = metadata?.Description,
 129            RequestContentTypes = [.. route.AllowedRequestContentTypes.Distinct(StringComparer.OrdinalIgnoreCase)],
 130            ResponseContentTypes = responses,
 131            HandlerName = route.HandlerName,
 132            HandlerLanguage = route.ScriptCode.Language.ToString(),
 133            OperationId = metadata?.OperationId
 134        };
 135    }
 136
 137    /// <summary>
 138    /// Creates an empty route summary used in lookup failures.
 139    /// </summary>
 140    /// <param name="pattern">Requested pattern.</param>
 141    /// <param name="operationId">Requested operation id.</param>
 142    /// <returns>An empty route summary.</returns>
 143    private static KestrunRouteSummary EmptyRoute(string? pattern, string? operationId)
 144    {
 145        return new KestrunRouteSummary
 146        {
 147            Pattern = pattern ?? "/",
 148            Verbs = [],
 149            Tags = [],
 150            Summary = null,
 151            Description = null,
 152            RequestContentTypes = [],
 153            ResponseContentTypes = [],
 154            HandlerName = null,
 155            HandlerLanguage = null,
 156            OperationId = operationId
 157        };
 158    }
 159
 160    /// <summary>
 161    /// Builds request schema payloads from OpenAPI metadata.
 162    /// </summary>
 163    /// <param name="route">The route options.</param>
 164    /// <returns>Request schemas keyed by content type.</returns>
 165    private static IReadOnlyDictionary<string, JsonNode?> BuildRequestSchemas(MapRouteOptions route)
 166    {
 167        var metadata = route.OpenAPI.Values.FirstOrDefault();
 168        var requestBody = metadata?.RequestBody;
 169        // If there is no request body, return an empty dictionary
 170        if (requestBody?.Content is null || requestBody.Content.Count == 0)
 171        {
 172            return new Dictionary<string, JsonNode?>(StringComparer.OrdinalIgnoreCase);
 173        }
 174        // Convert each request body content type to a JSON node
 175        return requestBody.Content.ToDictionary(
 176            static entry => entry.Key,
 177            static entry => ToJsonNode(entry.Value.Schema),
 178            StringComparer.OrdinalIgnoreCase);
 179    }
 180
 181    /// <summary>
 182    /// Builds response schema payloads from OpenAPI metadata.
 183    /// </summary>
 184    /// <param name="route">The route options.</param>
 185    /// <returns>Responses keyed by status code.</returns>
 186    private static IReadOnlyDictionary<string, KestrunRouteResponseSchema> BuildResponseSchemas(MapRouteOptions route)
 187    {
 188        var metadata = route.OpenAPI.Values.FirstOrDefault();
 189        var responses = metadata?.Responses;
 190        // If there are no responses, return an empty dictionary
 191        if (responses is null || responses.Count == 0)
 192        {
 193            return new Dictionary<string, KestrunRouteResponseSchema>(StringComparer.OrdinalIgnoreCase);
 194        }
 195
 196        // Convert each response status code to a KestrunRouteResponseSchema
 197#pragma warning disable IDE0028 // Simplify collection initialization
 198        return responses.ToDictionary(
 199            static entry => entry.Key,
 200            static entry => new KestrunRouteResponseSchema
 201            {
 202                Description = entry.Value.Description,
 203                Content = entry.Value.Content?.ToDictionary(
 204                    static item => item.Key,
 205                    static item => ToJsonNode(item.Value.Schema),
 206                    StringComparer.OrdinalIgnoreCase)
 207                    ?? new Dictionary<string, JsonNode?>(StringComparer.OrdinalIgnoreCase)
 208            },
 209            StringComparer.OrdinalIgnoreCase);
 210#pragma warning restore IDE0028 // Simplify collection initialization
 211    }
 212
 213    /// <summary>
 214    /// Converts an OpenAPI element into JSON.
 215    /// </summary>
 216    /// <param name="openApiElement">The OpenAPI element to serialize.</param>
 217    /// <returns>A JSON node representation when available.</returns>
 218    private static JsonNode? ToJsonNode(IOpenApiSerializable? openApiElement)
 219    {
 220        if (openApiElement is null)
 221        {
 222            return null;
 223        }
 224
 225        using var writer = new StringWriter();
 226        var jsonWriter = new OpenApiJsonWriter(writer);
 227        openApiElement.SerializeAsV31(jsonWriter);
 228        return JsonNode.Parse(writer.ToString());
 229    }
 230
 231    /// <summary>
 232    /// Reference-equality comparer for route options.
 233    /// </summary>
 234    private sealed class MapRouteOptionsReferenceComparer : IEqualityComparer<MapRouteOptions>
 235    {
 236        /// <inheritdoc />
 237        public bool Equals(MapRouteOptions? x, MapRouteOptions? y) => ReferenceEquals(x, y);
 238
 239        /// <inheritdoc />
 240        public int GetHashCode(MapRouteOptions obj) => RuntimeHelpers.GetHashCode(obj);
 241    }
 242}
 243
 244/// <summary>
 245/// Default OpenAPI provider implementation backed by <see cref="KestrunHost"/>.
 246/// </summary>
 247public sealed class KestrunOpenApiProvider : IKestrunOpenApiProvider
 248{
 249    /// <inheritdoc />
 250    public KestrunOpenApiDocumentResult GetOpenApi(KestrunHost host, string? documentId = null, string? version = null)
 251    {
 252        ArgumentNullException.ThrowIfNull(host);
 253
 254        var docId = string.IsNullOrWhiteSpace(documentId)
 255            ? host.DefaultOpenApiDocumentDescriptor?.DocumentId ?? OpenApiDocDescriptor.DefaultDocumentationId
 256            : documentId;
 257
 258        var descriptor = host.GetOrCreateOpenApiDocument(docId);
 259
 260        if (!descriptor.HasBeenGenerated)
 261        {
 262            descriptor.GenerateDoc();
 263        }
 264
 265        OpenApiSpecVersion specVersion;
 266        try
 267        {
 268            specVersion = string.IsNullOrWhiteSpace(version)
 269                ? OpenApiSpecVersion.OpenApi3_1
 270                : version.ParseOpenApiSpecVersion();
 271        }
 272        catch (ArgumentException ex)
 273        {
 274            return new KestrunOpenApiDocumentResult
 275            {
 276                DocumentId = docId,
 277                Version = version ?? OpenApiSpecVersion.OpenApi3_1.ToVersionString(),
 278                Error = new KestrunMcpError(
 279                    "unsupported_openapi_version",
 280                    ex.Message,
 281                    new Dictionary<string, object?> { ["version"] = version })
 282            };
 283        }
 284
 285        return new KestrunOpenApiDocumentResult
 286        {
 287            DocumentId = docId,
 288            Version = specVersion.ToVersionString(),
 289            Document = JsonNode.Parse(descriptor.ToJson(specVersion))
 290        };
 291    }
 292}
 293
 294/// <summary>
 295/// Default runtime inspector implementation backed by <see cref="KestrunHost"/>.
 296/// </summary>
 297public sealed class KestrunRuntimeInspector : IKestrunRuntimeInspector
 298{
 299    /// <inheritdoc />
 300    public KestrunRuntimeInspectionResult Inspect(KestrunHost host)
 301    {
 1302        ArgumentNullException.ThrowIfNull(host);
 303
 1304        return new KestrunRuntimeInspectionResult
 1305        {
 1306            ApplicationName = host.ApplicationName,
 1307            Status = ResolveStatus(host),
 1308            Environment = EnvironmentHelper.Name,
 1309            StartTimeUtc = host.Runtime.StartTime,
 1310            StopTimeUtc = host.Runtime.StopTime,
 1311            Uptime = host.Runtime.Uptime,
 1312            Listeners = [.. host.Options.Listeners.Select(CreateListener)],
 1313            RouteCount = host.RegisteredRoutes.Count,
 1314            Configuration = BuildSafeConfiguration(host)
 1315        };
 316    }
 317
 318    /// <summary>
 319    /// Resolves the runtime status label.
 320    /// </summary>
 321    /// <param name="host">The Kestrun host.</param>
 322    /// <returns>A runtime status label.</returns>
 323    private static string ResolveStatus(KestrunHost host)
 324    {
 325        // Determine the runtime status based on the host's state
 1326        if (host.IsRunning)
 327        {
 1328            return "running";
 329        }
 330
 331        // Determine the runtime status based on the host's state
 0332        if (host.IsConfigured)
 333        {
 0334            return "configured";
 335        }
 336
 337        // If the host is neither running nor configured, it is considered defined
 0338        return "defined";
 339    }
 340
 341    /// <summary>
 342    /// Builds safe listener metadata.
 343    /// </summary>
 344    /// <param name="listener">The listener options.</param>
 345    /// <returns>The runtime listener record.</returns>
 346    private static KestrunRuntimeListener CreateListener(ListenerOptions listener)
 347    {
 1348        return new KestrunRuntimeListener
 1349        {
 1350            Url = listener.ToString(),
 1351            Protocols = listener.Protocols.ToString(),
 1352            UseHttps = listener.UseHttps
 1353        };
 354    }
 355
 356    /// <summary>
 357    /// Builds a safe runtime configuration snapshot.
 358    /// </summary>
 359    /// <param name="host">The Kestrun host.</param>
 360    /// <returns>A safe configuration snapshot.</returns>
 361    private static IReadOnlyDictionary<string, object?> BuildSafeConfiguration(KestrunHost host)
 362    {
 1363        return new Dictionary<string, object?>
 1364        {
 1365            ["maxRunspaces"] = host.Options.MaxRunspaces,
 1366            ["minRunspaces"] = host.Options.MinRunspaces,
 1367            ["maxSchedulerRunspaces"] = host.Options.MaxSchedulerRunspaces,
 1368            ["currentUrls"] = host.CurrentUrls,
 1369            ["defaultResponseContentTypes"] = host.Options.DefaultResponseMediaType,
 1370            ["defaultApiResponseContentTypes"] = host.Options.DefaultApiResponseMediaType,
 1371            ["namedPipes"] = host.Options.NamedPipeNames,
 1372            ["unixSockets"] = host.Options.ListenUnixSockets
 1373        };
 374    }
 375}
 376
 377/// <summary>
 378/// Default request validation implementation backed by route metadata.
 379/// </summary>
 380public sealed class KestrunRequestValidator(IKestrunRouteInspector routeInspector) : IKestrunRequestValidator
 381{
 382    private readonly IKestrunRouteInspector _routeInspector = routeInspector ?? throw new ArgumentNullException(nameof(r
 383
 384    /// <inheritdoc />
 385    public KestrunRequestValidationResult Validate(KestrunHost host, KestrunRequestInput input)
 386    {
 387        ArgumentNullException.ThrowIfNull(host);
 388        ArgumentNullException.ThrowIfNull(input);
 389
 390        var method = NormalizeMethod(input.Method);
 391        var requestPath = NormalizePath(input.Path);
 392        var matches = FindPathMatches(host, requestPath);
 393        if (matches.Count == 0)
 394        {
 395            return Failure(404, "No registered route matches the requested path.", "route_not_found");
 396        }
 397
 398        var route = matches.FirstOrDefault(candidate => candidate.HttpVerbs.Any(verb => string.Equals(verb.ToMethodStrin
 399        if (route is null)
 400        {
 401            var allowedMethods = matches.SelectMany(static candidate => candidate.HttpVerbs).Select(static verb => verb.
 402            return Failure(
 403                404,
 404                $"No route matched method '{method}' for path '{requestPath}'. Registered methods: {string.Join(", ", al
 405                "method_not_matched");
 406        }
 407
 408        var contentTypeValidation = ValidateContentType(route, input);
 409        if (contentTypeValidation is not null)
 410        {
 411            return contentTypeValidation;
 412        }
 413
 414        var acceptValidation = ValidateAccept(route, input);
 415        // If the route has OpenAPI annotations, validate the Accept header.
 416        if (acceptValidation is not null)
 417        {
 418            return acceptValidation;
 419        }
 420
 421        // If we reach this point, the request is valid.
 422        return new KestrunRequestValidationResult
 423        {
 424            IsValid = true,
 425            StatusCode = 200,
 426            Message = "The request matches a registered route and satisfies known content-type/accept constraints.",
 427            Route = _routeInspector.GetRoute(host, route.Pattern).Route
 428        };
 429    }
 430
 431    /// <summary>
 432    /// Validates request content type constraints.
 433    /// </summary>
 434    /// <param name="route">The selected route.</param>
 435    /// <param name="input">The request input.</param>
 436    /// <returns>A failure result when validation fails; otherwise null.</returns>
 437    private KestrunRequestValidationResult? ValidateContentType(MapRouteOptions route, KestrunRequestInput input)
 438    {
 439        if (route.AllowedRequestContentTypes.Count == 0)
 440        {
 441            return null;
 442        }
 443
 444        var headers = NormalizeHeaders(input.Headers);
 445        var hasBody = HasBody(input.Body);
 446        _ = headers.TryGetValue(HeaderNames.ContentType, out var contentType);
 447
 448        if (string.IsNullOrWhiteSpace(contentType))
 449        {
 450            return !hasBody
 451                ? null
 452                : Failure(415, $"Content-Type is required. Supported types: {string.Join(", ", route.AllowedRequestConte
 453        }
 454
 455        if (!MediaTypeHeaderValue.TryParse(contentType, out var mediaType))
 456        {
 457            return Failure(400, $"Content-Type header '{contentType}' is malformed.", "invalid_content_type");
 458        }
 459
 460        var raw = mediaType.MediaType.ToString();
 461        var canonical = MediaTypeHelper.Canonicalize(raw);
 462        var allowed = route.AllowedRequestContentTypes.Any(candidate =>
 463            string.Equals(candidate, raw, StringComparison.OrdinalIgnoreCase) ||
 464            string.Equals(MediaTypeHelper.Canonicalize(candidate), canonical, StringComparison.OrdinalIgnoreCase));
 465
 466        return allowed
 467            ? null
 468            : Failure(415, $"Content-Type '{raw}' is not allowed. Supported types: {string.Join(", ", route.AllowedReque
 469    }
 470
 471    /// <summary>
 472    /// Validates Accept-header constraints for OpenAPI-aware routes.
 473    /// </summary>
 474    /// <param name="route">The selected route.</param>
 475    /// <param name="input">The request input.</param>
 476    /// <returns>A failure result when validation fails; otherwise null.</returns>
 477    private static KestrunRequestValidationResult? ValidateAccept(MapRouteOptions route, KestrunRequestInput input)
 478    {
 479        if (!route.IsOpenApiAnnotatedFunctionRoute)
 480        {
 481            return null;
 482        }
 483
 484        var headers = NormalizeHeaders(input.Headers);
 485        if (!headers.TryGetValue(HeaderNames.Accept, out var acceptHeader) || string.IsNullOrWhiteSpace(acceptHeader))
 486        {
 487            return null;
 488        }
 489
 490        var supported = ResolveResponseContentTypes(route);
 491        if (supported.Count == 0)
 492        {
 493            return null;
 494        }
 495
 496        var selected = SelectResponseMediaType(acceptHeader, supported, supported[0].ContentType);
 497        return selected is not null
 498            ? null
 499            : Failure(406, $"Accept header '{acceptHeader}' is not compatible with the route response types: {string.Joi
 500    }
 501
 502    /// <summary>
 503    /// Finds registered routes whose template matches the provided path.
 504    /// </summary>
 505    /// <param name="host">The Kestrun host.</param>
 506    /// <param name="requestPath">The request path.</param>
 507    /// <returns>The matching routes.</returns>
 508    private static List<MapRouteOptions> FindPathMatches(KestrunHost host, string requestPath)
 509    {
 510        return [.. host.RegisteredRoutes.Values
 511            .Distinct(KestrunRouteInspector.RouteReferenceComparer)
 512            .Where(route => RoutePatternMatches(route.Pattern, requestPath))];
 513    }
 514
 515    /// <summary>
 516    /// Determines whether a route pattern matches the supplied request path.
 517    /// </summary>
 518    /// <param name="pattern">The route pattern.</param>
 519    /// <param name="requestPath">The request path.</param>
 520    /// <returns>True when the path matches the route template.</returns>
 521    private static bool RoutePatternMatches(string? pattern, string requestPath)
 522    {
 523        if (string.IsNullOrWhiteSpace(pattern))
 524        {
 525            return false;
 526        }
 527
 528        var template = TemplateParser.Parse(pattern);
 529        var matcher = new TemplateMatcher(template, []);
 530        return matcher.TryMatch(requestPath, []);
 531    }
 532
 533    /// <summary>
 534    /// Resolves likely response content types for validation.
 535    /// </summary>
 536    /// <param name="route">The route options.</param>
 537    /// <returns>The likely successful response content types.</returns>
 538    internal static IReadOnlyList<ContentTypeWithSchema> ResolveResponseContentTypes(MapRouteOptions route)
 539    {
 540        return TryGetResponseContentTypes(route.DefaultResponseContentType, StatusCodes.Status200OK, out var values) && 
 541            ? values as IReadOnlyList<ContentTypeWithSchema> ?? [.. values]
 542            : [];
 543    }
 544
 545    /// <summary>
 546    /// Normalizes request method values.
 547    /// </summary>
 548    /// <param name="method">The incoming method.</param>
 549    /// <returns>A normalized method.</returns>
 550    private static string NormalizeMethod(string? method)
 551        => string.IsNullOrWhiteSpace(method) ? "GET" : method.Trim().ToUpperInvariant();
 552
 553    /// <summary>
 554    /// Normalizes request path values.
 555    /// </summary>
 556    /// <param name="path">The incoming path.</param>
 557    /// <returns>A normalized path.</returns>
 558    private static string NormalizePath(string? path)
 559        => string.IsNullOrWhiteSpace(path) ? "/" : path.StartsWith('/') ? path : "/" + path;
 560
 561    /// <summary>
 562    /// Normalizes headers into a case-insensitive dictionary.
 563    /// </summary>
 564    /// <param name="headers">The incoming headers.</param>
 565    /// <returns>A normalized header dictionary.</returns>
 566    private static Dictionary<string, string> NormalizeHeaders(IDictionary<string, string>? headers)
 567#pragma warning disable IDE0028 // Simplify collection initialization
 568        => headers is null
 569            ? new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
 570            : new Dictionary<string, string>(headers, StringComparer.OrdinalIgnoreCase);
 571#pragma warning restore IDE0028 // Simplify collection initialization
 572
 573    /// <summary>
 574    /// Determines whether a request body is present.
 575    /// </summary>
 576    /// <param name="body">The request body.</param>
 577    /// <returns>True when a body is present.</returns>
 578    private static bool HasBody(object? body)
 579    {
 580        return body switch
 581        {
 582            null => false,
 583            string text => !string.IsNullOrEmpty(text),
 584            JsonNode => true,
 585            _ => true
 586        };
 587    }
 588
 589    /// <summary>
 590    /// Creates a standardized failure payload.
 591    /// </summary>
 592    /// <param name="statusCode">The likely status code.</param>
 593    /// <param name="message">The validation message.</param>
 594    /// <param name="code">The stable error code.</param>
 595    /// <returns>A failure result.</returns>
 596    private static KestrunRequestValidationResult Failure(int statusCode, string message, string code)
 597    {
 598        return new KestrunRequestValidationResult
 599        {
 600            IsValid = false,
 601            StatusCode = statusCode,
 602            Message = message,
 603            Error = new KestrunMcpError(code, message)
 604        };
 605    }
 606
 607    /// <summary>
 608    /// Resolves response content types for a status code using exact, range, then default lookup.
 609    /// </summary>
 610    /// <param name="contentTypes">Configured content type mappings.</param>
 611    /// <param name="statusCode">The status code to resolve.</param>
 612    /// <param name="values">Resolved content types when available.</param>
 613    /// <returns>True when a mapping exists.</returns>
 614    internal static bool TryGetResponseContentTypes(
 615        IDictionary<string, ICollection<ContentTypeWithSchema>>? contentTypes,
 616        int statusCode,
 617        out ICollection<ContentTypeWithSchema>? values)
 618    {
 619        values = null;
 620        if (contentTypes is null || contentTypes.Count == 0)
 621        {
 622            return false;
 623        }
 624
 625        var statusKey = statusCode.ToString(System.Globalization.CultureInfo.InvariantCulture);
 626        if (TryGetValueIgnoreCase(contentTypes, statusKey, out values))
 627        {
 628            return true;
 629        }
 630
 631        if (statusCode is >= 100 and <= 599)
 632        {
 633            var rangeKey = $"{statusCode / 100}XX";
 634            if (TryGetValueIgnoreCase(contentTypes, rangeKey, out values))
 635            {
 636                return true;
 637            }
 638        }
 639
 640        return TryGetValueIgnoreCase(contentTypes, "default", out values);
 641    }
 642
 643    /// <summary>
 644    /// Performs a case-insensitive dictionary lookup.
 645    /// </summary>
 646    /// <param name="dictionary">The source dictionary.</param>
 647    /// <param name="key">The requested key.</param>
 648    /// <param name="value">The resolved value when present.</param>
 649    /// <returns>True when a value exists.</returns>
 650    internal static bool TryGetValueIgnoreCase<TValue>(IDictionary<string, TValue> dictionary, string key, out TValue? v
 651    {
 652        foreach (var entry in dictionary)
 653        {
 654            if (string.Equals(entry.Key, key, StringComparison.OrdinalIgnoreCase))
 655            {
 656                value = entry.Value;
 657                return true;
 658            }
 659        }
 660
 661        value = default;
 662        return false;
 663    }
 664
 665    /// <summary>
 666    /// Selects the most appropriate response media type for a supplied Accept header.
 667    /// </summary>
 668    /// <param name="acceptHeader">The incoming Accept header value.</param>
 669    /// <param name="supported">Supported response media types.</param>
 670    /// <param name="defaultType">Fallback type when <c>*/*</c> is configured.</param>
 671    /// <returns>The selected content type or null when no match exists.</returns>
 672    internal static ContentTypeWithSchema? SelectResponseMediaType(string? acceptHeader, IReadOnlyList<ContentTypeWithSc
 673    {
 674        if (supported.Count == 0)
 675        {
 676            return new ContentTypeWithSchema(defaultType, null);
 677        }
 678
 679        if (string.IsNullOrWhiteSpace(acceptHeader))
 680        {
 681            return supported[0];
 682        }
 683
 684        if (!MediaTypeHeaderValue.TryParseList([acceptHeader], out var accepts) || accepts.Count == 0)
 685        {
 686            return supported[0];
 687        }
 688
 689        var supportsAnyMediaType = supported.Any(static value => string.Equals(MediaTypeHelper.Normalize(value.ContentTy
 690        var normalizedSupported = supported.Select(static value => MediaTypeHelper.Normalize(value.ContentType)).ToArray
 691        var canonicalSupported = supported.Select(static value => MediaTypeHelper.Canonicalize(value.ContentType)).ToArr
 692
 693        foreach (var candidate in accepts.OrderByDescending(static value => value.Quality ?? 1.0))
 694        {
 695            var accept = candidate.MediaType.Value;
 696            if (accept is null)
 697            {
 698                continue;
 699            }
 700
 701            var normalizedAccept = MediaTypeHelper.Normalize(accept);
 702            if (supportsAnyMediaType)
 703            {
 704                return SelectWhenAnySupported(normalizedAccept, defaultType);
 705            }
 706
 707            var selected = SelectConfiguredMediaType(normalizedAccept, supported, normalizedSupported, canonicalSupporte
 708            if (selected is not null)
 709            {
 710                return selected;
 711            }
 712        }
 713
 714        return null;
 715    }
 716
 717    /// <summary>
 718    /// Selects a media type when the route supports any response media type.
 719    /// </summary>
 720    /// <param name="normalizedAccept">The normalized Accept header value.</param>
 721    /// <param name="defaultType">The fallback response type.</param>
 722    /// <returns>The selected content type entry.</returns>
 723    private static ContentTypeWithSchema SelectWhenAnySupported(string normalizedAccept, string defaultType)
 724    {
 725        // If the Accept header is a wildcard, return the default type.
 726        if (string.Equals(normalizedAccept, "*/*", StringComparison.OrdinalIgnoreCase) ||
 727            normalizedAccept.EndsWith("/*", StringComparison.OrdinalIgnoreCase))
 728        {
 729            return new ContentTypeWithSchema(defaultType, null);
 730        }
 731
 732        // If the Accept header is not a wildcard, resolve a concrete media type.
 733        return new ContentTypeWithSchema(ResolveWriterMediaType(normalizedAccept, defaultType), null);
 734    }
 735
 736    /// <summary>
 737    /// Selects a configured media type for an Accept header.
 738    /// </summary>
 739    /// <param name="normalizedAccept">The normalized Accept header value.</param>
 740    /// <param name="supported">Supported media types.</param>
 741    /// <param name="normalizedSupported">Normalized supported media types.</param>
 742    /// <param name="canonicalSupported">Canonical supported media types.</param>
 743    /// <returns>The selected media type entry when available.</returns>
 744    private static ContentTypeWithSchema? SelectConfiguredMediaType(
 745        string normalizedAccept,
 746        IReadOnlyList<ContentTypeWithSchema> supported,
 747        IReadOnlyList<string> normalizedSupported,
 748        IReadOnlyList<string> canonicalSupported)
 749    {
 750        if (string.Equals(normalizedAccept, "*/*", StringComparison.OrdinalIgnoreCase))
 751        {
 752            return supported[0];
 753        }
 754
 755        if (normalizedAccept.EndsWith("/*", StringComparison.OrdinalIgnoreCase))
 756        {
 757            var prefix = normalizedAccept[..^1];
 758            for (var i = 0; i < supported.Count; i++)
 759            {
 760                if (normalizedSupported[i].StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
 761                {
 762                    return supported[i];
 763                }
 764            }
 765
 766            return null;
 767        }
 768
 769        var canonicalAccept = MediaTypeHelper.Canonicalize(normalizedAccept);
 770        for (var i = 0; i < supported.Count; i++)
 771        {
 772            if (string.Equals(normalizedSupported[i], normalizedAccept, StringComparison.OrdinalIgnoreCase) ||
 773                string.Equals(canonicalSupported[i], canonicalAccept, StringComparison.OrdinalIgnoreCase))
 774            {
 775                return supported[i];
 776            }
 777        }
 778
 779        return null;
 780    }
 781
 782    /// <summary>
 783    /// Resolves a concrete writer media type for wildcard routes.
 784    /// </summary>
 785    /// <param name="normalizedAccept">The normalized Accept header value.</param>
 786    /// <param name="defaultType">The fallback type.</param>
 787    /// <returns>A concrete writer media type.</returns>
 788    private static string ResolveWriterMediaType(string normalizedAccept, string defaultType)
 789    {
 790        var canonical = MediaTypeHelper.Canonicalize(normalizedAccept);
 791        // If the Accept header is a known canonical type, return it.
 792        if (string.Equals(canonical, "application/json", StringComparison.OrdinalIgnoreCase) ||
 793            string.Equals(canonical, "application/xml", StringComparison.OrdinalIgnoreCase) ||
 794            string.Equals(canonical, "application/yaml", StringComparison.OrdinalIgnoreCase))
 795        {
 796            return canonical;
 797        }
 798
 799        // If the Accept header is a specific type, return it.
 800        if (string.Equals(normalizedAccept, "text/csv", StringComparison.OrdinalIgnoreCase) ||
 801            string.Equals(normalizedAccept, "application/x-www-form-urlencoded", StringComparison.OrdinalIgnoreCase))
 802        {
 803            return normalizedAccept;
 804        }
 805
 806        // If the Accept header is a text type, return text/plain.
 807        return normalizedAccept.StartsWith("text/", StringComparison.OrdinalIgnoreCase) ? "text/plain" : defaultType;
 808    }
 809}
 810
 811/// <summary>
 812/// Default route invoker implementation that uses the live HTTP pipeline.
 813/// </summary>
 814public sealed class KestrunRequestInvoker(
 815    IKestrunRequestValidator validator,
 816    KestrunRequestInvokerOptions options) : IKestrunRequestInvoker
 817{
 818    private readonly IKestrunRequestValidator _validator = validator ?? throw new ArgumentNullException(nameof(validator
 819    private readonly KestrunRequestInvokerOptions _options = options ?? throw new ArgumentNullException(nameof(options))
 820
 821    /// <inheritdoc />
 822    public async Task<KestrunRouteInvokeResult> InvokeAsync(KestrunHost host, KestrunRequestInput input, CancellationTok
 823    {
 824        ArgumentNullException.ThrowIfNull(host);
 825        ArgumentNullException.ThrowIfNull(input);
 826
 827        var path = NormalizePath(input.Path);
 828        if (!_options.EnableInvocation)
 829        {
 830            return Failure(403, "Route invocation is disabled for this MCP server.", "invoke_disabled");
 831        }
 832
 833        if (!IsPathAllowed(path))
 834        {
 835            return Failure(403, $"Route '{path}' is not allowlisted for invocation.", "invoke_not_allowlisted");
 836        }
 837
 838        var validation = _validator.Validate(host, input);
 839        if (!validation.IsValid)
 840        {
 841            return new KestrunRouteInvokeResult
 842            {
 843                StatusCode = validation.StatusCode,
 844                Headers = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase),
 845                Error = validation.Error
 846            };
 847        }
 848
 849        var baseUri = ResolveBaseUri(host);
 850        if (baseUri is null)
 851        {
 852            return Failure(503, "The Kestrun host is not running on a known listener URL.", "runtime_not_available");
 853        }
 854
 855        try
 856        {
 857            using var httpClient = new HttpClient { BaseAddress = baseUri };
 858            using var request = BuildRequestMessage(input);
 859            using var response = await httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
 860            var contentType = response.Content.Headers.ContentType?.ToString();
 861            var body = response.Content is null ? null : await response.Content.ReadAsStringAsync(cancellationToken).Con
 862
 863            return new KestrunRouteInvokeResult
 864            {
 865                StatusCode = (int)response.StatusCode,
 866                ContentType = contentType,
 867                Headers = RedactHeaders(response),
 868                Body = body
 869            };
 870        }
 871        catch (OperationCanceledException)
 872        {
 873            throw;
 874        }
 875        catch (Exception ex)
 876        {
 877            return Failure(500, $"Route invocation failed: {ex.Message}", "invoke_failed");
 878        }
 879    }
 880
 881    /// <summary>
 882    /// Builds an HTTP request message from the supplied invocation input.
 883    /// </summary>
 884    /// <param name="input">The invocation input.</param>
 885    /// <returns>The request message.</returns>
 886    private static HttpRequestMessage BuildRequestMessage(KestrunRequestInput input)
 887    {
 888        var uri = BuildRelativeUri(input.Path, input.Query);
 889        var request = new HttpRequestMessage(new HttpMethod(string.IsNullOrWhiteSpace(input.Method) ? "GET" : input.Meth
 890        var headers = input.Headers ?? new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
 891        var contentType = headers.TryGetValue(HeaderNames.ContentType, out var value) ? value : null;
 892
 893        foreach (var header in headers)
 894        {
 895            if (string.Equals(header.Key, HeaderNames.ContentType, StringComparison.OrdinalIgnoreCase))
 896            {
 897                continue;
 898            }
 899
 900            if (!request.Headers.TryAddWithoutValidation(header.Key, header.Value))
 901            {
 902                request.Content ??= new StringContent(string.Empty, Encoding.UTF8);
 903                _ = request.Content.Headers.TryAddWithoutValidation(header.Key, header.Value);
 904            }
 905        }
 906
 907        if (input.Body is not null)
 908        {
 909            request.Content = CreateContent(input.Body, contentType);
 910        }
 911
 912        return request;
 913    }
 914
 915    /// <summary>
 916    /// Creates HTTP content for the invocation request.
 917    /// </summary>
 918    /// <param name="body">The request body.</param>
 919    /// <param name="contentType">The request content type.</param>
 920    /// <returns>The HTTP content.</returns>
 921    private static HttpContent CreateContent(object body, string? contentType)
 922    {
 923        if (body is string text)
 924        {
 925            var mediaType = string.IsNullOrWhiteSpace(contentType) ? "text/plain" : contentType;
 926            return new StringContent(text, Encoding.UTF8, mediaType);
 927        }
 928
 929        var json = JsonSerializer.Serialize(body);
 930        return new StringContent(json, Encoding.UTF8, string.IsNullOrWhiteSpace(contentType) ? "application/json" : cont
 931    }
 932
 933    /// <summary>
 934    /// Builds a relative request URI from path and query values.
 935    /// </summary>
 936    /// <param name="path">The route path.</param>
 937    /// <param name="query">Optional query values.</param>
 938    /// <returns>The request URI.</returns>
 939    private static string BuildRelativeUri(string? path, IDictionary<string, string>? query)
 940    {
 941        var builder = new StringBuilder(NormalizePath(path));
 942        if (query is null || query.Count == 0)
 943        {
 944            return builder.ToString();
 945        }
 946
 947        var first = true;
 948        foreach (var pair in query)
 949        {
 950            _ = builder.Append(first ? '?' : '&').
 951            Append(WebUtility.UrlEncode(pair.Key)).
 952            Append('=').
 953            Append(WebUtility.UrlEncode(pair.Value));
 954            first = false;
 955        }
 956
 957        return builder.ToString();
 958    }
 959
 960    /// <summary>
 961    /// Resolves the first known loopback/base URL for the host.
 962    /// </summary>
 963    /// <param name="host">The running host.</param>
 964    /// <returns>The resolved base URI when available.</returns>
 965    private static Uri? ResolveBaseUri(KestrunHost host)
 966    {
 967        foreach (var url in host.CurrentUrls)
 968        {
 969            if (Uri.TryCreate(url, UriKind.Absolute, out var uri))
 970            {
 971                return NormalizeLoopbackUri(uri);
 972            }
 973        }
 974
 975        return null;
 976    }
 977
 978    /// <summary>
 979    /// Rewrites wildcard listener addresses to a loopback address suitable for local invocation.
 980    /// </summary>
 981    /// <param name="uri">The candidate listener URI.</param>
 982    /// <returns>The rewritten URI when needed; otherwise the original URI.</returns>
 983    private static Uri NormalizeLoopbackUri(Uri uri)
 984    {
 985        if (!string.Equals(uri.Host, "0.0.0.0", StringComparison.OrdinalIgnoreCase) &&
 986            !string.Equals(uri.Host, "::", StringComparison.OrdinalIgnoreCase) &&
 987            !string.Equals(uri.Host, "[::]", StringComparison.OrdinalIgnoreCase) &&
 988            !string.Equals(uri.Host, "*", StringComparison.OrdinalIgnoreCase))
 989        {
 990            return uri;
 991        }
 992
 993        var builder = new UriBuilder(uri)
 994        {
 995            Host = uri.Scheme.Equals(Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase)
 996                ? "localhost"
 997                : IPAddress.Loopback.ToString()
 998        };
 999
 1000        return builder.Uri;
 1001    }
 1002
 1003    /// <summary>
 1004    /// Redacts configured sensitive headers from response output.
 1005    /// </summary>
 1006    /// <param name="response">The HTTP response.</param>
 1007    /// <returns>A redacted header dictionary.</returns>
 1008    private IReadOnlyDictionary<string, string> RedactHeaders(HttpResponseMessage response)
 1009    {
 1010        var headers = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
 1011        foreach (var header in response.Headers)
 1012        {
 1013            headers[header.Key] = RedactIfSensitive(header.Key, string.Join(", ", header.Value));
 1014        }
 1015
 1016        if (response.Content is not null)
 1017        {
 1018            foreach (var header in response.Content.Headers)
 1019            {
 1020                headers[header.Key] = RedactIfSensitive(header.Key, string.Join(", ", header.Value));
 1021            }
 1022        }
 1023
 1024        return headers;
 1025    }
 1026
 1027    /// <summary>
 1028    /// Determines whether a path is allowlisted for invocation.
 1029    /// </summary>
 1030    /// <param name="path">The request path.</param>
 1031    /// <returns>True when the path is allowlisted.</returns>
 1032    private bool IsPathAllowed(string path)
 1033    {
 1034        // If no allowed path patterns are configured, deny all paths.
 1035        if (_options.AllowedPathPatterns.Count == 0)
 1036        {
 1037            return false;
 1038        }
 1039
 1040        // Check if the path matches any of the allowed patterns.
 1041        return _options.AllowedPathPatterns.Any(pattern =>
 1042            string.Equals(pattern, "*", StringComparison.Ordinal) ||
 1043            GlobMatches(pattern, path));
 1044    }
 1045
 1046    /// <summary>
 1047    /// Redacts sensitive header values.
 1048    /// </summary>
 1049    /// <param name="name">The header name.</param>
 1050    /// <param name="value">The original header value.</param>
 1051    /// <returns>The redacted or original value.</returns>
 1052    private string RedactIfSensitive(string name, string value)
 1053        => _options.RedactedHeaders.Contains(name) ? "[REDACTED]" : value;
 1054
 1055    /// <summary>
 1056    /// Matches a glob-style path pattern.
 1057    /// </summary>
 1058    /// <param name="pattern">The glob pattern.</param>
 1059    /// <param name="value">The candidate value.</param>
 1060    /// <returns>True when the value matches the pattern.</returns>
 1061    private static bool GlobMatches(string pattern, string value)
 1062    {
 1063        var regex = "^" + Regex.Escape(pattern)
 1064            .Replace("\\*", ".*", StringComparison.Ordinal)
 1065            .Replace("\\?", ".", StringComparison.Ordinal) + "$";
 1066        return Regex.IsMatch(value, regex, RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
 1067    }
 1068
 1069    /// <summary>
 1070    /// Creates a standardized invocation failure payload.
 1071    /// </summary>
 1072    /// <param name="statusCode">The failure status code.</param>
 1073    /// <param name="message">The failure message.</param>
 1074    /// <param name="code">The stable error code.</param>
 1075    /// <returns>A failure result.</returns>
 1076    private static KestrunRouteInvokeResult Failure(int statusCode, string message, string code)
 1077    {
 1078        return new KestrunRouteInvokeResult
 1079        {
 1080            StatusCode = statusCode,
 1081            Headers = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase),
 1082            Error = new KestrunMcpError(code, message)
 1083        };
 1084    }
 1085
 1086    /// <summary>
 1087    /// Normalizes request paths.
 1088    /// </summary>
 1089    /// <param name="path">The incoming path.</param>
 1090    /// <returns>A normalized path.</returns>
 1091    private static string NormalizePath(string? path)
 1092        => string.IsNullOrWhiteSpace(path) ? "/" : path.StartsWith('/') ? path : "/" + path;
 1093}