From 65a480515b0f09317c4482c2228b836900af47db Mon Sep 17 00:00:00 2001 From: Ben Richards Date: Mon, 7 Sep 2026 08:56:50 +0300 Subject: [PATCH 1/2] =?UTF-8?q?D5-S14=20=E2=80=94=20Mcp:=20the=20frozen=20?= =?UTF-8?q?catalogue=20and=20its=20startup=20checks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the new SubZeroDev.Platform.Mcp module: ToolName, ToolProducerName, ToolDefinition, ToolRegistration, IToolProducer and IToolCatalogue per design/20-contract.md § Public surface 10, plus McpOptions as the module's own exposure-configuration surface (the contract names the concept without declaring its shape). McpStartupValidation runs every registered producer once at startup, rejects a tool whose parameter schema names a redaction-marker parameter (HostStartupError.SensitiveToolParameter) or whose required permission no catalog declares (HostStartupError.UnregisteredPermission), and freezes the catalogue — Mcp references Hosting directly for this (a module referencing a framework package, allowed by ADR-006 rule 1/I-C7), since the two new HostStartupError variants live there and no other module may reach Mcp to raise them on its behalf. Ordering is safe by the generic host's own phase separation: PlatformRegistryStartup populates and freezes the permission catalog registry in the StartingAsync phase, which completes for every hosted service before any StartAsync phase begins — including McpStartupValidation's. The SDK package reference itself is deferred to S15 ("the SDK transport and session" per the slice's own Touches): nothing in S14's acceptance criteria requires invoking it, and an unused PackageReference produces no actual assembly reference to check against, so I-M9's architecture test (PackageGraphTests) proves the containment property ahead of anything crossing it, on the same fixture-first discipline as I-C6/I-C7/I-C8. Co-Authored-By: Claude Sonnet 5 --- SubZeroDev.Platform.slnx | 1 + .../StartupFailure.cs | 15 + src/SubZeroDev.Platform.Mcp/McpModule.cs | 34 +++ src/SubZeroDev.Platform.Mcp/McpOptions.cs | 13 + .../McpStartupValidation.cs | 103 +++++++ .../SubZeroDev.Platform.Mcp.csproj | 19 ++ src/SubZeroDev.Platform.Mcp/Tool.cs | 89 ++++++ src/SubZeroDev.Platform.Mcp/ToolCatalogue.cs | 45 +++ tests/SubZeroDev.Platform.Tests/McpTests.cs | 277 ++++++++++++++++++ .../PackageGraphTests.cs | 238 +++++++++++++++ .../SubZeroDev.Platform.Tests.csproj | 1 + 11 files changed, 835 insertions(+) create mode 100644 src/SubZeroDev.Platform.Mcp/McpModule.cs create mode 100644 src/SubZeroDev.Platform.Mcp/McpOptions.cs create mode 100644 src/SubZeroDev.Platform.Mcp/McpStartupValidation.cs create mode 100644 src/SubZeroDev.Platform.Mcp/SubZeroDev.Platform.Mcp.csproj create mode 100644 src/SubZeroDev.Platform.Mcp/Tool.cs create mode 100644 src/SubZeroDev.Platform.Mcp/ToolCatalogue.cs create mode 100644 tests/SubZeroDev.Platform.Tests/McpTests.cs diff --git a/SubZeroDev.Platform.slnx b/SubZeroDev.Platform.slnx index 60eacd1..9f57985 100644 --- a/SubZeroDev.Platform.slnx +++ b/SubZeroDev.Platform.slnx @@ -12,6 +12,7 @@ + diff --git a/src/SubZeroDev.Platform.Hosting/StartupFailure.cs b/src/SubZeroDev.Platform.Hosting/StartupFailure.cs index cb5a2f1..cbe772b 100644 --- a/src/SubZeroDev.Platform.Hosting/StartupFailure.cs +++ b/src/SubZeroDev.Platform.Hosting/StartupFailure.cs @@ -96,6 +96,21 @@ public static HostStartupError UndeclaredEndpointRequirement(string route) => $"Endpoint '{route}' carries neither a platform authorization requirement nor a named " + "exemption. Call RequiresPlatformAuthorization or ExemptFromPlatformAuthorization on it.", null); + + /// A registered tool's schema names a parameter matching the redaction marker set — + /// I-M2. A tool that could ask for a password never gets as far as running. + /// The tool and the offending parameter. + /// The error. + public static HostStartupError SensitiveToolParameter(string detail) => + new(nameof(SensitiveToolParameter), detail, null); + + /// A tool, an endpoint, or any registration requires a no + /// catalog declares. A startup-detectable defect, never a runtime denial. + /// The permission catalog's own rejection. + /// The tool or registration and the undeclared name. + /// The error. + public static HostStartupError UnregisteredPermission(PlatformError? inner, string detail) => + new(nameof(UnregisteredPermission), detail, inner); } /// A fatal condition at host build or start. Distinct from diff --git a/src/SubZeroDev.Platform.Mcp/McpModule.cs b/src/SubZeroDev.Platform.Mcp/McpModule.cs new file mode 100644 index 0000000..125a489 --- /dev/null +++ b/src/SubZeroDev.Platform.Mcp/McpModule.cs @@ -0,0 +1,34 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Hosting; +using SubZeroDev.Platform.Abstractions; + +namespace SubZeroDev.Platform.Mcp; + +/// D5-S14's module: the frozen tool catalogue and its startup checks. A host also +/// registers one or more implementations of its own — a manifest +/// projection, a product-owned fixed table, or both — and may register a +/// naming which of the tools they supply are exposed. Depends only on the framework's own +/// contracts — no knowledge of Identity, Organizations, Billing or Licensing. +public sealed class McpModule : IPlatformModule +{ + /// + public ModuleName Name { get; } = new("Mcp"); + + /// + public IReadOnlyCollection DependsOn { get; } = []; + + /// + public void Register(IServiceCollection services) + { + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(provider => provider.GetRequiredService()); + + // Runs once, at startup, and never again — the frozen catalogue this produces is what + // I-M1 requires. A plain IHostedService, exactly like Licensing's own startup-only + // verification: a Platform background-work registration is a recurring tick by + // construction, and this must not become one. + services.TryAddEnumerable(ServiceDescriptor.Singleton()); + } +} diff --git a/src/SubZeroDev.Platform.Mcp/McpOptions.cs b/src/SubZeroDev.Platform.Mcp/McpOptions.cs new file mode 100644 index 0000000..3fbfe49 --- /dev/null +++ b/src/SubZeroDev.Platform.Mcp/McpOptions.cs @@ -0,0 +1,13 @@ +namespace SubZeroDev.Platform.Mcp; + +/// Which registered tools configuration exposes. Not part of the contract's declared +/// types (design/20-contract.md § Public surface 10 says only that "Mcp exposes tool producer +/// registration and exposure configuration" without naming its shape) — this is the module's own +/// answer, and default closed: a tool absent from is registered but +/// never listed or callable (I-M3). +public sealed class McpOptions +{ + /// The names of every tool configuration exposes. Empty by default, so installing a + /// producer never exposes anything on its own — a deployment must say so explicitly. + public IReadOnlySet ExposedTools { get; init; } = new HashSet(); +} diff --git a/src/SubZeroDev.Platform.Mcp/McpStartupValidation.cs b/src/SubZeroDev.Platform.Mcp/McpStartupValidation.cs new file mode 100644 index 0000000..51213bf --- /dev/null +++ b/src/SubZeroDev.Platform.Mcp/McpStartupValidation.cs @@ -0,0 +1,103 @@ +using System.Text.Json; +using Microsoft.Extensions.Hosting; +using SubZeroDev.Platform.Core; +using SubZeroDev.Platform.Hosting; + +namespace SubZeroDev.Platform.Mcp; + +/// Runs every registered producer once, validates every definition it supplies, and +/// freezes the catalogue. A plain rather than +/// : its work belongs in the StartAsync phase, which the +/// generic host runs for every hosted service only after every StartingAsync hook has +/// completed — so is already +/// populated and frozen by PlatformRegistryStartup's own StartingAsync by the time +/// this runs, and answers against the whole +/// composition rather than whatever had registered first. +internal sealed class McpStartupValidation( + IEnumerable producers, + McpOptions options, + IPermissionCatalogRegistry permissionCatalogs, + ToolCatalogue catalogue) : IHostedService +{ + public async Task StartAsync(CancellationToken cancellationToken) + { + var registrations = new List(); + + foreach (var producer in producers) + { + var definitions = await producer.ProduceAsync(cancellationToken).ConfigureAwait(false); + + foreach (var definition in definitions) + { + var sensitiveParameter = FindSensitiveParameter(definition.ParameterSchema); + if (sensitiveParameter is not null) + { + throw new PlatformStartupException(HostStartupError.SensitiveToolParameter( + $"Tool '{definition.Name}' (producer '{producer.Name}') names parameter " + + $"'{sensitiveParameter}', which matches the redaction marker set.")); + } + + var declared = permissionCatalogs.EnsureDeclared(definition.RequiredPermission); + if (!declared.IsSuccess) + { + throw new PlatformStartupException(HostStartupError.UnregisteredPermission( + declared.Error, + $"Tool '{definition.Name}' (producer '{producer.Name}') requires permission " + + $"'{definition.RequiredPermission}', which no registered catalog declares.")); + } + + registrations.Add(new ToolRegistration( + definition, + producer.Name, + options.ExposedTools.Contains(definition.Name))); + } + } + + catalogue.Initialize(registrations); + } + + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + /// Walks a JSON Schema document's properties (and, for an array, its + /// items) looking for a key the redaction marker set treats as sensitive. Recurses into + /// nested object schemas, since a parameter that is itself an object can name a sensitive field + /// one level down. + /// The schema to search. + /// The first sensitive parameter name found, or . + private static string? FindSensitiveParameter(JsonElement schema) + { + if (schema.ValueKind != JsonValueKind.Object) + { + return null; + } + + if (schema.TryGetProperty("properties", out var properties) + && properties.ValueKind == JsonValueKind.Object) + { + foreach (var property in properties.EnumerateObject()) + { + if (Redaction.IsSensitiveKey(property.Name)) + { + return property.Name; + } + + var nested = FindSensitiveParameter(property.Value); + if (nested is not null) + { + return nested; + } + } + } + + if (schema.TryGetProperty("items", out var items)) + { + var nested = FindSensitiveParameter(items); + if (nested is not null) + { + return nested; + } + } + + return null; + } +} diff --git a/src/SubZeroDev.Platform.Mcp/SubZeroDev.Platform.Mcp.csproj b/src/SubZeroDev.Platform.Mcp/SubZeroDev.Platform.Mcp.csproj new file mode 100644 index 0000000..20b42db --- /dev/null +++ b/src/SubZeroDev.Platform.Mcp/SubZeroDev.Platform.Mcp.csproj @@ -0,0 +1,19 @@ + + + + + false + + + + + + + + + + + + + + diff --git a/src/SubZeroDev.Platform.Mcp/Tool.cs b/src/SubZeroDev.Platform.Mcp/Tool.cs new file mode 100644 index 0000000..a2e3d38 --- /dev/null +++ b/src/SubZeroDev.Platform.Mcp/Tool.cs @@ -0,0 +1,89 @@ +using System.Text.Json; +using SubZeroDev.Platform.Abstractions; + +namespace SubZeroDev.Platform.Mcp; + +/// A tool's name, unique across every producer. +/// The stable name. +public readonly record struct ToolName(string Value) +{ + /// The stable name. + public string Value { get; } = Value ?? throw new ArgumentNullException(nameof(Value)); + + /// + public override string ToString() => Value; +} + +/// Which producer supplied a definition. +/// The producer's name. +public readonly record struct ToolProducerName(string Value) +{ + /// The producer's name. + public string Value { get; } = Value ?? throw new ArgumentNullException(nameof(Value)); + + /// + public override string ToString() => Value; +} + +/// A tool as its producer supplies it. Carries no exposure: a producer cannot expose +/// itself (design/20-contract.md § Public surface 10 — exposure is a fact configuration decides, +/// never one a producer can assert). +/// The tool's name, unique across every producer. +/// A human-readable description of what the tool does. +/// The tool's parameter schema, as the producer supplies it. A +/// because a manifest-projecting producer has no .NET method to infer a +/// schema from — a shape that could only derive one by reflection would privilege the other kind +/// of producer. +/// The permission a caller must hold to invoke this tool. Not +/// optional: an unauthenticated or unauthorized tool is expressed as a permission the composition +/// grants, never as an absent check. +/// The paid feature this tool admits new work under, or +/// when the tool admits no new paid-feature work — on the same terms as an +/// endpoint that only reads. +public sealed record ToolDefinition( + ToolName Name, + string Description, + JsonElement ParameterSchema, + PermissionName RequiredPermission, + FeatureName? RequiredFeature); + +/// A definition after configuration has decided its exposure. What the frozen catalogue +/// holds. +/// The tool as its producer supplied it. +/// The producer that supplied . +/// Whether configuration exposes this tool. An unexposed registration is +/// still held by the catalogue's internal bookkeeping, but offers no +/// route to it. +public sealed record ToolRegistration( + ToolDefinition Definition, + ToolProducerName Producer, + bool IsExposed); + +/// Supplies definitions at startup. A manifest-projecting producer and a product-owned +/// fixed-table producer both implement this, and neither is privileged over the other (I-M7). +public interface IToolProducer +{ + /// The producer's name, unique among registered producers. + ToolProducerName Name { get; } + + /// Produces every definition this producer supplies. Runs once, at startup, and + /// nothing calls it again — the catalogue it feeds is frozen afterwards (I-M1). + /// Cancels the production. + /// The definitions this producer supplies. + ValueTask> ProduceAsync(CancellationToken cancellationToken); +} + +/// The catalogue, frozen after startup. +public interface IToolCatalogue +{ + /// Every exposed registration. An unexposed one is not here and is not reachable from + /// here — there is no member by which a caller could reach it (I-M10). + IReadOnlyCollection Exposed { get; } + + /// Looks up an exposed tool. Unregistered and unexposed both answer + /// — the two must never be distinguished (I-M4). + /// The tool name. + /// The registration, when found and exposed. + /// when names an exposed tool. + bool TryGetExposed(ToolName name, out ToolRegistration registration); +} diff --git a/src/SubZeroDev.Platform.Mcp/ToolCatalogue.cs b/src/SubZeroDev.Platform.Mcp/ToolCatalogue.cs new file mode 100644 index 0000000..ec65940 --- /dev/null +++ b/src/SubZeroDev.Platform.Mcp/ToolCatalogue.cs @@ -0,0 +1,45 @@ +namespace SubZeroDev.Platform.Mcp; + +/// +/// A holder rather than a value built at construction: the catalogue is registered once +/// into the container, before startup has run any producer, and replaces +/// its content exactly once, from . One reference assignment, so +/// a concurrent reader sees either the empty catalogue or the whole frozen one, never a +/// half-populated set. +internal sealed class ToolCatalogue : IToolCatalogue +{ + private volatile CatalogueSnapshot _snapshot = new( + new Dictionary(), + []); + + /// + public IReadOnlyCollection Exposed => _snapshot.Exposed; + + /// + public bool TryGetExposed(ToolName name, out ToolRegistration registration) + { + if (_snapshot.ByName.TryGetValue(name, out var found) && found.IsExposed) + { + registration = found; + return true; + } + + registration = default!; + return false; + } + + /// Freezes the catalogue's content. Called exactly once, at startup, after every + /// producer has run and every registration has passed validation (I-M1) — nothing calls this + /// again afterward, and nothing else in this module's public surface could. + /// Every registration, exposed and unexposed alike. + internal void Initialize(IReadOnlyCollection registrations) + { + var byName = registrations.ToDictionary(registration => registration.Definition.Name); + var exposed = registrations.Where(registration => registration.IsExposed).ToList(); + _snapshot = new CatalogueSnapshot(byName, exposed); + } + + private sealed record CatalogueSnapshot( + IReadOnlyDictionary ByName, + IReadOnlyCollection Exposed); +} diff --git a/tests/SubZeroDev.Platform.Tests/McpTests.cs b/tests/SubZeroDev.Platform.Tests/McpTests.cs new file mode 100644 index 0000000..a042e0b --- /dev/null +++ b/tests/SubZeroDev.Platform.Tests/McpTests.cs @@ -0,0 +1,277 @@ +using System.Reflection; +using System.Text.Json; +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; +using SubZeroDev.Platform.Abstractions; +using SubZeroDev.Platform.Core; +using SubZeroDev.Platform.Hosting; +using SubZeroDev.Platform.Mcp; + +namespace SubZeroDev.Platform.Tests; + +/// D5-S14: a product can offer tools to an AI client from two independent sources — a +/// manifest it ships and its own code — with neither privileged over the other, nothing exposed +/// until configuration says so, and a tool that could ask for a password never reaching a running +/// catalogue. +public sealed class McpTests +{ + private static readonly PermissionName SamplePermission = new("Sample.Tool.Use"); + + /// S14.1 — a manifest-projecting producer and a product-owned fixed-table producer + /// both register through , and the catalogue treats them + /// identically: no ordering, capability or schema-derivation difference between them. + [Fact] + public async Task Two_independent_producers_are_treated_identically_by_the_catalogue() + { + var manifestProjecting = new StubToolProducer( + "manifest", + new ToolDefinition(new ToolName("from-manifest"), "A manifest tool.", Schema(), SamplePermission, null)); + var fixedTable = new StubToolProducer( + "fixed-table", + new ToolDefinition(new ToolName("from-fixed-table"), "A fixed-table tool.", Schema(), SamplePermission, null)); + + var (app, catalogue) = await StartWithProducersAsync( + [manifestProjecting, fixedTable], + exposed: [new ToolName("from-manifest"), new ToolName("from-fixed-table")]); + + try + { + Assert.Equal(2, catalogue.Exposed.Count); + Assert.True(catalogue.TryGetExposed(new ToolName("from-manifest"), out var first)); + Assert.True(catalogue.TryGetExposed(new ToolName("from-fixed-table"), out var second)); + Assert.Equal(new ToolProducerName("manifest"), first.Producer); + Assert.Equal(new ToolProducerName("fixed-table"), second.Producer); + } + finally + { + await app.DisposeAsync(); + } + } + + /// S14.2 — declares no exposure member. Exposure comes + /// only from configuration. + [Fact] + public void ToolDefinition_declares_no_exposure_member() + { + var members = typeof(ToolDefinition).GetProperties(BindingFlags.Public | BindingFlags.Instance) + .Select(property => property.Name); + + Assert.DoesNotContain(members, name => name.Contains("Expos", StringComparison.OrdinalIgnoreCase)); + } + + /// S14.3 — a registered tool absent from the exposure configuration is not in the + /// catalogue's exposed set, and looking it up answers exactly what looking up a name that was + /// never registered answers. + [Fact] + public async Task A_registered_but_unexposed_tool_answers_identically_to_an_unregistered_name() + { + var producer = new StubToolProducer( + "producer", + new ToolDefinition(new ToolName("hidden"), "Not exposed.", Schema(), SamplePermission, null)); + + var (app, catalogue) = await StartWithProducersAsync([producer], exposed: []); + + try + { + var hiddenFound = catalogue.TryGetExposed(new ToolName("hidden"), out var hiddenRegistration); + var neverRegisteredFound = catalogue.TryGetExposed(new ToolName("never-registered"), out var neverRegistration); + + Assert.False(hiddenFound); + Assert.False(neverRegisteredFound); + Assert.Equal(neverRegistration, hiddenRegistration); + Assert.DoesNotContain(catalogue.Exposed, registration => registration.Definition.Name == new ToolName("hidden")); + } + finally + { + await app.DisposeAsync(); + } + } + + /// S14.4 — declares no member reaching an unexposed + /// registration: no enumeration of all registrations, no exposure-ignoring lookup. + [Fact] + public void IToolCatalogue_declares_no_member_reaching_an_unexposed_registration() + { + Assert.Equal( + ["Exposed", "TryGetExposed"], + DeclaredMemberNames(typeof(IToolCatalogue)).OrderBy(name => name, StringComparer.Ordinal)); + } + + /// S14.5 — a registered tool whose parameter schema names a parameter matching the + /// redaction marker set fails startup with HostStartupError.SensitiveToolParameter, + /// naming the tool and the parameter. + [Fact] + public async Task A_tool_naming_a_sensitive_parameter_fails_startup_naming_the_tool_and_parameter() + { + var producer = new StubToolProducer( + "producer", + new ToolDefinition( + new ToolName("leaky-tool"), + "Names a sensitive parameter.", + Schema("apiKey"), + SamplePermission, + null)); + + var error = await RefusedAsync([producer], exposed: [new ToolName("leaky-tool")]); + + Assert.Equal(nameof(HostStartupError.SensitiveToolParameter), error.Code); + Assert.Contains("leaky-tool", error.Detail, StringComparison.Ordinal); + Assert.Contains("apiKey", error.Detail, StringComparison.Ordinal); + } + + /// S14.6 — a tool requiring a permission no catalog declares fails startup with + /// HostStartupError.UnregisteredPermission. + [Fact] + public async Task A_tool_requiring_an_undeclared_permission_fails_startup() + { + var producer = new StubToolProducer( + "producer", + new ToolDefinition( + new ToolName("orphaned-tool"), + "Requires a permission nothing declares.", + Schema(), + new PermissionName("Nothing.Declares.This"), + null)); + + var error = await RefusedAsync([producer], exposed: [new ToolName("orphaned-tool")], declareSamplePermission: false); + + Assert.Equal(nameof(HostStartupError.UnregisteredPermission), error.Code); + Assert.Contains("orphaned-tool", error.Detail, StringComparison.Ordinal); + Assert.Contains("Nothing.Declares.This", error.Detail, StringComparison.Ordinal); + } + + /// S14.7 — each producer's production runs once at startup and never again, and the + /// catalogue exposes no registration, unregistration or re-exposure member. + [Fact] + public async Task Each_producer_runs_exactly_once_and_the_catalogue_exposes_no_mutation_member() + { + var producer = new StubToolProducer( + "producer", + new ToolDefinition(new ToolName("counted-tool"), "Counted.", Schema(), SamplePermission, null)); + + var (app, catalogue) = await StartWithProducersAsync([producer], exposed: [new ToolName("counted-tool")]); + + try + { + // Resolving the catalogue and reading it repeatedly must not re-run production. + _ = catalogue.Exposed; + _ = catalogue.TryGetExposed(new ToolName("counted-tool"), out _); + + Assert.Equal(1, producer.CallCount); + + // I-M1's structural half: the catalogue's public surface has exactly Exposed and + // TryGetExposed — no Register, Unregister or re-Expose member exists to call. + Assert.Equal( + ["Exposed", "TryGetExposed"], + DeclaredMemberNames(typeof(IToolCatalogue)).OrderBy(name => name, StringComparer.Ordinal)); + } + finally + { + await app.DisposeAsync(); + } + } + + /// Member names on , excluding compiler-generated property + /// accessors — so a property counts once, under its own name, rather than once again as + /// get_Name. + private static IEnumerable DeclaredMemberNames(Type type) => + type.GetMembers(BindingFlags.Public | BindingFlags.Instance) + .Where(member => member is not MethodInfo method || !method.IsSpecialName) + .Select(member => member.Name); + + private static JsonElement Schema(params string[] parameterNames) + { + using var stream = new MemoryStream(); + using (var writer = new Utf8JsonWriter(stream)) + { + writer.WriteStartObject(); + writer.WriteString("type", "object"); + writer.WriteStartObject("properties"); + foreach (var name in parameterNames) + { + writer.WriteStartObject(name); + writer.WriteString("type", "string"); + writer.WriteEndObject(); + } + + writer.WriteEndObject(); + writer.WriteEndObject(); + } + + return JsonDocument.Parse(stream.ToArray()).RootElement; + } + + private static async Task<(WebApplication App, IToolCatalogue Catalogue)> StartWithProducersAsync( + IReadOnlyCollection producers, + IReadOnlyCollection exposed) + { + var (app, _) = await WebHostUnderTest.StartAsync(services => + { + services.AddSingleton(); + services.AddSingleton(new McpOptions { ExposedTools = exposed.ToHashSet() }); + foreach (var producer in producers) + { + services.AddSingleton(producer); + } + + services.AddSingleton(new StubPermissionCatalog(SamplePermission)); + }); + + return (app, app.Services.GetRequiredService()); + } + + /// Starts a host expected to refuse during startup, and returns why. + private static async Task RefusedAsync( + IReadOnlyCollection producers, + IReadOnlyCollection exposed, + bool declareSamplePermission = true) + { + WebApplication? app = null; + try + { + (app, _) = await WebHostUnderTest.StartAsync(services => + { + services.AddSingleton(); + services.AddSingleton(new McpOptions { ExposedTools = exposed.ToHashSet() }); + foreach (var producer in producers) + { + services.AddSingleton(producer); + } + + if (declareSamplePermission) + { + services.AddSingleton(new StubPermissionCatalog(SamplePermission)); + } + }); + } + catch (PlatformStartupException exception) + { + return Assert.IsType(exception.Error); + } + finally + { + if (app is not null) + { + await app.DisposeAsync(); + } + } + + throw new InvalidOperationException("The host started; it was expected to refuse."); + } +} + +/// A fixed, in-memory — stands in for both the manifest- +/// projecting and the product-owned fixed-table shape, since the contract requires the catalogue +/// treat them identically regardless of how a producer computed its definitions. +internal sealed class StubToolProducer(string name, params ToolDefinition[] definitions) : IToolProducer +{ + public ToolProducerName Name { get; } = new(name); + + public int CallCount { get; private set; } + + public ValueTask> ProduceAsync(CancellationToken cancellationToken) + { + CallCount++; + return ValueTask.FromResult>(definitions); + } +} diff --git a/tests/SubZeroDev.Platform.Tests/PackageGraphTests.cs b/tests/SubZeroDev.Platform.Tests/PackageGraphTests.cs index 7230d56..be9e1dd 100644 --- a/tests/SubZeroDev.Platform.Tests/PackageGraphTests.cs +++ b/tests/SubZeroDev.Platform.Tests/PackageGraphTests.cs @@ -104,6 +104,57 @@ public void S11_3_the_check_fails_against_a_deliberately_broken_fixture() Assert.Equal(["SubZeroDev.Platform.Organizations -> SubscriptionState"], violations); } + // S14.8 ------------------------------------------------------------------------------------- + + /// I-M9's containment half: nothing outside Mcp references the SDK. S14 does not yet + /// wire the SDK's transport in — that is S15's "the SDK transport and session" — so this proves + /// the boundary the check exists to hold, ahead of anything crossing it. + [Fact] + public void I_M9_ModelContextProtocol_is_referenced_by_no_package_other_than_Mcp() + { + var graph = SdkReferenceGuard.Resolve(AllPlatformAssemblies()); + + var violations = SdkReferenceGuard.ReferencedOutsideMcp(graph); + + Assert.Empty(violations); + } + + [Fact] + public void S14_8_the_reference_check_fails_against_a_deliberately_broken_fixture() + { + var broken = new Dictionary> + { + ["SubZeroDev.Platform.Organizations"] = new HashSet { "ModelContextProtocol.Core" }, + }; + + var violations = SdkReferenceGuard.ReferencedOutsideMcp(broken); + + Assert.Equal(["SubZeroDev.Platform.Organizations -> ModelContextProtocol.Core"], violations); + } + + [Fact] + public void I_M9_no_platform_public_type_exposes_returns_accepts_or_derives_from_an_sdk_type() + { + var map = SdkTypeSurfaceGuard.Resolve(AllPlatformAssemblies()); + + var violations = SdkTypeSurfaceGuard.Violations(map); + + Assert.Empty(violations); + } + + [Fact] + public void S14_8_the_surface_check_fails_against_a_deliberately_broken_fixture() + { + var broken = new Dictionary> + { + ["SubZeroDev.Platform.Mcp.LeakyType"] = new HashSet { "ModelContextProtocol.Protocol.Tool" }, + }; + + var violations = SdkTypeSurfaceGuard.Violations(broken); + + Assert.Equal(["SubZeroDev.Platform.Mcp.LeakyType -> ModelContextProtocol.Protocol.Tool"], violations); + } + /// Every assembly outside Billing that is built alongside this test run: the six /// framework assemblies plus every other module. Billing itself is deliberately absent — I-C8 /// bounds what may reference its types from outside it, not from within. @@ -113,6 +164,19 @@ .. FrameworkAssemblies(), typeof(SubZeroDev.Platform.Organizations.Organization).Assembly, // Organizations ]; + /// Every framework assembly plus every module assembly built alongside this test + /// run — I-M9 bounds the SDK's reach across the whole tree, not just inside Mcp. + private static IReadOnlyCollection AllPlatformAssemblies() => + [ + .. FrameworkAssemblies(), + typeof(SubZeroDev.Platform.Identity.IdentityModule).Assembly, + typeof(SubZeroDev.Platform.Organizations.Organization).Assembly, + typeof(SubZeroDev.Platform.Billing.BillingModule).Assembly, + typeof(SubZeroDev.Platform.Licensing.LicensingModule).Assembly, + typeof(SubZeroDev.Platform.Audit.AuditModule).Assembly, + typeof(SubZeroDev.Platform.Mcp.McpModule).Assembly, + ]; + private static IReadOnlyCollection FrameworkAssemblies() => [ typeof(CompositionProfile).Assembly, // Abstractions @@ -329,3 +393,177 @@ internal static IReadOnlyList Violations( return violations; } } + +/// I-M9's package-reference half: ModelContextProtocol.* is referenced by +/// SubZeroDev.Platform.Mcp and by nothing else. Same abstracted-graph mechanism as +/// , filtered to the SDK's own assembly-name prefix rather than +/// SubZeroDev.Platform.*. +internal static class SdkReferenceGuard +{ + private const string SdkAssemblyPrefix = "ModelContextProtocol"; + + /// Resolves the reference graph over a set of loaded assemblies, keeping only + /// references whose name starts with . + internal static IReadOnlyDictionary> Resolve(IReadOnlyCollection assemblies) + { + var graph = new Dictionary>(StringComparer.Ordinal); + + foreach (var assembly in assemblies) + { + var references = assembly.GetReferencedAssemblies() + .Select(referenced => referenced.Name) + .Where(referencedName => referencedName is not null + && referencedName.StartsWith(SdkAssemblyPrefix, StringComparison.Ordinal)) + .Cast() + .ToHashSet(StringComparer.Ordinal); + + graph[assembly.GetName().Name!] = references; + } + + return graph; + } + + /// I-M9: nothing but SubZeroDev.Platform.Mcp may reference the SDK. + internal static IReadOnlyList ReferencedOutsideMcp( + IReadOnlyDictionary> graph) + { + var violations = new List(); + + foreach (var (package, references) in graph) + { + if (package == "SubZeroDev.Platform.Mcp") + { + continue; + } + + violations.AddRange(references.Select(reference => $"{package} -> {reference}")); + } + + return violations; + } +} + +/// I-M9's public-surface half: no Platform public type exposes, returns, accepts or +/// derives from an SDK type. On the same two-layer shape as — +/// does the real reflection, is pure logic proved +/// first against a fixture no real build could produce. +internal static class SdkTypeSurfaceGuard +{ + private const string SdkNamespacePrefix = "ModelContextProtocol"; + + /// Maps every public type's full name to the full names of every type its public + /// surface — base type, interfaces, and public property, field and method signatures — refers + /// to, generic arguments and array element types flattened in. + internal static IReadOnlyDictionary> Resolve(IReadOnlyCollection assemblies) + { + var map = new Dictionary>(StringComparer.Ordinal); + + foreach (var assembly in assemblies) + { + foreach (var type in assembly.GetExportedTypes()) + { + var referenced = SurfaceTypeNames(type); + if (referenced.Count > 0) + { + map[type.FullName ?? type.Name] = referenced; + } + } + } + + return map; + } + + /// I-M9: nothing in may name an SDK type. + internal static IReadOnlyList Violations( + IReadOnlyDictionary> typeReferencesByType) + { + var violations = new List(); + + foreach (var (type, referenced) in typeReferencesByType) + { + violations.AddRange( + referenced + .Where(name => name.StartsWith(SdkNamespacePrefix, StringComparison.Ordinal)) + .Select(name => $"{type} -> {name}")); + } + + return violations; + } + + private static IReadOnlySet SurfaceTypeNames(Type type) + { + var names = new HashSet(StringComparer.Ordinal); + + void Add(Type candidate) + { + foreach (var flattened in Flatten(candidate)) + { + if (flattened.FullName is { } fullName) + { + names.Add(fullName); + } + } + } + + if (type.BaseType is not null) + { + Add(type.BaseType); + } + + foreach (var implemented in type.GetInterfaces()) + { + Add(implemented); + } + + foreach (var member in type.GetMembers( + BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static | BindingFlags.DeclaredOnly)) + { + switch (member) + { + case PropertyInfo property: + Add(property.PropertyType); + break; + case FieldInfo field: + Add(field.FieldType); + break; + case MethodInfo method when !method.IsSpecialName: + Add(method.ReturnType); + foreach (var parameter in method.GetParameters()) + { + Add(parameter.ParameterType); + } + + break; + } + } + + return names; + } + + /// Yields itself, then every generic argument and array + /// element type it carries, recursively — so Task<IReadOnlyCollection<Tool>> + /// surfaces Tool rather than hiding it inside the wrapper types. + private static IEnumerable Flatten(Type type) + { + yield return type; + + if (type.IsGenericType) + { + foreach (var argument in type.GetGenericArguments()) + { + foreach (var nested in Flatten(argument)) + { + yield return nested; + } + } + } + + if (type.HasElementType) + { + foreach (var nested in Flatten(type.GetElementType()!)) + { + yield return nested; + } + } + } +} diff --git a/tests/SubZeroDev.Platform.Tests/SubZeroDev.Platform.Tests.csproj b/tests/SubZeroDev.Platform.Tests/SubZeroDev.Platform.Tests.csproj index 494e3db..8fc5621 100644 --- a/tests/SubZeroDev.Platform.Tests/SubZeroDev.Platform.Tests.csproj +++ b/tests/SubZeroDev.Platform.Tests/SubZeroDev.Platform.Tests.csproj @@ -37,6 +37,7 @@ + From e947415e981e8f4be3a771ac216d96f365c046fc Mon Sep 17 00:00:00 2001 From: Ben Richards Date: Mon, 7 Sep 2026 09:21:34 +0300 Subject: [PATCH 2/2] Record the /verify gate run for the Mcp catalogue slice Full discovered gate suite ran and passed except the Linux-only sample round-trip script, which cannot run on this Windows session and defers to build.yml's own ubuntu-latest run. --- .claude/verify-report.json | 193 +++++++++++++++++-------------------- 1 file changed, 90 insertions(+), 103 deletions(-) diff --git a/.claude/verify-report.json b/.claude/verify-report.json index 26a4435..5e14175 100644 --- a/.claude/verify-report.json +++ b/.claude/verify-report.json @@ -1,107 +1,94 @@ { - "pullRequest": 218, - "headSha": "a745dc912faf4db8c5c6bdda5b74e9660b98e9a6", - "discoveredBy": "# verification: true flags in .github/workflows/*.yml", "gates": [ - { - "name": "build.yml / build — Test", - "status": "Passed", - "detail": "dotnet build --configuration Release then dotnet test --no-build --configuration Release --verbosity normal: Total tests 422, Passed 422, Failed 0, Total time 47.8321 Seconds; build succeeded with 0 Warning(s), 0 Error(s)." - }, - { - "name": "build.yml / build — Run the sample in both roles", - "status": "DidNotRun", - "reason": "build/Test-SampleRoundTrip.ps1 is Linux-only by its own docstring (\"Linux-only, and it always was: it signals processes and reads the store with sqlite3\") — it P/Invokes libc's kill(2) for SIGTERM/SIGKILL. On this Windows host it printed \"Both roles served their probes in Production.\" and then exited 1 at the first signal: \"Test-SampleRoundTrip.ps1: Exception calling \\\"kill\\\" with \\\"2\\\" argument(s): \\\"Value cannot be null. (Parameter 'path1')\\\"\". The outbox-survives-process-death half of the gate was therefore never evaluated; the CI check on this pull request is where its answer comes from." - }, - { - "name": "build.yml / build — Assert no framework project references a workload", - "status": "Passed", - "detail": "pwsh ./build/Test-WorkloadIsolation.ps1, exit 0: \"No project under src/ or samples/ references workloads/ (40 project file(s) checked).\"" - }, - { - "name": "build.yml / game-service — Typecheck", - "status": "Passed", - "detail": "npm run typecheck in workloads/game-service (tsc --noEmit), exit 0, no diagnostics." - }, - { - "name": "build.yml / game-service — Start the workload and the edge, the documented way", - "status": "Passed", - "detail": "The step's own commands, run locally: workload livez {\"status\":\"healthy\"}, readyz {\"status\":\"healthy\"}; edge health/live {\"status\":\"Healthy\",\"checks\":[]}, health/ready {\"status\":\"Healthy\",\"checks\":[{\"name\":\"game-workload\",\"status\":\"Healthy\"},{\"name\":\"platform.audit.sink\",\"status\":\"Healthy\"}]}." - }, - { - "name": "build.yml / game-service — Run the replay against the workload", - "status": "Passed", - "detail": "npx vitest run tests/replay.test.ts: Test Files 1 passed (1), Tests 11 passed (11), duration 12.81s." - }, - { - "name": "build.yml / game-service — Run the replay against the edge", - "status": "Passed", - "detail": "npx vitest run tests/replay-edge.test.ts with GAME_EDGE_DLL set to the Release build: Test Files 1 passed (1), Tests 2 passed (2), duration 6.03s." - }, - { - "name": "build.yml / game-service — Migrate the schema to head, the documented way", - "status": "Passed", - "detail": "npm run migrate with GAME_SERVICE_DB_SCHEMA=ci_documented_migrate against the compose PostgreSQL: \"migrated ci_documented_migrate to head\", exit 0." - }, - { - "name": "build.yml / game-service — Start the workload against the durable store, the documented way", - "status": "Passed", - "detail": "npm start with GAME_SERVICE_STORAGE=durable, GAME_SERVICE_DB_SCHEMA=ci_documented_durable_start, port 8081; /readyz answered {\"status\":\"healthy\"} — the self-migrating durable start path reached a serving, ready instance." - }, - { - "name": "build.yml / game-service — Run the one-instance contention proof", - "status": "Passed", - "detail": "npx vitest run tests/contention-one-instance.test.ts: Test Files 1 passed (1), all tests passed." - }, - { - "name": "build.yml / game-service — Run the two-instance contention proof", - "status": "Passed", - "detail": "npx vitest run tests/contention-two-instances.test.ts: Test Files 1 passed (1), Tests 7 passed (7), duration 18.54s." - }, - { - "name": "build.yml / game-service — Run the durable replay", - "status": "Passed", - "detail": "npx vitest run tests/durable-replay.test.ts: Test Files 1 passed (1), Tests 7 passed (7), duration 7.83s." - }, - { - "name": "build.yml / game-service — Run the port-conformance suite", - "status": "Passed", - "detail": "npx vitest run tests/conformance.test.ts: Test Files 1 passed (1), Tests 7 passed (7), duration 1.50s." - }, - { - "name": "build.yml / game-service — Test", - "status": "Passed", - "detail": "npm test in workloads/game-service with GAME_EDGE_DLL set: Test Files 24 passed | 1 skipped (25), Tests 197 passed | 1 skipped (198), duration 30.98s. The skipped file is tests/trace-evidence.test.ts, whose describe block is `describe.skipIf(!process.env[\"OTEL_COLLECTOR_BIN\"])` — that variable is set only by build.yml, which downloads a linux_amd64 OpenTelemetry Collector this host cannot run. That one block was not exercised locally; the CI check on this pull request is where its answer comes from." - }, - { - "name": "docs-ci.yml / documentation — Validate Markdown links, terminology, and generated files", - "status": "Passed", - "detail": "./build/Test-Documentation.ps1, exit 0: \"Documentation checks passed across 127 Markdown file(s), with 2 warning(s).\" Both warnings are pre-existing and untouched by this branch — design/d3/90-decisions.md:1138:66 and :1141:948, Terminology: Use 'JSON' instead of 'Json'." - }, - { - "name": "docs-ci.yml / slice-status-markers — Validate design/30-slices.md's Status markers", - "status": "Passed", - "detail": "./build/Test-SliceStatusMarkers.ps1, exit 0: \"Slice status markers consistent across 11 slice(s) in design/30-slices.md.\"" - }, - { - "name": "docs-ci.yml / verify — Build and verify the status-page site", - "status": "Passed", - "detail": "npm --prefix site ci then npm --prefix site run check, exit 0: format:check, lint, typecheck, vitest (Test Files 6 passed (6), Tests 57 passed (57)), build (tsc -b plus the landing-page adapter, built in 263ms), \"Both built HTML entry points contain their required static metadata.\", and \"Package merge preserves docs and rejects all protected-boundary violations.\"" - }, - { - "name": "docs-deploy.yml / deploy — Build and verify the status-page site", - "status": "Passed", - "detail": "The identical command as docs-ci.yml's flagged step above (npm --prefix site ci; npm --prefix site run check), run once and reported against both flags: exit 0, 6 test files and 57 tests passed, build and both verification scripts clean." - }, - { - "name": "tools/Test-Companion.ps1 (not CI-gated)", - "status": "Passed", - "detail": "State Valid, CoreCount 23, CompanionCount 0, AbsentCount 23, no findings: \"Companion split OK - 23 core(s) checked, 0 companion file(s) present, 23 core(s) with no companion.\"" - }, - { - "name": "tools/Test-DesignState.ps1 (not CI-gated)", - "status": "DidNotRun", - "reason": "Exit code 2 — could not evaluate. The script's own two reasons, verbatim: \"[ContractListUnreadable] SectionNotFound: design\\\\20-contract.md\" and \"[StateSetAbsent] design/state/ is missing or holds no records other than WorkRef mirrors\"; report line \"StateSetAbsent: nothing to check.\" Findings (0), Reported (0). This repository has no design/state/ record set, so there is nothing for it to check — it is not a regression on this branch." - } + { + "name": "Test", + "status": "Passed", + "detail": "dotnet test --no-build --configuration Release --verbosity normal: Test Run Successful. Total tests: 442, Passed: 442, Total time: 1.2112 Minutes. Includes the new S14 McpTests and the extended PackageGraphTests (SdkReferenceGuard, SdkTypeSurfaceGuard)." + }, + { + "name": "Run the sample in both roles", + "status": "DidNotRun", + "reason": "build/Test-SampleRoundTrip.ps1 documents itself as Linux-only: it signals processes via a P/Invoke to libc kill(2) and reads the store with the sqlite3 CLI, neither of which is available in this Windows session (no sqlite3 on PATH, no libc). The corresponding check on this pull request's build.yml run (ubuntu-latest) is authoritative for this gate." + }, + { + "name": "Assert no framework project references a workload", + "status": "Passed", + "detail": "pwsh ./build/Test-WorkloadIsolation.ps1: \"No project under src/ or samples/ references workloads/ (46 project file(s) checked).\" Exit code 0." + }, + { + "name": "Typecheck", + "status": "Passed", + "detail": "npm run typecheck (workloads/game-service): tsc --noEmit completed with no output and exit code 0." + }, + { + "name": "Start the workload and the edge, the documented way", + "status": "Passed", + "detail": "Reproduced the documented commands manually against a Docker-provisioned PostgreSQL (docker compose up -d). Workload on 127.0.0.1:8080: GET /livez -> {\"status\":\"healthy\"}, GET /readyz -> {\"status\":\"healthy\"}. Edge built (dotnet build ... GameEdge.csproj -c Release) and started on 127.0.0.1:5080 against it: GET /health/live -> {\"status\":\"Healthy\",\"checks\":[]}, GET /health/ready -> {\"status\":\"Healthy\",\"checks\":[{\"name\":\"game-workload\",\"status\":\"Healthy\"},{\"name\":\"platform.audit.sink\",\"status\":\"Healthy\"}]}." + }, + { + "name": "Run the replay against the workload", + "status": "Passed", + "detail": "npx vitest run tests/replay.test.ts against a real Docker-provisioned PostgreSQL container: Test Files 1 passed (1), Tests 11 passed (11), including the byte-identity proof over a real hosted process (S5.2/S5.3)." + }, + { + "name": "Run the replay against the edge", + "status": "Passed", + "detail": "npx vitest run tests/replay-edge.test.ts with GAME_EDGE_DLL pointed at the Release build: Test Files 1 passed (1), Tests 2 passed (2), both edge-fronted byte-identity comparisons (S7.8)." + }, + { + "name": "Migrate the schema to head, the documented way", + "status": "Passed", + "detail": "GAME_SERVICE_DB_SCHEMA=ci_documented_migrate npm run migrate: \"migrated ci_documented_migrate to head\", exit code 0, against a real Docker-provisioned PostgreSQL instance." + }, + { + "name": "Start the workload against the durable store, the documented way", + "status": "Passed", + "detail": "Started with GAME_SERVICE_STORAGE=durable and the documented connection string/schema (ci_documented_durable_start), then GET http://127.0.0.1:8081/readyz -> {\"status\":\"healthy\"}." + }, + { + "name": "Run the one-instance contention proof", + "status": "Passed", + "detail": "npx vitest run tests/contention-one-instance.test.ts: Test Files 1 passed (1), Tests 5 passed (5), including the two-concurrent-submissions-one-winner proof (S6.1/S6.6) and the unreachable-store-answers-503 perturbation case." + }, + { + "name": "Run the two-instance contention proof", + "status": "Passed", + "detail": "npx vitest run tests/contention-two-instances.test.ts: Test Files 1 passed (1), Tests 7 passed (7), including cross-instance session visibility (S7.2) and the one-winner-one-loser proof across two spawned instances (S7.3/S7.4)." + }, + { + "name": "Run the durable replay", + "status": "Passed", + "detail": "npx vitest run tests/durable-replay.test.ts: Test Files 1 passed (1), Tests 7 passed (7), including the durable dump/in-process snapshot and golden-transcript comparisons (S8.2-S8.4) against real PostgreSQL." + }, + { + "name": "Run the port-conformance suite", + "status": "Passed", + "detail": "npx vitest run tests/conformance.test.ts: Test Files 1 passed (1), Tests 7 passed (7)." + }, + { + "name": "Test (game-service)", + "status": "Passed", + "detail": "npm test (vitest run) against a real Docker-provisioned PostgreSQL, with GAME_EDGE_DLL set: Test Files 24 passed | 1 skipped (25); Tests 197 passed | 1 skipped (198); 0 failed. The one skip is tests/trace-evidence.test.ts, which self-skips without an OTEL_COLLECTOR_BIN pointing at a real OpenTelemetry Collector binary -- that binary is a Linux-only download this build.yml job installs and this local Windows session did not reproduce; the corresponding CI run is authoritative for that one sub-test." + }, + { + "name": "Validate Markdown links, terminology, and generated files", + "status": "Passed", + "detail": "pwsh ./build/Test-Documentation.ps1: \"Documentation checks passed across 127 Markdown file(s), with 2 warning(s).\" Both warnings are pre-existing terminology notes in design/d3/90-decisions.md (\"Use 'JSON' instead of 'Json'\"), unrelated to this PR's changes; warnings do not fail the gate. Exit code 0." + }, + { + "name": "Validate design/30-slices.md's Status markers", + "status": "Passed", + "detail": "pwsh ./build/Test-SliceStatusMarkers.ps1: \"Slice status markers consistent across 11 slice(s) in 'design/30-slices.md'.\" This PR does not touch design/30-slices.md." + }, + { + "name": "Build and verify the status-page site (docs-ci verify job)", + "status": "Passed", + "detail": "npm --prefix site ci && npm --prefix site run check: format:check, lint (oxlint, one non-blocking fast-refresh warning in src/shared.tsx), typecheck, test (6 files / 57 tests passed), test:build (site builds and both HTML entry points carry required static metadata), and test:merge (\"Package merge preserves docs and rejects all protected-boundary violations\") all succeeded." + }, + { + "name": "Build and verify the status-page site (docs-deploy job)", + "status": "Passed", + "detail": "Identical command to the docs-ci verify job above, run once and reported under both step names since both jobs run the exact same script (npm --prefix site ci && npm --prefix site run check) against the exact same site/ directory." + } ] }