diff --git a/AuthKit.slnx b/AuthKit.slnx
index 9a470a1..90ca6e1 100644
--- a/AuthKit.slnx
+++ b/AuthKit.slnx
@@ -10,10 +10,15 @@
+
+
+
+
+
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 093b472..6985be3 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -11,7 +11,6 @@
-
@@ -24,14 +23,11 @@
-
-
-
diff --git a/Dockerfile b/Dockerfile
index 2ef19b7..9dfb642 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -16,6 +16,10 @@ COPY ["src/Host/Host.csproj", "src/Host/"]
COPY ["src/Core/Core.csproj", "src/Core/"]
COPY ["src/Plugins/Abstractions/AuthKit.Plugins.Abstractions.csproj", "src/Plugins/Abstractions/"]
COPY ["src/Plugins/Solutions/DevTokens/DevTokens.csproj", "src/Plugins/Solutions/DevTokens/"]
+COPY ["src/Plugins/Solutions/DevTools/DevTools.csproj", "src/Plugins/Solutions/DevTools/"]
+
+COPY ["tests/Host/AuthKit.Host.Tests.csproj", "tests/Host/"]
+COPY ["tests/Plugins/Abstractions/AuthKit.Plugins.Abstractions.Tests.csproj", "tests/Plugins/Abstractions/"]
COPY ["tools/PluginContractValidator/PluginContractValidator.csproj", "tools/PluginContractValidator/"]
RUN dotnet restore "AuthKit.slnx"
@@ -30,6 +34,7 @@ FROM build AS publish
WORKDIR /src
RUN dotnet publish "src/Host/Host.csproj" -c Release -o /app/publish
RUN dotnet publish "src/Plugins/Solutions/DevTokens/DevTokens.csproj" -c Release -o /app/publish/plugins/DevTokens
+RUN dotnet publish "src/Plugins/Solutions/DevTools/DevTools.csproj" -c Release -o /app/publish/plugins/DevTools
COPY src/Plugins/Solutions/DevTokens/plugin.manifest.json /app/publish/plugins/DevTokens/plugin.manifest.json
FROM base AS final
diff --git a/Docs/ADR/020-devtools-plugin.md b/Docs/ADR/020-devtools-plugin.md
new file mode 100644
index 0000000..733f934
--- /dev/null
+++ b/Docs/ADR/020-devtools-plugin.md
@@ -0,0 +1,57 @@
+[ADR Home](../../README.md) | [Category Index](./README.md) | [Previous](./019-plugin-metadata-attribute.md) | [Next](./021-swagger-serving-via-reflection.md)
+
+# [ADR-020] Host Developer Tools Through A Dedicated DevTools Plugin
+
+*2026-09* | Status: accepted
+
+**Tag:** #adr_020
+
+**Date:** 2026-09-11
+
+**Scope:** Host + Plugins.Solutions.DevTools
+
+## Context
+
+AuthKit needs developer facing tooling to exercise its own surfaces: an interactive page to call any exposed gRPC method, and Swagger UI to browse the REST/OpenAPI surface. Earlier work exposed a gRPC UI as a standalone `GrpcUI` plugin and served Swagger UI on the host itself through `UseSwagger` / `UseSwaggerUI` (Swashbuckle direct middleware). The two tools lived in different architectural homes: one was a plugin, the other was hard wired into the host's request pipeline.
+
+## Problem
+
+A tool baked into the host can only ever be enabled together with the host, couples the host to Swashbuckle's serving middleware, and cannot be shared across host flavors or removed without editing host code. A dedicated `GrpcUI` plugin covered gRPC but left the REST/OpenAPI tooling outside the plugin boundary two mechanisms for the same job, two places to configure. Tooling URLs were also fixed implicit host paths rather than configurable plugin owned prefixes.
+
+## Decision
+
+Both developer tools move into single `DevTools` plugin (`src/Plugins/Solutions/DevTools`), replacing the standalone `GrpcUI` plugin and removing Swagger serving from the host:
+
+- The plugin owns its URL space through `DevToolsOptions`: gRPC UI at `GrpcUiPathBase` (`/grpc-ui`), Swagger UI under the `Swagger` section (default route prefix `swagger`, document name `v1`, title `AuthKit API`), and a landing page at `PathBase` (`/devtools`).
+- The gRPC UI is in process: `GrpcServiceCatalog` scans the default assembly load context for static `ServiceDescriptor` properties, `GrpcDynamicInvoker` executes unary methods dynamically over `Grpc.Net.Client` without generated stubs, and `DevToolsMiddleware` dispatches `/grpc-ui` and its `/api/services` + `/api/invoke` endpoints.
+- Swagger document generation stays in the host (`RestfulConfiguration` + `AddSwaggerGen`); only the SWAGGER SERVING middleware moves into `DevTools` via `SwaggerHost`.
+- The host no longer calls `UseSwagger` / `UseSwaggerUI` `AppMiddlewareConfiguration` contains only the plugin slot. Developer tools are present in a deployment exactly when the plugin is deployed.
+- Swagger UI serving is gated: by default enabled only in the development environment (`Swagger.Enabled ?? environment.IsDevelopment()`), with pinning of the OpenAPI spec version for the served document.
+- The host is published with `DevTools` selected by default (solution file and Dockerfile), so a default deployment keeps both tools available.
+
+### Design Rationale
+
+- **One tooling surface**: REST (Swagger) and gRPC (in-process UI) are both interactive representations of the same host one plugin owns the developer experience.
+- **Consistent plugin boundary**: everything a template developer visits is contributed by plugin, under plugin owned path bases, removable by not deploying the plugin.
+- **In-process over proxy**: the gRPC UI talks to the host directly rather than fronting separate process and using gRPC reflection, keeping discovery local and configuration minimal.
+- **No stubs needed**: dynamic invocation over method descriptors means the plugin never needs generated client code for the services it renders.
+
+## Rejected
+
+- Keeping Swagger serving in the host (`UseSwagger`/`UseSwaggerUI`) couples host to specific tool and a specific serving middleware.
+- A standalone `GrpcUI` plugin with host owned Swagger two homes for equivalent functionality.
+- A gRPC proxy process (eg. maintained third party gRPC UI binary) for streaming and health support additional deployment process and transport hop the extra support was deemed unnecessary for an internal developer tool.
+- Serving Swagger from the plugin while re-emitting document generation there would duplicate `AddSwaggerGen` configuration generation stays in the host, serving stays in the plugin.
+
+## Consequences
+
+The host's `AppMiddlewareConfiguration` is smaller and no longer references Swashbuckle. The `DevTools` solution owns compiler friendly tooling code (catalog, invoker, middleware, options, and UI assets). Swagger UI availability depends on plugin deployment and environment gating rather than host build configuration. The gRPC UI only supports unary methods streaming methods are reported as unsupported rather than approximated. Configuration is centralized in `DevToolsOptions`, resolved with environment variables (`GRPC_UI_TARGET`, `DEV_CERT_PORT_GRPC`) for local development against the HTTPS gRPC endpoint.
+
+## Related
+
+- [ADR-009](./009-dynamic-plugin-discovery.md) - plugin contract and dynamic discovery
+- [ADR-010](./010-plugin-loading-from-directory.md) - plugin middleware slot used by DevToolsMiddleware
+- [ADR-013](./013-dual-rest-and-grpc-transport.md) - dual REST + gRPC surfaces being exercised
+- [ADR-021](./021-swagger-serving-via-reflection.md) - how SwaggerHost serves SwaggerUI through reflection
+
+[ADR Home](../../README.md) | [Category Index](./README.md) | [Previous](./019-plugin-metadata-attribute.md) | [Next](./021-swagger-serving-via-reflection.md)
\ No newline at end of file
diff --git a/Docs/ADR/021-swagger-serving-via-reflection.md b/Docs/ADR/021-swagger-serving-via-reflection.md
new file mode 100644
index 0000000..f5874f7
--- /dev/null
+++ b/Docs/ADR/021-swagger-serving-via-reflection.md
@@ -0,0 +1,55 @@
+[ADR Home](../../README.md) | [Category Index](./README.md) | [Previous](./020-devtools-plugin.md) | [Next]()
+
+# [ADR-021] Serve Swagger Through Reflection-Based SwaggerHost In DevTools
+
+*2026-09* | Status: accepted
+
+**Tag:** #adr_021
+
+**Date:** 2026-09-11
+
+**Scope:** Plugins.Solutions.DevTools
+
+## Context
+
+ADR-020 moved Swagger UI serving from the host into the `DevTools` plugin. The plugin already consumed the OpenAPI document Provider (`ISwaggerProvider`, registered by `RestfulConfiguration` + `AddSwaggerGen`), but the actual document serialization and HTML UI rendering were performed by Swashbuckle's `SwaggerMiddleware` and `SwaggerUIMiddleware` — both of which are **internal** to their NuGet packages, so a plugin cannot call them the way the host used to.
+
+## Problem
+
+The plugin must reproduce what the host previously did with the public `UseSwagger` / `UseSwaggerUI` extension methods, but the middlewares behind those extensions are not part of the public API surface. The plugin also needs to control the OpenAPI spec version pinned for the served document — independently of any document that `RestfulConfiguration` might generate for other consumers.
+
+## Decision
+
+`DevTools` serves Swagger with a reflection-based `SwaggerHost`:
+
+- `SwaggerHost` resolves the internal `Swashbuckle.AspNetCore.Swagger.SwaggerMiddleware` and `Swashbuckle.AspNetCore.SwaggerUI.SwaggerUIMiddleware` types by assembly-qualified name at startup, constructs one instance per middleware via `Activator.CreateInstance` with the standard (notFound, options) constructor shape, and invokes the internal `Invoke` methods reflectively per request.
+- The Swagger document middleware is configured with a route template under the configured Swagger route prefix and the resolved OpenAPI spec version.
+- The Swagger UI middleware receives UI options (route prefix, document title, `IndexStream` pointing at the embedded `index.html` resource) and an endpoint pointing at the document route.
+- `DevToolsMiddleware` decides whether a request path belongs to the plugin (gRPC UI, Swagger, landing page) and, for the Swagger segment, delegates to `SwaggerHost.TryServeAsync`; a 404-not-found `RequestDelegate` is handed to the Swashbuckle middlewares when the path does not match.
+- The OpenAPI spec version for the served document is resolved by `SwaggerHost.ResolveSpecVersion`: a `DevTools:Swagger:SpecVersion` override, then the host's `OpenApi:SpecVersion`, defaulting to `3.0`. Supported values are `3.0.x` and `3.1.x`; an unknown value logs a warning and falls back to `3.0`.
+- Because the served document uses the spec version pinned for DevTools, mutual-TLS schemes (ADR-018) surface natively (`mutualTLS`) only when the document is pinned to OpenAPI 3.1.
+
+### Design Rationale
+
+- **Reuse over reimplementation**: the plugin does not re-implement OpenAPI serialization or the HTML UI — it drives the exact middleware the host used, through reflection, because the types are internal.
+- **Single construction**: middleware instances are built once at plugin startup and reused for every request; per-request work is a reflection invoke, not a rebuild.
+- **Independent pinning**: DevTools controls its own document spec version rather than inheriting a fixed host document, which is what lets the OpenAPI mapper's 3.1-dependent cases (ADR-018) work in DevTools.
+
+## Rejected
+
+- Re-implementing the Swagger UI page and OpenAPI serialization inside the plugin — duplicates a large, maintained surface for no architectural gain.
+- Depending on the internal Swashbuckle types as a hard library reference — they are internal by design; a hard reference simply would not compile.
+- Copying/shipping the Swashbuckle middleware source into the plugin — license and maintenance burden, and drift from upstream bug fixes.
+- Removing Swagger serving from DevTools entirely (document-only JSON at a well-known URL) — loses the interactive UI that motivated the plugin.
+
+## Consequences
+
+`DevTools` stays on the Swashbuckle version that ships the internal middlewares; upgrading a Swashbuckle version could change the internal type/methods signatures and break `SwaggerHost` at startup (fail-fast with a descriptive exception). The reflection layer is a deliberate, visible cost: two `Type`/`MethodInfo` lookups and a per-request `Invoke` instead of direct calls. Unknown spec versions degrade to a warning + 3.0 in DevTools serving (so the UI still works), which is intentionally lazier than `RestfulConfiguration`, which hard-fails on unknown spec versions in app configuration.
+
+## Related
+
+- [ADR-013](./013-dual-rest-and-grpc-transport.md) - the OpenAPI surface served by DevTools
+- [ADR-020](./020-devtools-plugin.md) - why Swagger serving lives in the plugin at all
+- [ADR-018](./018-security-scheme-contract-explicit-handling.md) - mutual-TLS schemes require the 3.1 pin DevTools provides
+
+[ADR Home](../../README.md) | [Category Index](./README.md) | [Previous](./020-devtools-plugin.md) | [Next]()
\ No newline at end of file
diff --git a/src/Host/Configuration/RestfulConfiguration.cs b/src/Host/Configuration/RestfulConfiguration.cs
index f9e6b95..623a639 100644
--- a/src/Host/Configuration/RestfulConfiguration.cs
+++ b/src/Host/Configuration/RestfulConfiguration.cs
@@ -1,8 +1,4 @@
-using AuthKit.Plugins.Abstractions;
-using AuthKit.Plugins.Abstractions.Contracts.SecuritySchemes;
using Host.Plugins;
-using Microsoft.Extensions.Configuration;
-using Microsoft.Extensions.Logging;
using Microsoft.OpenApi;
namespace Host.Configuration;
diff --git a/src/Plugins/Solutions/DevTools/Catalog/GrpcServiceCatalog.cs b/src/Plugins/Solutions/DevTools/Catalog/GrpcServiceCatalog.cs
new file mode 100644
index 0000000..0d4f29c
--- /dev/null
+++ b/src/Plugins/Solutions/DevTools/Catalog/GrpcServiceCatalog.cs
@@ -0,0 +1,183 @@
+using System.Reflection;
+using System.Runtime.Loader;
+using Google.Protobuf.Reflection;
+using Microsoft.Extensions.Logging;
+
+namespace DevTools.Catalog;
+
+///
+/// Provides an in-process catalog of the gRPC services exposed by the
+/// host application.
+///
+///
+///
+/// Generated gRPC code exposes each service as a static Descriptor
+/// property of type on the generated static
+/// service class (e.g. Host.Greeter.Descriptor). Services are
+/// discovered by scanning those properties across the assemblies loaded
+/// into the default load context.
+///
+///
+/// Message fields are expanded up to levels
+/// so recursive messages remain finite when rendered by the UI.
+///
+///
+public sealed class GrpcServiceCatalog(ILogger logger) : IGrpcServiceCatalog
+{
+ ///
+ /// Maximum number of message nesting levels expanded when describing
+ /// request and response schemas.
+ ///
+ private const int MaxMessageDepth = 5;
+
+ private readonly Lazy<(IReadOnlyList Services,
+ IReadOnlyDictionary Methods)> _catalog = new(() => Scan(logger));
+
+ ///
+ /// Gets the discovered gRPC services ordered by full name.
+ ///
+ ///
+ /// A collection of describing the
+ /// discovered services and their methods.
+ ///
+ public IReadOnlyList GetServices() => _catalog.Value.Services;
+
+ ///
+ /// Attempts to locate a method by its full name.
+ ///
+ /// The full name of the service.
+ /// The name of the method.
+ ///
+ /// The for the matching method,
+ /// or null when no such method exists in the catalog.
+ ///
+ public GrpcMethodCatalogEntry? TryGetMethod(string serviceName, string methodName)
+ {
+ _catalog.Value.Methods.TryGetValue($"{serviceName}/{methodName}", out var entry);
+ return entry;
+ }
+
+ ///
+ /// Scans the assemblies currently loaded into the default load context and
+ /// gathers every gRPC service and method into an ordered, immutable snapshot.
+ ///
+ private static (IReadOnlyList Services, IReadOnlyDictionary Methods) Scan(ILogger logger)
+ {
+ var services = new List();
+ var methods = new Dictionary();
+ var seenServices = new HashSet(StringComparer.Ordinal);
+
+ foreach (var assembly in AssemblyLoadContext.Default.Assemblies.ToArray())
+ {
+ foreach (var type in SafeGetTypes(assembly))
+ {
+ foreach (var property in type.GetProperties(BindingFlags.Static | BindingFlags.Public))
+ {
+ if (property.PropertyType != typeof(ServiceDescriptor) || property.GetIndexParameters().Length != 0)
+ continue;
+
+ if (property.GetValue(null) is not ServiceDescriptor descriptor || !seenServices.Add(descriptor.FullName))
+ continue;
+
+ try
+ {
+ services.Add(BuildServiceInfo(descriptor));
+ foreach (var method in descriptor.Methods)
+ methods[$"{descriptor.FullName}/{method.Name}"] =
+ new GrpcMethodCatalogEntry(BuildMethodInfo(method), method);
+ }
+ catch (Exception ex)
+ {
+ logger.LogWarning(ex, "Failed to describe gRPC service {Service}.", descriptor.FullName);
+ }
+ }
+ }
+ }
+
+ logger.LogInformation("gRPC catalog discovered {Count} service(s).", services.Count);
+ return (services
+ .OrderBy(s => s.FullName, StringComparer.Ordinal)
+ .ToArray(), methods);
+ }
+
+ private static Type[] SafeGetTypes(Assembly assembly)
+ {
+ try
+ {
+ return assembly.GetTypes();
+ }
+ catch (ReflectionTypeLoadException ex)
+ {
+ return ex.Types.Where(t => t is not null).Cast().ToArray();
+ }
+ }
+
+ private static GrpcServiceInfo BuildServiceInfo(ServiceDescriptor descriptor) =>
+ new()
+ {
+ Name = descriptor.Name,
+ FullName = descriptor.FullName,
+ Package = descriptor.File.Package,
+ FileName = descriptor.File.Name,
+ Methods = descriptor.Methods
+ .Select(BuildMethodInfo)
+ .ToArray()
+ };
+
+ private static GrpcMethodInfo BuildMethodInfo(MethodDescriptor method) =>
+ new()
+ {
+ Name = method.Name,
+ FullName = $"{method.Service.FullName}/{method.Name}",
+ IsClientStreaming = method.IsClientStreaming,
+ IsServerStreaming = method.IsServerStreaming,
+ Request = BuildMessageSchema(method.InputType, depth: 0),
+ Response = BuildMessageSchema(method.OutputType, depth: 0)
+ };
+
+ private static GrpcMessageSchema BuildMessageSchema(MessageDescriptor message, int depth) =>
+ new()
+ {
+ Name = message.Name,
+ FullName = message.FullName,
+ Fields = depth >= MaxMessageDepth
+ ? []
+ : message.Fields.InFieldNumberOrder()
+ .Select(field => BuildFieldSchema(field, depth))
+ .ToArray()
+ };
+
+ private static GrpcFieldSchema BuildFieldSchema(FieldDescriptor field, int depth)
+ {
+ var schema = new GrpcFieldSchema
+ {
+ Name = field.Name,
+ FieldType = field.FieldType.ToString(),
+ IsRepeated = field.IsRepeated,
+ IsMap = field.IsMap
+ };
+
+ if (field.IsMap)
+ {
+ var keyField = field.MessageType.Fields.InFieldNumberOrder()[0];
+ var valueField = field.MessageType.Fields.InFieldNumberOrder()[1];
+
+ schema.MapKeyType = keyField.FieldType.ToString();
+ if (valueField.FieldType == FieldType.Message)
+ schema.MapValue = BuildMessageSchema(valueField.MessageType, depth + 1);
+ else
+ schema.MapValueType = valueField.FieldType.ToString();
+ }
+
+ if (field is { FieldType: FieldType.Message, IsMap: false })
+ schema.Message = BuildMessageSchema(field.MessageType, depth + 1);
+
+ if (field.FieldType == FieldType.Enum)
+ {
+ schema.EnumType = field.EnumType.Name;
+ schema.EnumValues = field.EnumType.Values.Select(v => v.Name).ToArray();
+ }
+
+ return schema;
+ }
+}
\ No newline at end of file
diff --git a/src/Plugins/Solutions/DevTools/Catalog/GrpcServiceInfo.cs b/src/Plugins/Solutions/DevTools/Catalog/GrpcServiceInfo.cs
new file mode 100644
index 0000000..115e8c8
--- /dev/null
+++ b/src/Plugins/Solutions/DevTools/Catalog/GrpcServiceInfo.cs
@@ -0,0 +1,173 @@
+namespace DevTools.Catalog;
+
+///
+/// Descriptor of a protobuf scalar or composite field in a request or
+/// response message.
+///
+///
+/// Only one of the composite members is populated for a given field:
+/// for nested messages, for
+/// message values of a map, or plus
+/// for enums.
+///
+public sealed class GrpcFieldSchema
+{
+ ///
+ /// Gets the name of the field.
+ ///
+ public required string Name { get; set; }
+
+ ///
+ /// Gets the protobuf field type (e.g. String, Int32, Message).
+ ///
+ public required string FieldType { get; set; }
+
+ ///
+ /// Gets a value indicating whether the field is repeated.
+ ///
+ public bool IsRepeated { get; set; }
+
+ ///
+ /// Gets a value indicating whether the field is a map.
+ ///
+ public bool IsMap { get; set; }
+
+ ///
+ /// Gets the key type of a map field.
+ ///
+ public string? MapKeyType { get; set; }
+
+ ///
+ /// Gets the scalar value type of a map field.
+ ///
+ public string? MapValueType { get; set; }
+
+ ///
+ /// Gets the message value schema of a map field.
+ ///
+ public GrpcMessageSchema? MapValue { get; set; }
+
+ ///
+ /// Gets the nested message schema of a message field.
+ ///
+ public GrpcMessageSchema? Message { get; set; }
+
+ ///
+ /// Gets the enum type name of an enum field.
+ ///
+ public string? EnumType { get; set; }
+
+ ///
+ /// Gets the enum values of an enum field.
+ ///
+ public IEnumerable? EnumValues { get; set; }
+}
+
+///
+/// Protobuf message layout used to render the request and response schema
+/// in the UI.
+///
+///
+/// Nested messages are expanded up to a fixed depth to avoid infinite
+/// recursion on self-referencing messages.
+///
+public sealed class GrpcMessageSchema
+{
+ ///
+ /// Gets the short name of the message.
+ ///
+ public required string Name { get; init; }
+
+ ///
+ /// Gets the fully qualified name of the message.
+ ///
+ public required string FullName { get; init; }
+
+ ///
+ /// Gets the fields of the message in field number order.
+ ///
+ public required IEnumerable Fields { get; init; }
+}
+
+///
+/// Descriptor of a single RPC method including its input and output schemas.
+///
+public sealed class GrpcMethodInfo
+{
+ ///
+ /// Gets the name of the method.
+ ///
+ public required string Name { get; init; }
+
+ ///
+ /// Gets the fully qualified method name (e.g. greet.Greeter/SayHello).
+ ///
+ public required string FullName { get; init; }
+
+ ///
+ /// Gets a value indicating whether the method is a client-streaming method.
+ ///
+ public bool IsClientStreaming { get; init; }
+
+ ///
+ /// Gets a value indicating whether the method is a server-streaming method.
+ ///
+ public bool IsServerStreaming { get; init; }
+
+ ///
+ /// Gets the schema of the method's input message.
+ ///
+ public required GrpcMessageSchema Request { get; init; }
+
+ ///
+ /// Gets the schema of the method's output message.
+ ///
+ public required GrpcMessageSchema Response { get; init; }
+}
+
+///
+/// Descriptor of a gRPC service together with all of its methods.
+///
+public sealed class GrpcServiceInfo
+{
+ ///
+ /// Gets the short name of the service.
+ ///
+ public required string Name { get; init; }
+
+ ///
+ /// Gets the fully qualified name of the service.
+ ///
+ public required string FullName { get; init; }
+
+ ///
+ /// Gets the protobuf package the service belongs to.
+ ///
+ public required string Package { get; init; }
+
+ ///
+ /// Gets the name of the .proto file defining the service.
+ ///
+ public required string FileName { get; init; }
+
+ ///
+ /// Gets the methods exposed by the service.
+ ///
+ public required IEnumerable Methods { get; init; }
+}
+
+///
+/// Payload returned by the catalog API endpoint.
+///
+public sealed class GrpcCatalogResponse
+{
+ ///
+ /// Gets the gRPC endpoint used to execute invocations.
+ ///
+ public required string Target { get; init; }
+
+ ///
+ /// Gets the discovered gRPC services.
+ ///
+ public required IEnumerable Services { get; init; }
+}
\ No newline at end of file
diff --git a/src/Plugins/Solutions/DevTools/Catalog/IGrpcServiceCatalog.cs b/src/Plugins/Solutions/DevTools/Catalog/IGrpcServiceCatalog.cs
new file mode 100644
index 0000000..2447a05
--- /dev/null
+++ b/src/Plugins/Solutions/DevTools/Catalog/IGrpcServiceCatalog.cs
@@ -0,0 +1,43 @@
+using Google.Protobuf.Reflection;
+
+namespace DevTools.Catalog;
+
+///
+/// Provides the gRPC services available in the current process together with
+/// the protobuf schema required to render and invoke their methods.
+///
+public interface IGrpcServiceCatalog
+{
+ ///
+ /// Gets the discovered gRPC services ordered by full name.
+ ///
+ ///
+ /// A collection of describing the
+ /// discovered services and their methods.
+ ///
+ IReadOnlyList GetServices();
+
+ ///
+ /// Locates a method by its full method name.
+ ///
+ /// The full name of the service.
+ /// The name of the method.
+ ///
+ /// The for the matching method,
+ /// or null when no such method exists in the catalog.
+ ///
+ GrpcMethodCatalogEntry? TryGetMethod(string serviceName, string methodName);
+}
+
+///
+/// Catalog entry carrying the live Protobuf reflection objects needed to
+/// serialize and invoke the method.
+///
+///
+/// The is required at invocation time so the
+/// dynamic marshallers can be built
+/// without generated client stubs.
+///
+public sealed record GrpcMethodCatalogEntry(
+ GrpcMethodInfo Info,
+ MethodDescriptor Descriptor);
\ No newline at end of file
diff --git a/src/Plugins/Solutions/DevTools/DevTools.csproj b/src/Plugins/Solutions/DevTools/DevTools.csproj
new file mode 100644
index 0000000..8965d52
--- /dev/null
+++ b/src/Plugins/Solutions/DevTools/DevTools.csproj
@@ -0,0 +1,25 @@
+
+
+ net10.0
+ preview
+ enable
+ enable
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/Plugins/Solutions/DevTools/DevToolsPlugin.cs b/src/Plugins/Solutions/DevTools/DevToolsPlugin.cs
new file mode 100644
index 0000000..e067dbd
--- /dev/null
+++ b/src/Plugins/Solutions/DevTools/DevToolsPlugin.cs
@@ -0,0 +1,90 @@
+using DevTools.Options;
+using AuthKit.Plugins.Abstractions;
+using AuthKit.Plugins.Abstractions.Contracts;
+using AuthKit.Plugins.Abstractions.Contracts.Plugins;
+using DevTools.Catalog;
+using DevTools.Middleware;
+using DevTools.Runtime;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace DevTools;
+
+///
+/// AuthKit developer tools plugin responsible for exposing Swagger UI for the
+/// REST API and a web interface for browsing and invoking gRPC services.
+///
+///
+///
+/// The plugin serves the Swagger UI for the host's OpenAPI document and
+/// provides a single-page interface for inspecting gRPC services, their
+/// methods, and messages, and executing unary requests.
+///
+///
+/// The Host core provides the OpenAPI document itself and the gRPC services
+/// through dependency injection. The plugin does not generate Swagger
+/// documents and does not require generated gRPC client stubs; methods are
+/// invoked dynamically using the protobuf descriptors returned by the
+/// catalog.
+///
+///
+[PluginMetadata(
+ id: "authkit.devtools",
+ version: "1.0.0",
+ tags: ["developer", "tools", "grpc", "swagger"],
+ dependsOn: [],
+ capabilities: ["developer-tools"],
+ name: "DevTools",
+ displayName: "Developer Tools",
+ description: "Swagger UI for REST and web UI for gRPC services.",
+ author: "AuthKit Contributors",
+ license: "MIT",
+ licenseUrl: "https://opensource.org/licenses/MIT",
+ homepage: "https://example.org/devtools",
+ repositoryUrl: "https://example.org/devtools.git"
+)]
+public sealed class DevToolsPlugin : IAuthKitPlugin
+{
+ ///
+ /// Registers the gRPC service catalog, invocation runtime, and Swagger
+ /// serving components in the dependency injection container.
+ ///
+ /// The used to register plugin services.
+ /// Application configuration used to configure .
+ public void ConfigureServices(IServiceCollection services, IConfiguration configuration)
+ {
+ services.Configure(configuration.GetSection("DevTools"));
+
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ }
+
+ ///
+ /// The middleware serving the Swagger UI and the gRPC UI together with
+ /// their JSON APIs.
+ ///
+ public Type MiddlewareType => typeof(DevToolsMiddleware);
+
+ ///
+ /// Verifies that the gRPC service catalog is resolvable and can be
+ /// enumerated.
+ ///
+ /// The root service provider of the host application.
+ /// true when the catalog is available; otherwise, false.
+ public Task CheckHealthAsync(IServiceProvider services)
+ {
+ var catalog = services.GetService();
+ if (catalog is null) return Task.FromResult(false);
+
+ try
+ {
+ _ = catalog.GetServices();
+ return Task.FromResult(true);
+ }
+ catch
+ {
+ return Task.FromResult(false);
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/Plugins/Solutions/DevTools/Middleware/DevToolsMiddleware.cs b/src/Plugins/Solutions/DevTools/Middleware/DevToolsMiddleware.cs
new file mode 100644
index 0000000..f5045f3
--- /dev/null
+++ b/src/Plugins/Solutions/DevTools/Middleware/DevToolsMiddleware.cs
@@ -0,0 +1,217 @@
+using System.Text;
+using System.Text.Json;
+using DevTools.Catalog;
+using DevTools.Runtime;
+using DevTools.Options;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Http.Json;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+
+namespace DevTools.Middleware;
+
+///
+/// Serves the developer tooling contributed by the plugin: Swagger UI for the
+/// REST API and the gRPC UI for browsing and invoking gRPC methods.
+///
+///
+///
+/// The middleware owns the routing for the tool pages and their JSON APIs.
+/// It also serves a small landing page linking both tools.
+///
+///
+/// All other paths are delegated to the remaining pipeline, so authentication,
+/// REST endpoints, and gRPC endpoints continue to work unchanged.
+///
+///
+public sealed class DevToolsMiddleware(RequestDelegate next, IOptions options,
+ IGrpcServiceCatalog catalog, GrpcDynamicInvoker invoker, SwaggerHost swagger,
+ IHostEnvironment environment, ILogger logger)
+{
+ private static readonly Lazy GrpcUiPage = new(LoadEmbeddedPage);
+
+ ///
+ /// Routes the request to the gRPC UI, Swagger UI, or landing page, and
+ /// otherwise delegates to the remaining pipeline.
+ ///
+ /// The current HTTP request context.
+ public async Task InvokeAsync(HttpContext context)
+ {
+ var grpcPathBase = options.Value.GrpcUiPathBase.TrimEnd('/');
+
+ if (PathMatches(context, grpcPathBase, out var grpcRelative))
+ {
+ await ServeGrpcUiAsync(context, grpcRelative);
+ return;
+ }
+
+ var swaggerEnabled = options.Value.Swagger.Enabled ?? environment.IsDevelopment();
+ if (swaggerEnabled && await swagger.TryServeAsync(context))
+ return;
+
+ if (PathMatches(context, options.Value.PathBase, out _))
+ {
+ await ServeLandingAsync(context);
+ return;
+ }
+
+ await next(context);
+ }
+
+ ///
+ /// Determines whether the request path starts with the given prefix and
+ /// returns the remainder of the path.
+ ///
+ private static bool PathMatches(HttpContext context, string pathBase, out string relative)
+ {
+ var path = context.Request.Path.Value ?? "/";
+ relative = "";
+
+ if (string.IsNullOrEmpty(pathBase))
+ return false;
+
+ if (!path.StartsWith(pathBase, StringComparison.OrdinalIgnoreCase))
+ return false;
+
+ var rest = path[pathBase.Length..];
+ if (rest.Length > 0 && !rest.StartsWith('/'))
+ return false;
+
+ relative = rest;
+ return true;
+ }
+
+ ///
+ /// Serves the gRPC UI page and its JSON API endpoints.
+ ///
+ private async Task ServeGrpcUiAsync(HttpContext context, string relative)
+ {
+ switch (relative)
+ {
+ case "":
+ context.Response.Redirect(options.Value.GrpcUiPathBase.TrimEnd('/') + "/");
+ return;
+ case "/":
+ case "/index.html":
+ {
+ context.Response.ContentType = "text/html; charset=utf-8";
+ await context.Response.WriteAsync(RenderGrpcUiPage(), context.RequestAborted);
+ return;
+ }
+ case "/api/services":
+ await ApiServicesAsync(context);
+ return;
+ case "/api/invoke" when HttpMethods.IsPost(context.Request.Method):
+ await ApiInvokeAsync(context);
+ return;
+ default:
+ context.Response.StatusCode = StatusCodes.Status404NotFound;
+ return;
+ }
+ }
+
+ ///
+ /// Serves the landing page linking the Swagger UI and the gRPC UI.
+ ///
+ private async Task ServeLandingAsync(HttpContext context)
+ {
+ var grpcPathBase = options.Value.GrpcUiPathBase.TrimEnd('/');
+ var swaggerPrefix = "/" + options.Value.Swagger.RoutePrefix.Trim('/');
+
+ context.Response.ContentType = "text/html; charset=utf-8";
+ await context.Response.WriteAsync(
+ """
+
+
+ AuthKit DevTools
+
+