diff --git a/.github/workflows/ci-build-test.yml b/.github/workflows/ci-build-test.yml index fd8505fb7..29b988258 100644 --- a/.github/workflows/ci-build-test.yml +++ b/.github/workflows/ci-build-test.yml @@ -49,6 +49,9 @@ jobs: 10.0.x 9.0.x + - name: Log selected .NET SDK + run: dotnet --info + - name: 🔧 Set up Node.js uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: diff --git a/Makefile b/Makefile index 993c3dd76..8edda10ee 100644 --- a/Makefile +++ b/Makefile @@ -18,6 +18,7 @@ build: restore test: build dotnet test \ --no-build \ + -p:TestTfmsInParallel=false \ --configuration $(CONFIGURATION) \ --filter '(Execution!=Manual)' \ --blame \ diff --git a/docs/concepts/filters.md b/docs/concepts/filters.md index 80d6d4653..4a1516302 100644 --- a/docs/concepts/filters.md +++ b/docs/concepts/filters.md @@ -32,6 +32,13 @@ The following request filter methods are available on `IMcpRequestFilterBuilder` | `AddUnsubscribeFromResourcesFilter` | Resource unsubscription handlers | | `AddSetLoggingLevelFilter` | Logging level handlers | +The Skills extension uses the same typed `McpRequestFilter` delegates, configured separately on +`McpSkillsOptions.ListSkillsFilters` and `McpSkillsOptions.GetSkillFilters`. They wrap complete composed Skills +operations, including registered primitives. Get matching runs before filters so they can inspect +`context.MatchedPrimitive`. See [Skills request filters](skills/skills.md#typed-request-filters) for manual +authorization and the separate file-resource policy boundary. Core resource filters and `AddAuthorizationFilters` +do not automatically apply to Skills methods. + The Tasks extension and ASP.NET Core tool authorization use alternate-result `tools/call` filters. Alternate-result filters run in registration order outside the ordinary `AddCallToolFilter` pipeline. The Tasks filter creates a task at its position in that order and runs its remaining pipeline in the background. Consequently, alternate-result filters registered before Tasks run before task creation, while those registered after Tasks run in the background before the ordinary filters and tool. ASP.NET Core authorization is registered before Tasks, so an unauthorized call is rejected without creating a task. Ordinary call-tool filters run exactly once after all alternate-result filters and before the tool. For task-backed calls, ordinary filters execute in the background after task creation. An explicit `CallToolWithAlternateHandler` remains a full replacement and cannot be combined with ordinary call-tool filters. diff --git a/docs/concepts/skills/skills.md b/docs/concepts/skills/skills.md index d2f26d8e5..b4c2d6e91 100644 --- a/docs/concepts/skills/skills.md +++ b/docs/concepts/skills/skills.md @@ -18,39 +18,44 @@ Skills are provided by the `ModelContextProtocol.Extensions.Skills` package. The [Skills extension specification](https://github.com/modelcontextprotocol/ext-skills/blob/main/specification/beta/skills.mdx) (SEP-2640, extension id `io.modelcontextprotocol/skills`). +> [!IMPORTANT] +> Skills support is experimental. All public APIs use `[Experimental("MCPEXP005")]`; suppress that diagnostic +> specifically where you intentionally consume the package. SEP-2640 is Final, but the extension specification +> is Beta. See [MCPEXP005](xref:list-of-diagnostics#mcpexp005). + ### Overview -A skill entry () is a complete, point-in-time snapshot of a skill: +A skill entry () describes one skill: - `uri`: the resource URI of its `SKILL.md`, conventionally `skill:///SKILL.md`. The final segment of `` is the skill's name. - `frontmatter`: the `SKILL.md` YAML frontmatter, rendered verbatim as a JSON object. `name` and `description` - are always present; every other authored field passes through. The - , , - , , - and properties read the string fields the Agent Skills - specification defines. + are required; other authored fields pass through. The DTO provides accessors for the standard string fields. - `resources`: either a complete manifest of the skill's files, each with a SHA-256 digest and size (), or the string `"dynamic"` for generated content that cannot be digested. -A host builds its registry from entries alone, then fetches files lazily with `resources/read` and verifies each -one against the manifest. Before loading a skill, it also parses the fetched `SKILL.md` and compares its -frontmatter with the entry's; digest verification does not cover that. That verification, and the rule that an -unlisted file is a change to the skill, is what lets a user's approval bind to specific content. +Enumerated files are available through `skill.Resources.Entries`. A `"dynamic"` manifest has null `Entries`, +not an empty collection; the array-or-string JSON representation remains under the name `resources`. -Keeping the entry and the file in agreement is the server's job, and checking that they agree is the host's: +A host fetches files lazily through `resources/read`. Before loading a skill, it verifies the files against the +manifest and independently parses `SKILL.md` to compare its frontmatter with the entry. | Who | Responsibility | |---|---| -| Server, using `McpServerSkill` | Frontmatter is read from `SKILL.md` and the manifest computed from the served bytes. With explicit frontmatter for YAML the reader does not support, the application must supply JSON identical to the file. | -| Server, using a custom catalog | The application supplies entries whose frontmatter and manifest match the files it serves. The SDK checks entry structure only. | +| Server, using `McpServerSkillPackage` | Frontmatter is read from `SKILL.md` and the manifest computed from the authored resource bytes. With explicit frontmatter for YAML the reader does not support, the application must supply JSON identical to the file. | +| Server, using a custom get implementation, handlers, or an existing entry | The application supplies entries whose frontmatter and manifest match the files it serves. The SDK checks entry structure only. | | Host | Verifies each file against the manifest, then parses the fetched `SKILL.md` and compares its frontmatter with the entry before loading. | ### Serving skills +Use file-authored registrations for fixed content, application-owned sources for request-time data, and typed +filters to wrap either path. + +#### File-authored registrations + The simplest way to serve skills is to keep each one in its own directory, as the Agent Skills specification -lays them out, and point `WithSkillsFromDirectory` at the parent: +lays them out, and point `WithSkillsFromSubdirectories` at the parent: ```csharp using ModelContextProtocol.Extensions.Skills; @@ -59,180 +64,61 @@ using ModelContextProtocol.Protocol; builder.Services .AddMcpServer() .WithHttpTransport() - .WithSkillsFromDirectory(Path.Combine(AppContext.BaseDirectory, "Skills"), configure: options => - { - options.TimeToLive = TimeSpan.FromMinutes(5); - options.CacheScope = CacheScope.Public; - }); + .WithSkillsFromSubdirectories(Path.Combine(AppContext.BaseDirectory, "Skills")); ``` -Every included immediate subdirectory containing a `SKILL.md` becomes a skill. The SDK reads the frontmatter from each -`SKILL.md`, computes every digest and size from the same bytes the resources serve, registers the file resources, -and publishes each skill at `skill://{name}/SKILL.md`, where `name` comes from the frontmatter. Pass a `uriPrefix` +Included immediate child directories containing a `SKILL.md` become skills; discovery does not search deeper +directories for additional skills. Within each discovered skill, selected files are collected recursively, so a +nested `SKILL.md` is a file of that skill, not a separate registration. The SDK reads the frontmatter from each +skill's root `SKILL.md`, computes every digest and size from the same bytes the resources serve, registers the file resources, +and publishes each skill at `skill://{name}/SKILL.md`, where `name` comes from the frontmatter. Set `McpServerSkillPackageDiscoveryOptions.UriPrefix` such as `skill://acme/billing/` to place the skills under an organizational path. -Names used as URI path segments are percent-encoded. The SDK decodes the final name segment once -when comparing it with frontmatter; the supplied URI text remains the skill's identifier. Nested skills -sharing a file must use the identical URI text, including its encoding, so verified reads name the same resource. -Native absolute resource URIs such as `repo:/skills/refunds/SKILL.md` and `repo:skills/refunds/SKILL.md` -are also supported; an absolute resource URI does not need an authority introduced by `://`. - -Dot-prefixed, hidden, and system files and directories are excluded by default, before reading their contents -or counting them toward the published manifest's limits. The `includeHiddenAndSystemFiles: true` overloads of -`WithSkillsFromDirectory` and `McpServerSkill.CreateFromDirectory` provide an explicit opt-in. For a narrower -selection, use `McpServerSkill.Create` with explicit files; that API keeps the files the caller selected. +Names used as URI path segments are escaped with [`Uri.EscapeDataString`](https://learn.microsoft.com/dotnet/api/system.uri.escapedatastring). + +Dot-prefixed, hidden, and system files and directories are excluded by default, before reading their contents. +The discovery and single-package options provide `IncludeHiddenAndSystemFiles` for an explicit opt-in. For a narrower +selection, use `McpServerSkillPackage.Create` with explicit files; that API keeps the files the caller selected. Included symbolic links remain errors rather than being followed. -For finer control, build skills individually with and -register them with `WithSkills`: +For finer control, author packages with and +register them with `WithSkillPackages`: ```csharp -var gitWorkflow = McpServerSkill.CreateFromDirectory(Path.Combine(skillsRoot, "git-workflow")); +using System.Text.Json.Nodes; -var refunds = McpServerSkill.Create( - "skill://acme/billing/refunds/SKILL.md", +var gitWorkflow = McpServerSkillPackage.CreateFromDirectory( + Path.Combine(skillsRoot, "git-workflow"), + new McpServerSkillPackageCreateOptions + { + Metadata = ["skill-reader"], + Meta = new JsonObject { ["example.com/category"] = "workflow" }, + }); + +var refunds = McpServerSkillPackage.Create( [ McpServerSkillFile.FromText("SKILL.md", skillMarkdown), McpServerSkillFile.FromText("examples/approved.md", approvedTemplate), new McpServerSkillFile { Path = "assets/logo.png", Content = logoBytes }, - ]); - -builder.Services.AddMcpServer().WithHttpTransport().WithSkills([gitWorkflow, refunds]); -``` - -Register skills once per server. `WithSkills` and `WithSkillsFromDirectory` install the `skills/list` and `skills/get` -handlers, so a second call for the same service collection throws before it -registers anything. To serve skills from several directories or sources, pass every `McpServerSkill` to one -`WithSkills` call, as above, or implement one `IMcpSkillCatalog` that combines them. - -Each skill file must be the only resource at its URI. If another resource is registered at a URI that the server's -resource collection treats as the same (scheme and authority are compared without case), building the server's -options throws naming the URI, whether that resource was registered before or -after the skills. Without this check, one of the two would be served in place of the other. - -All of these validate structural entry and file constraints and throw with a -specific message when, for example, the frontmatter `name` does not match the URI, `description` exceeds the -Agent Skills limit of 1024 Unicode scalar values, `compatibility` exceeds 500 Unicode scalar values, -`license` or `allowed-tools` is not a string, a resource URI escapes the skill's directory, `SKILL.md` is missing, or -the skill exceeds the per-skill limits of 512 files or 16 MiB. Every field the Agent Skills specification defines is -type-checked: `name`, `description`, `license`, `compatibility`, and `allowed-tools` must be strings, and `metadata` a -string-to-string map. Other frontmatter fields pass through verbatim, as the specification requires; hosts compare -them against the file. Hosts still ignore `allowed-tools` for MCP-origin skills unless the user has approved that grant. File contents are copied when the skill is created, so -later changes to a caller's buffer or to files on disk do not affect what is served. `CreateFromDirectory` does not -follow symbolic links, since a link can point outside the skill directory; it throws if it encounters one. File -names containing characters with URI syntax (such as `{`, `?`, or a space) are percent-encoded in the resource URIs. - -The optional property carries entry-level `_meta`. -It is separate from the frontmatter's string-to-string `metadata` map and may contain arbitrary JSON values. -Catalog snapshots preserve it without sharing mutable objects with callers. - -#### Frontmatter - -The `Create` and `CreateFromDirectory` overloads that do not take a `JsonObject` read the YAML frontmatter of -`SKILL.md` with the SDK's built-in reader, which needs no YAML library. It reads a bounded subset of YAML: block -mappings (nested up to 32 levels), block sequences, single-line flow collections, plain, quoted, and block scalars, -and comments. The root must be a mapping. Unquoted scalars are resolved per -the YAML 1.2 core schema (`null`, booleans, integers, finite floats, otherwise strings), the resolution the `yaml` -package and `yaml.v3` use; other parsers can differ at the edges. That matters because a host verifies a skill by -parsing the fetched `SKILL.md` itself with a full YAML parser and comparing field by field against the published -entry; a value that one side types as a number and the other as a string is a verification failure. Quote values -such as version numbers that are meant to be strings. Hexadecimal (`0x`) and octal (`0o`) integers keep their exact -value up to 64 bits, leading zeros included. Wider ones are reported as unsupported, because parsers disagree on -them: `yaml.v3` reads them as strings and the `yaml` package as rounded floating-point numbers. - -Anchors, aliases, tags, complex (`? `) and empty mapping keys, nested and multi-line flow collections (including -`{items:[one]}`, and any flow collection with a comment inside, since a comment runs to the end of its line), -multi-line quoted scalars, nesting deeper than 32 levels, and hexadecimal or octal integers wider than 64 bits are -not supported; these overloads reject them with an naming the construct rather than -guess. For such a file, the `Create` and `CreateFromDirectory` overloads that take an explicit `JsonObject` supply the -frontmatter directly. They still reject invalid UTF-8, a missing frontmatter block, and malformed YAML the reader -detects, and when the reader can parse the whole block, the supplied object must match it exactly. Every overload -also rejects, anywhere in the frontmatter, comments and quoted strings included, a raw character outside YAML's -printable set, such as a NUL, and a raw carriage return without a line feed, NEL, LS or PS. YAML 1.2.2 allows some of -the first kind in quoted strings, but libyaml, PyYAML and Go's `yaml.v3` reject them anywhere, and YAML parsers -disagree on whether the second kind ends a line. Write such a character as an escape in a double-quoted string, such -as `\0` or `\L`. - -The reader can stop at the first unsupported construct, though, so for such a file it may not check the rest of the -block, and it cannot compare the block with the supplied object. The application must parse the entire frontmatter -with a full YAML parser and supply the identical JSON; the SDK accepting it is not proof that it matches. - -#### Custom catalogs - -When skills come from a database, a file share, or a large or generated catalog, implement - and pass it to `WithSkills`: - -```csharp -public sealed class DatabaseSkillCatalog(SkillRepository repository) : IMcpSkillCatalog -{ - public async ValueTask ListAsync(string? cursor, McpSkillRequestContext context, CancellationToken cancellationToken) - { - string tenant = GetTenant(context.User); - var (skills, nextCursor) = await repository.GetPageAsync(tenant, cursor, pageSize: 50, cancellationToken); - return new McpSkillPage { Skills = skills, NextCursor = nextCursor }; - } - - public ValueTask GetAsync(string uri, McpSkillRequestContext context, CancellationToken cancellationToken) => - repository.FindAsync(GetTenant(context.User), uri, cancellationToken); -} -``` - -Both methods receive an with the JSON-RPC request, -the caller's when the transport supplies one (the ASP.NET Core -transport does), and any items that incoming-message filters attached to the request. - -The catalog passed to `WithSkills` is one instance shared by every request, so it must not capture scoped services, -such as a database context, directly. A catalog that needs scoped services can create its own scope for each call -with `IServiceScopeFactory`; such a scope is independent of the request's scope and does not share its instances. - -A catalog supplies entries only; the skills' files must still be served as resources, since hosts read them with -`resources/read`. Each entry's frontmatter must match its `SKILL.md`; the SDK validates entry structure but does not -read the files to check this. A catalog may list only part of what it serves, or nothing at all, as long as -`GetAsync` answers for every skill the server serves to the caller. Throw - with for a -cursor the catalog cannot interpret. is the -built-in implementation over a fixed set of entries and can be composed into a custom one. - -To show different callers different skills, filter the entries first and paginate only the filtered set. Every -page and continuation cursor then covers only skills the caller may read. Filtering a finished page while passing -on its cursor is not enough, because the cursor can still lead to hidden skills. Building an -`InMemoryMcpSkillCatalog` validates and copies its entries, so keep one filtered view per role or user instead of -building a new one on every request when that is expensive: - -```csharp -public sealed class RoleSkillCatalog(IReadOnlyList skills, SkillAccessPolicy policy) : IMcpSkillCatalog -{ - private readonly ConcurrentDictionary _viewsByRole = new(); - - public ValueTask ListAsync(string? cursor, McpSkillRequestContext context, CancellationToken cancellationToken) => - GetView(context.User).ListAsync(cursor, context, cancellationToken); + ], + new McpServerSkillPackageCreateOptions { Uri = "skill://acme/billing/refunds/SKILL.md" }); - public ValueTask GetAsync(string uri, McpSkillRequestContext context, CancellationToken cancellationToken) => - GetView(context.User).GetAsync(uri, context, cancellationToken); - - // Authorize before paginating: the view holds only the skills this role may read. - private InMemoryMcpSkillCatalog GetView(ClaimsPrincipal? user) => - _viewsByRole.GetOrAdd(policy.GetRole(user), role => - new InMemoryMcpSkillCatalog(skills.Where(skill => policy.CanRead(role, skill.Uri)))); -} +builder.Services.AddMcpServer().WithHttpTransport().WithSkillPackages([gitWorkflow, refunds]); ``` -Guard `resources/read` for the skills' files with the same policy, for example with a read-resource request filter. - -#### Dynamic skills - -A skill whose content is generated on demand cannot publish stable digests. Set its entry's `Resources` to - and serve it through a catalog. Such a skill -offers no content integrity, and hosts may decline to load it. A host that does load one still verifies its -frontmatter. - -#### Protocol versions and caching hints - -`skills/list` and `skills/get` results carry the base protocol's `ttlMs` and `cacheScope` caching attributes, -configured through . Those attributes, and -`resultType`, are defined from protocol revision `2026-07-28`. The SDK emits them only on requests negotiated under that revision or later, -where they are required and default to `0` and `private` when unset, the same conservative defaults the built-in -list methods use. On earlier revisions, which reject them as unrecognized keys, they are omitted. Both methods use -the same configured hints, so use `public` only when the listing and every entry are identical for every caller. + supplies an optional `Uri`, explicit +`Frontmatter`, directory file-selection policy, server-local `Metadata`, and entry-level protocol `Meta`. +Each package contains a registered primitive in `Skill` and a read-only list of ordinary file `Resources`. +The primitive provides its entry for listing/get, and its resources serve the authored bytes. Local metadata is +not serialized, copied to file resources, or interpreted as authorization. Registering a package does not authorize +file reads; protect metadata and file resources separately with the same application policy. +The single-directory builder, `WithSkillFromDirectory`, authors one directory. `WithSkillsFromSubdirectories` +discovers immediate child directories in ordinal order, not nested skills. Its separate discovery options +provide `UriPrefix`, +`IncludeHiddenAndSystemFiles`, and common `Metadata`/`Meta`, not per-skill URI or frontmatter overrides. + +The factories do not require dependency injection. `CreateFromSubdirectories` also returns standalone packages. +Register components separately with `WithSkills([package.Skill])` and `WithResources(package.Resources)`, +or register both with `WithSkillPackages([package])`. ### Consuming skills @@ -240,17 +126,22 @@ The client extension methods in skills = await client.ListSkillsAsync(); +// Retrieve all listing pages. A server may list fewer skills than it serves. +IList listedSkills = await client.ListSkillsAsync(); +foreach (Skill listed in listedSkills) +{ + Console.WriteLine(listed.Uri); +} // Retrieve one skill by URI, for example one referenced from server instructions. Skill skill = await client.GetSkillAsync("skill://git-workflow/SKILL.md"); @@ -259,9 +150,9 @@ Skill skill = await client.GetSkillAsync("skill://git-workflow/SKILL.md"); ReadResourceResult contents = await client.ReadSkillResourceAsync(skill, skill.Uri); // This example loads text SKILL.md resources and rejects other content forms. -if (contents.Contents.Single(content => content.Uri == skill.Uri) is not TextResourceContents text) +if (contents.Contents.Single(content => new Uri(content.Uri) == new Uri(skill.Uri)) is not TextResourceContents text) { - throw new SkillVerificationException("A text SKILL.md is required for this example."); + throw new NotSupportedException("A text SKILL.md is required for this example."); } // Parse the fetched frontmatter into JSON with a full YAML parser of your choice; the SkillsClient sample uses @@ -269,69 +160,291 @@ if (contents.Contents.Single(content => content.Uri == skill.Uri) is not TextRes JsonObject fetchedFrontmatter = ParseFrontmatter(text.Text); if (!JsonNode.DeepEquals(fetchedFrontmatter, skill.Frontmatter)) { - throw new SkillVerificationException("The fetched frontmatter differs from the held entry."); + throw new InvalidDataException("The fetched frontmatter differs from the held entry."); } ``` -`ListSkillsAsync` and `GetSkillAsync` validate every entry the server returns against the specification's structural -requirements and throw for an entry a host -must not load, such as one whose manifest omits its own `SKILL.md`, carries a malformed digest, or lists a file -outside the skill. `ReadSkillResourceAsync` throws the same exception when the -content's size or digest does not match the manifest, or when the URI is not listed in it at all. In both cases the -content must not be used. To recover, refresh the entry with `GetSkillAsync` and proceed from the new manifest; -because the manifest changed, any approval bound to the previous one is revoked and must be obtained again. +List/get methods validate each entry; verified reads check membership, requested URI, size and digest. +Entry or integrity failures use . Do not use failed content. +An unlisted file is refused before a request is sent. Refresh the entry to read newly added files; a changed +manifest revokes approval bound to the previous manifest. -The lower-level overloads, `ListSkillsAsync(ListSkillsRequestParams)` and `GetSkillAsync(GetSkillRequestParams)`, -return one page or the raw result and expose the caching hints. +`ListSkillsAsync(ListSkillsRequestParams)` and `GetSkillAsync(GetSkillRequestParams)` expose the result's caching hints. +The convenience list, get, and verified file-read methods accept standard `RequestOptions` for metadata and progress +tokens, followed by cancellation. Single-page listing accepts metadata through `ListSkillsRequestParams.Meta`. +Verified reads forward their options to `resources/read`. exposes the verification and digest helpers for hosts that read resources through other means. -#### Listing limits and rejected entries +#### Pagination and malformed results -The aggregating `ListSkillsAsync()` overload stops at 100 pages or 1,000 entries by default. Repeated cursors -and exceeded limits throw `McpException`; no silently truncated list is returned. Empty cursors remain opaque -values, not an end marker. Use explicit positive limits when appropriate: +`ListSkillsAsync()` follows all listing pages and returns their entries as an `IList`, matching the +tools/prompts/resources convenience methods. `ListSkillsAsync(ListSkillsRequestParams)` sends one request and +preserves its cursor and cache hints. Only a null cursor ends pagination; an empty string remains opaque. +The aggregating overload has no page/entry ceiling or repeated-cursor detection. Supply cancellation, or use +the single-page overload to enforce application-owned budgets. -```csharp -var skills = await client.ListSkillsAsync(maxPages: 200, maxSkills: 5000); -``` +A missing or null required skills array is not treated as an empty listing. +High-level operations reject null result members and null list entries. A structurally invalid entry fails its page +and aggregation rather than being silently omitted. Low-level DTO serialization still permits explicit null result +members; required-member presence and the manifest's array-or-`"dynamic"` wire form are checked separately. +RPC failures retain the usual MCP exception behavior. -Use the paged overload for application-controlled iteration. Strict listing still rejects a page containing -an invalid entry. To retain valid entries while reporting the others, opt into -`ListSkillsWithDiagnosticsAsync`: +### Application-owned sources + +#### Registered get implementations + +A registration can publish a `ProtocolSkill` listing entry, or leave it null for direct retrieval only. +Listing and startup never invoke `GetAsync`. Listing entries and get results must be structurally valid and +match the registration's immutable `Id`. Get receives the actual request context and cancellation token. + +For example, an application repository can provide a get-only registration: ```csharp -var listing = await client.ListSkillsWithDiagnosticsAsync(new ListSkillsRequestParams()); -foreach (var rejected in listing.RejectedEntries) -{ - Console.Error.WriteLine($"Skill entry {rejected.Index} was rejected: {rejected.Error.Message}"); -} -foreach (var listedSkill in listing.Page.Skills) +using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol.Server; + +builder.Services.AddScoped(); +builder.Services.AddMcpServer().WithHttpTransport().WithSkills([new RunbookSkill()]); + +sealed class RunbookSkill() : McpServerSkill("skill://runbook/SKILL.md") { - Console.WriteLine(listedSkill.Uri); + public override IReadOnlyList Metadata => ["runbook-reader"]; + + public override async ValueTask GetAsync( + RequestContext request, CancellationToken cancellationToken = default) => new() + { + Skill = await request.Services!.GetRequiredService() + .GetEntryAsync(request.User, cancellationToken), + TimeToLive = TimeSpan.Zero, + CacheScope = CacheScope.Private, + }; } ``` -`Page` preserves the original cursor and cache hints. `RejectedEntries` identifies each invalid entry by its -position in the original page and retains the parsing/validation error. An empty `Page.Skills` can mean every -entry was rejected, so callers must inspect the errors. A malformed page envelope or missing `skills` array -still fails the request. This API does not load rejected entries or replace file/frontmatter verification. +`RunbookRepository` is application code returning the matching entry. Override `ProtocolSkill` to also publish a +listing entry, or use `ListSkillsHandler` for request-dependent discovery. `McpServerSkill.Create(entry, metadata)` +wraps an existing entry. None of these paths creates file resources. + +Async retrieval does not require a `"dynamic"` manifest: it can return a complete digest/size manifest. +`"dynamic"` explicitly gives up content verification; hosts may decline it and must still verify frontmatter if accepted. + +#### List/get handlers + +For application-owned sources, configure typed list/get handlers and resolve dependencies from `context.Services`: + +```csharp +using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol; +using ModelContextProtocol.Extensions.Skills; +using ModelContextProtocol.Protocol; + +builder.Services.AddScoped(); +builder.Services + .AddMcpServer() + .WithHttpTransport() + .WithSkills(options => + { + options.ListSkillsHandler = (context, cancellationToken) => + context.Services!.GetRequiredService() + .ListSkillsAsync(context.User, context.Params.Cursor, cancellationToken); + options.GetSkillHandler = async (context, cancellationToken) => new GetSkillResult + { + Skill = await context.Services!.GetRequiredService() + .FindSkillAsync(context.User, context.Params.Uri, cancellationToken) ?? + throw new McpProtocolException("Skill is unavailable.", McpErrorCode.InvalidParams), + }; + }); +``` + +Serve and authorize files separately through the ordinary resource handlers. The complete, tested +[database-style recipe](https://github.com/modelcontextprotocol/csharp-sdk/blob/main/samples/SkillsServer/DatabaseStyleHandlers.cs) +includes list/get/read handlers and an illustrative **in-memory** store, not a database or authentication implementation. +It applies tenant/permission checks before pagination and permits direct access to authorized unlisted skills. +Real database code should execute these predicates, cursor ordering and page limits in its query, propagating +cancellation. Do not load an entire database into the sample's fixed in-memory index. + +Registered entries are appended to the initial custom list page (null cursor); continuation pages remain +application-owned. Return a fresh mutable page and do not list registrations again in the callback. +Registered gets take precedence over the custom fallback. Filtering callback entries cannot hide registrations +appended afterwards; use a typed list filter for those. + +For caller-dependent sources, authorize before taking a page and derive cursors only from allowed returned rows. +Custom get handlers cannot authorize registered skills; use [typed Skills filters](#typed-request-filters). +Registered resource matching likewise wins over a custom read fallback. Do not mutate shared registrations per caller. + +For per-entry policies on fixed registrations, authorize using `context.MatchedPrimitive?.Id`, not the raw +`context.Params.Uri`: URI-equivalent spellings can match the same primitive. Custom handlers and URI templates +must authorize the resolved application identity instead. + +#### Typed request filters + +`ListSkillsFilters` and `GetSkillFilters` use . +They run in registration order with the handler's context, services, caller, items and cancellation token. +Resolve scoped dependencies inside the returned handler. Get filters receive the matched primitive, including +its local `Metadata`, in `context.MatchedPrimitive`; list filters see the complete augmented page after `next`. +A filter may short-circuit, but its result still passes final structural validation and get URI binding. + +For example, this fixed collection is visible only to authenticated callers with the `skill-reader` role. The Skills +and resource-list filters return empty listings for other callers; the get and file-read filters deny access +independently: + +```csharp +using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol; +using ModelContextProtocol.Extensions.Skills; +using ModelContextProtocol.Protocol; +using System.Security.Claims; + +var report = McpServerSkillPackage.Create( + [McpServerSkillFile.FromText("SKILL.md", "---\nname: report\ndescription: Report workflow\n---\n")]); + +builder.Services.AddMcpServer() + .WithHttpTransport() + .WithSkillPackages([report]) + .WithSkills(options => + { + options.ListSkillsFilters.Add(next => async (context, cancellationToken) => + { + var result = await next(context, cancellationToken); + if (!CanReadSkills(context.User)) + { + result.Skills = []; + } + + result.TimeToLive = TimeSpan.Zero; + result.CacheScope = CacheScope.Private; + return result; + }); + options.GetSkillFilters.Add(next => (context, cancellationToken) => + { + if (!CanReadSkills(context.User)) + { + throw new McpProtocolException("Skill is unavailable.", McpErrorCode.InvalidParams); + } + + return next(context, cancellationToken); + }); + }) + .WithRequestFilters(filters => + { + filters.AddListResourcesFilter(next => async (context, cancellationToken) => + { + var result = await next(context, cancellationToken); + if (!CanReadSkills(context.User)) + { + result.Resources = []; + } + + return result; + }); + filters.AddReadResourceFilter(next => (context, cancellationToken) => + { + if (!CanReadSkills(context.User)) + { + throw new McpProtocolException("Access forbidden.", McpErrorCode.InvalidRequest); + } + + return next(context, cancellationToken); + }); + }); + +static bool CanReadSkills(ClaimsPrincipal? user) => + user?.Identity?.IsAuthenticated == true && user.IsInRole("skill-reader"); +``` + +> [!IMPORTANT] +> This example filters unpaginated static Skills and resource listings. For paginated sources, authorize before forming +> pages and cursors; filtering afterwards cannot repair a cursor that reveals hidden entries. Listing omission never +> denies get or file read. Skills and resource filters are independent, with no automatic policy inheritance. +> Automatic participation in ASP.NET Core `AddAuthorizationFilters` is not yet integrated; support is +> tracked by [#1990](https://github.com/modelcontextprotocol/csharp-sdk/issues/1990). + +### Advanced reference + +#### Configuration and file ownership + +Registration is additive: repeated calls install wire handlers once and add primitives with `TryAdd`, preserving +insertion order and the first accepted identity. Configure handlers and filters separately with `WithSkills(options => ...)`. +DI contributions are aggregated during `Configure` before that callback; `PostConfigure` runs afterwards. +Later handler assignments win, and null restores collection-backed behavior. + +`CreateFromSubdirectories` and `WithSkillPackages` validate their complete input batch before returning or adding +services. Shared file URIs must agree on digest and size, including ignored duplicate skill identities. +Rejected batches leave services unchanged. Empty package registration is valid; discovery without skills is an error. + +Skills and file resources are registered independently with ordinary first-wins behavior. Removing or replacing +either does not update the other. Package authoring initially binds the manifest to its file bytes; applications +own agreement after entry mutations or resource overrides. Generic collections and lists validate individual +entries, not cross-entry snapshots. Verified reads remain authoritative for manifest/content agreement. + +Factories capture options settings but may retain supplied frontmatter, entry `_meta` and local metadata references. +Applications own those mutable objects; use separate objects for response-only changes. Package file bytes are +copied and the resource collection is read-only. Textual MIME types use text only when bytes round-trip through +UTF-8; other content is served as a blob. + +File/directory factories eagerly retain selected files without a count, byte or time budget. Use them on +application-controlled inputs, or select/read bounded files yourself. The SDK imposes no per-skill file/byte ceiling; +the specification requires hosts to support 512 files and 16 MiB, permits larger skills, and says servers **SHOULD NOT** +exceed that envelope for interoperability. + +#### YAML frontmatter limits + +The built-in reader supports a bounded YAML 1.2 core-schema subset: block mappings/sequences, single-line flow +collections, plain/quoted/block scalars and comments, with a mapping root and nesting up to 32 levels. +Anchors, aliases, tags, complex or empty keys, multi-line quoted/flow forms, and hexadecimal/octal integers wider +than 64 bits require explicit `McpServerSkillPackageCreateOptions.Frontmatter`. Parse the entire file with a full +YAML parser first and supply identical JSON. Quote values such as version numbers that must remain strings. + +Explicit frontmatter does not bypass UTF-8, boundary or detected syntax errors. When the built-in reader can +parse the whole block, it compares supplied frontmatter field by field. If it stops at an unsupported construct, +the application owns validation of the remaining block and its agreement with the supplied object. +Raw non-printable characters, lone CR, NEL, LS and PS are rejected for parser interoperability; use quoted escapes. + +#### Request services and transport + +Use `context.Services`, not the server's potentially longer-lived provider. DI supplies it automatically; +low-level `McpServer.Create` must receive a provider if handlers need services. Resolve scoped dependencies per +request, rather than capturing them in long-lived callbacks. + +The scope follows the same lifecycle as built-in MCP request handlers: + +| Transport or configuration | Request services | +|---|---| +| Stateless Streamable HTTP, including modern clients on a hybrid endpoint | The existing `HttpContext.RequestServices`. Scoped state set by HTTP middleware is preserved; Skills does not create another scope. ASP.NET Core owns and disposes it. | +| Stream/stdio, stateful Streamable HTTP, and legacy SSE | When a scope factory is available and `McpServerOptions.ScopeRequests = true` (the default), Core creates an independent scope per MCP request and disposes it asynchronously after the handler finishes, including on failure or cancellation. This is not the HTTP middleware's scope. | +| `McpServerOptions.ScopeRequests = false` | The provider supplied to the server is used as-is and remains caller-owned. Supply an appropriately scoped provider when using scoped dependencies. | + +Do not dispose request providers or retain their scoped services. With request scoping disabled, stream/stateful +requests can share the supplied root provider; legacy SSE shares its long-lived GET scope. Disposable transients +remain retained until that provider is disposed. Do not share a `DbContext` concurrently. Shared callbacks and +singletons must support concurrency; Skills creates no provider or nested scope. + +#### Custom-handler protocol boundaries + +Low-level `McpServerOptions.RequestHandlers` callbacks own their JSON. Typed Skills handlers instead return +`ListSkillsResult` or `GetSkillResult`; the SDK validates entries after all filters, binds gets to the original URI, +and shapes protocol/cache fields. Invalid request parameters use `InvalidParams`; server-result failures retain +Core's generic internal-error wire response and local diagnostics. + +#### Protocol versions and caching hints + +For `2026-07-28+`, both methods emit `resultType: "complete"` and default unset cache hints to `ttlMs: 0` and +`cacheScope: "private"`. Earlier protocols omit all three fields. Only serialized responses are shaped; +shared application results retain their discriminator and hints. + +Set hints on handler results or in typed filters. Registered `GetAsync` can supply get hints; a custom get fallback +does not intercept registrations. List hints cover the **complete augmented page**, even when an empty custom +result supplies hints for static registrations. Use `public` only if every returned entry is identical for every caller. ### Security considerations Skill content is instructional text delivered to a model and is therefore a prompt-injection surface. The specification places most of the burden on hosts. In particular: -- `skills/list` and `skills/get` are registered as raw request handlers, so they do not pass through the request - filters that guard the built-in resource methods, including the ASP.NET Core authorization filters. A listing - therefore discloses frontmatter, file names, sizes, and digests to any caller the transport admits. If some - callers must not see some skills, implement and - decide from the request context's user, and guard the corresponding resources the same way. The built-in - in-memory catalog serves the same entries to every caller. Use one shared application policy for both - metadata and file access, and filter before paginating as shown in [Custom catalogs](#custom-catalogs), so - continuation cursors cannot disclose hidden skill URIs. Filtering a completed page while forwarding its - unfiltered cursor is not sufficient. Do not derive authorization from `resources/list` membership: - listings may intentionally be partial while an authorized direct read remains valid. +- Apply one policy to Skills and their files using [caller-filtered sources](#listget-handlers) or + [typed request filters](#typed-request-filters). Neither listing omission nor `resources/list` membership + authorizes resource access. - Treat MCP-served skill content as untrusted model input, and tag it with its originating server when it enters the model's context. - Digests are unsigned and come from the same server as the content. A match proves consistency between the entry @@ -342,14 +455,9 @@ specification places most of the burden on hosts. In particular: - Skill names are labels, not identifiers, and are not unique across servers. Identify a skill by the pair of server identity and URI. -The SDK implements digest and size verification and the unlisted-file rule. It does not verify frontmatter against -the fetched `SKILL.md` automatically, and a host must do so before loading a skill, as the example above shows: parse -the fetched text with a full YAML parser of its choice, convert the frontmatter to JSON, and compare it with the -entry's frontmatter using -, -treating a parse failure or a difference as a verification failure. The -[SkillsClient](https://github.com/modelcontextprotocol/csharp-sdk/tree/main/samples/SkillsClient) sample shows one way, -with YamlDotNet. A host never uses the entry's frontmatter in place of parsing the file. +Digest verification does not replace independent frontmatter parsing. The +[SkillsClient](https://github.com/modelcontextprotocol/csharp-sdk/tree/main/samples/SkillsClient) sample demonstrates +both checks with YamlDotNet before using content. ### Not implemented diff --git a/docs/list-of-diagnostics.md b/docs/list-of-diagnostics.md index 577334246..07cd14b8a 100644 --- a/docs/list-of-diagnostics.md +++ b/docs/list-of-diagnostics.md @@ -30,6 +30,34 @@ If you use experimental APIs, you will get one of the diagnostics shown below. T | `MCPEXP001` | Experimental APIs tied to MCP specification features. Reuse this ID for newly introduced experimental spec features, and add feature-specific messages/URLs in `Experimentals`. | | `MCPEXP002` | Experimental SDK extensibility APIs used to implement features in standalone packages without requiring Core to understand those features. For example, the Tasks package uses these APIs to extend the SDK while keeping the Tasks concept out of Core. This includes `McpClient`/`McpServer` subclassing, custom request handlers, alternate handlers and filters, outgoing request interception, and `RunSessionHandler`. These APIs remain experimental until additional extensibility scenarios validate the design. | | `MCPEXP003` | Experimental MCP Apps extension APIs. MCP Apps is the first official MCP extension (`io.modelcontextprotocol/ui`), enabling servers to deliver interactive UIs inside AI clients (see [MCP Apps specification](https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/2026-01-26/apps.mdx)). | +| `MCPEXP005` | Experimental MCP Skills extension APIs (`io.modelcontextprotocol/skills`). The extension and its C# APIs are subject to change. | + +The experimental `McpServerRequestHandler.Handler` property now uses +`McpRequestHandler` rather than +`Func>`. This is an intentional **source- and +binary-breaking change** to the `MCPEXP002` extensibility API, not a compatibility adapter. Extension packages +compiled against the previous property signature must be rebuilt against the updated Core package, even when +their handler lambda still compiles without source changes. Explicitly typed handler methods must change their +first parameter from `JsonRpcRequest` to `RequestContext`. + +Read operation arguments from `context.Params`. Composed handlers can replace this value without changing +`context.JsonRpcRequest.Params`; the raw request remains available through `context.JsonRpcRequest` for +envelope and transport information. Handlers also receive the request-bound `context.Server` and +`context.Services`, and use the same service scope lifecycle as built-in handlers, honoring +`McpServerOptions.ScopeRequests`. They still control JSON serialization and bypass the typed request filters. + +### MCPEXP005 + +All public APIs in `ModelContextProtocol.Extensions.Skills` are experimental and subject to change. +SEP-2640 is Final, but the [Skills extension specification](https://github.com/modelcontextprotocol/ext-skills/blob/main/specification/beta/skills.mdx) +is Beta under the [extension lifecycle](https://modelcontextprotocol.io/seps/3392-extension-lifecycle). +Suppress `MCPEXP005` specifically in projects that intentionally consume these APIs, for example: + +```xml +$(NoWarn);MCPEXP005 +``` + +This diagnostic is separate from the `MCPEXP002` Core extensibility APIs used to implement the extension. ## Obsolete APIs diff --git a/global.json b/global.json index 9e13a6187..d2c062931 100644 --- a/global.json +++ b/global.json @@ -1,6 +1,6 @@ { "sdk": { - "version": "10.0.101", - "rollForward": "minor" + "version": "10.0.114", + "rollForward": "latestFeature" } } diff --git a/samples/SkillsClient/Program.cs b/samples/SkillsClient/Program.cs index f5703065d..cf87d86c1 100644 --- a/samples/SkillsClient/Program.cs +++ b/samples/SkillsClient/Program.cs @@ -7,140 +7,176 @@ // 5. Show that a file the manifest does not list is refused before any request is sent. // // Start the SkillsServer sample first (dotnet run --project samples/SkillsServer). Pass a different endpoint as -// the first argument to run against another server. +// the first argument to run against another instance of this sample server. using ModelContextProtocol; using ModelContextProtocol.Client; using ModelContextProtocol.Extensions.Skills; using ModelContextProtocol.Protocol; using System.Text.Json.Nodes; -using YamlDotNet.Core; var endpoint = new Uri(args.Length > 0 ? args[0] : "http://localhost:3001"); -await using McpClient client = await McpClient.CreateAsync(new HttpClientTransport(new() +using var cancellation = new CancellationTokenSource(); +ConsoleCancelEventHandler cancelHandler = (_, eventArgs) => { - Name = "Skills Server", - Endpoint = endpoint, -})); - -// A server using server/discover may omit its optional identity, in which case ServerInfo is null. -string serverName = client.ServerInfo is { } serverInfo ? $"{serverInfo.Name} {serverInfo.Version}" : "the server"; -Console.WriteLine($"Connected to {serverName} at {endpoint} (protocol {client.NegotiatedProtocolVersion})"); -Console.WriteLine(); - -// 1. Capability check. Clients issue skills/list and skills/get only after observing the declaration. -if (!client.SupportsSkills()) + eventArgs.Cancel = true; + cancellation.Cancel(); +}; +Console.CancelKeyPress += cancelHandler; +try +{ + await RunAsync(endpoint, cancellation.Token); +} +catch (OperationCanceledException) when (cancellation.IsCancellationRequested) { - Console.WriteLine("The server does not declare the io.modelcontextprotocol/skills extension."); - return; + Console.WriteLine("Cancelled."); +} +finally +{ + Console.CancelKeyPress -= cancelHandler; } -// 2. Enumerate. A listing may be empty or partial; a skill can always be fetched by URI. -// The per-page overload returns each page's caching hints with its entries. Only a missing cursor ends the -// listing, so bound the number of pages and refuse a repeated cursor, as client.ListSkillsAsync() does when -// the hints are not needed. -Console.WriteLine("=== skills/list ==="); -const int MaxPages = 100; -var requestParams = new ListSkillsRequestParams(); -var seenCursors = new HashSet(StringComparer.Ordinal); -for (int pageNumber = 1; ; pageNumber++) +static async Task RunAsync(Uri endpoint, CancellationToken cancellationToken) { - if (pageNumber > MaxPages) + await using McpClient client = await McpClient.CreateAsync(new HttpClientTransport(new() { - throw new McpException($"The skill listing exceeded the limit of {MaxPages} pages."); - } + Name = "Skills Server", + Endpoint = endpoint, + }), cancellationToken: cancellationToken); - ListSkillsResult page = await client.ListSkillsAsync(requestParams); - Console.WriteLine($" page {pageNumber} caching hints: ttlMs={(page.TimeToLive is { } ttl ? ttl.TotalMilliseconds.ToString() : "(none)")} cacheScope={page.CacheScope?.ToString() ?? "(none)"}"); + // A server using server/discover may omit its optional identity, in which case ServerInfo is null. + string serverName = client.ServerInfo is { } serverInfo ? $"{serverInfo.Name} {serverInfo.Version}" : "the server"; + Console.WriteLine($"Connected to {serverName} at {endpoint} (protocol {client.NegotiatedProtocolVersion})"); + Console.WriteLine(); - foreach (Skill listed in page.Skills) + // 1. Capability check. Clients issue skills/list and skills/get only after observing the declaration. + if (!client.ServerCapabilities.SupportsSkills()) { - string manifest = listed.Resources.IsDynamic - ? "dynamic (no digests)" - : $"{listed.Resources.Resources!.Count} file(s), {listed.Resources.Resources.Sum(r => r.Size)} bytes"; - Console.WriteLine($" {listed.Uri}"); - Console.WriteLine($" name: {listed.Name}"); - Console.WriteLine($" description: {listed.Description}"); - Console.WriteLine($" manifest: {manifest}"); + Console.WriteLine("The server does not declare the io.modelcontextprotocol/skills extension."); + return; } - if (page.NextCursor is null) + // 2. Enumerate. A listing may be empty or partial; a skill can always be fetched by URI. + // The per-page overload returns each page's caching hints with its entries. Only a missing cursor ends the + // listing. Use ListSkillsAsync() when page hints aren't needed. This sample bounds pages and refuses repeated cursors. + Console.WriteLine("=== skills/list ==="); + const int MaxPages = 100; + var requestParams = new ListSkillsRequestParams(); + var seenCursors = new HashSet(StringComparer.Ordinal); + for (int pageNumber = 1; ; pageNumber++) { - break; + if (pageNumber > MaxPages) + { + throw new InvalidOperationException($"The listing exceeded this sample's budget of {MaxPages} pages."); + } + + ListSkillsResult page = await client.ListSkillsAsync(requestParams, cancellationToken); + Console.WriteLine($" page {pageNumber} caching hints: ttlMs={(page.TimeToLive is { } ttl ? ttl.TotalMilliseconds.ToString() : "(none)")} cacheScope={page.CacheScope?.ToString() ?? "(none)"}"); + + foreach (Skill listed in page.Skills) + { + string manifest = listed.Resources.IsDynamic + ? "dynamic (no digests)" + : $"{listed.Resources.Entries.Count} file(s), {GetTotalSize(listed)} bytes"; + Console.WriteLine($" {listed.Uri}"); + Console.WriteLine($" name: {listed.Name}"); + Console.WriteLine($" description: {listed.Description}"); + Console.WriteLine($" manifest: {manifest}"); + } + + if (page.NextCursor is null) + { + break; + } + + if (!seenCursors.Add(page.NextCursor)) + { + throw new InvalidDataException("The server repeated a skills/list pagination cursor."); + } + + requestParams = new ListSkillsRequestParams { Cursor = page.NextCursor }; } - if (!seenCursors.Add(page.NextCursor)) + Console.WriteLine(); + + // 3. Retrieve one skill by URI. The server's instructions reference this one, so a host confirms it directly. + Console.WriteLine("=== skills/get ==="); + Console.WriteLine($" server instructions: {client.ServerInstructions}"); + const string SkillUri = "skill://git-workflow/SKILL.md"; + Skill skill = await client.GetSkillAsync(SkillUri, cancellationToken: cancellationToken); + if (skill.Resources.IsDynamic) { - throw new McpException("The server repeated a skills/list pagination cursor."); + throw new NotSupportedException("This sample requires a static manifest to verify fetched content."); } - requestParams = new ListSkillsRequestParams { Cursor = page.NextCursor }; -} - -Console.WriteLine(); - -// 3. Retrieve one skill by URI. The server's instructions reference this one, so a host confirms it directly. -Console.WriteLine("=== skills/get ==="); -Console.WriteLine($" server instructions: {client.ServerInstructions}"); -const string SkillUri = "skill://git-workflow/SKILL.md"; -Skill skill = await client.GetSkillAsync(SkillUri); -Console.WriteLine($" {skill.Uri} ({skill.Name})"); -foreach (SkillResource file in skill.Resources.Resources!) -{ - Console.WriteLine($" {file.Uri} {file.Size,6} bytes {file.Digest}"); -} + Console.WriteLine($" {skill.Uri} ({skill.Name})"); + foreach (SkillResource file in skill.Resources.Entries) + { + Console.WriteLine($" {file.Uri} {file.Size,6} bytes {file.Digest}"); + } -Console.WriteLine(); + Console.WriteLine(); -// 4. ReadSkillResourceAsync checks manifest membership, URI, size and digest, not frontmatter. -// The host must independently verify frontmatter before loading, regardless of the server's authoring path. -// This sample parses it with YamlDotNet, a full YAML parser (see YamlFrontmatterReader.cs). -Console.WriteLine("=== resources/read (verified) ==="); -ReadResourceResult skillFile = await client.ReadSkillResourceAsync(skill, SkillUri); -if (skillFile.Contents.Single(content => content.Uri == SkillUri) is not TextResourceContents skillText) -{ - throw new SkillVerificationException("This sample requires text SKILL.md contents for frontmatter verification."); -} + // 4. ReadSkillResourceAsync checks manifest membership, URI, size and digest, not frontmatter. + // The host must independently verify frontmatter before loading, regardless of the server's authoring path. + // This sample parses it with YamlDotNet, a full YAML parser (see YamlFrontmatterReader.cs). + Console.WriteLine("=== resources/read (verified) ==="); + ReadResourceResult skillFile = await client.ReadSkillResourceAsync(skill, SkillUri, cancellationToken: cancellationToken); + if (skillFile.Contents.Single(content => new Uri(content.Uri) == new Uri(skill.Uri)) is not TextResourceContents skillText) + { + throw new NotSupportedException("This sample requires text SKILL.md contents for frontmatter verification."); + } -try -{ var fetchedFrontmatter = new YamlFrontmatterReader().Read(skillText.Text); if (!JsonNode.DeepEquals(fetchedFrontmatter, skill.Frontmatter)) { - throw new SkillVerificationException("The fetched SKILL.md frontmatter differs from the held entry."); + throw new InvalidDataException("The fetched SKILL.md frontmatter differs from the held entry."); } -} -catch (Exception exception) when (exception is YamlException or FormatException) -{ - // Invalid YAML, or frontmatter without a JSON form, rejects this load. The server-provided JSON is never a - // substitute for parsing the fetched file. - throw new SkillVerificationException("The fetched SKILL.md frontmatter could not be verified.", exception); -} -Console.WriteLine($" {SkillUri} manifest and frontmatter verified:"); -Console.WriteLine(Indent(skillText.Text)); + Console.WriteLine($" {SkillUri} manifest and frontmatter verified:"); + Console.WriteLine(Indent(skillText.Text)); -string supportingFileUri = skill.Resources.Resources.First(r => r.Uri != SkillUri).Uri; -ReadResourceResult supportingFile = await client.ReadSkillResourceAsync(skill, supportingFileUri); -string supportingSize = supportingFile.Contents.Single(content => content.Uri == supportingFileUri) switch -{ - TextResourceContents text => $"{text.Text.Length} chars", - BlobResourceContents blob => $"{blob.DecodedData.Length} bytes", - _ => "content of an unknown kind", -}; -Console.WriteLine($" {supportingFileUri} verified ({supportingSize})"); -Console.WriteLine(); + if (skill.Resources.Entries.FirstOrDefault(file => new Uri(file.Uri) != new Uri(skill.Uri)) is { } supportingEntry) + { + var supportingFile = await client.ReadSkillResourceAsync(skill, supportingEntry.Uri, cancellationToken: cancellationToken); + string supportingSize = supportingFile.Contents.Single(content => new Uri(content.Uri) == new Uri(supportingEntry.Uri)) switch + { + TextResourceContents text => $"{text.Text.Length} chars", + BlobResourceContents blob => $"{blob.DecodedData.Length} bytes", + _ => throw new NotSupportedException("This sample supports text and blob resource contents."), + }; + + Console.WriteLine($" {supportingEntry.Uri} verified ({supportingSize})"); + } -// 5. An unlisted file is a change to the skill. While acting on the held entry, the host must not read it. -Console.WriteLine("=== unlisted file ==="); -try -{ - await client.ReadSkillResourceAsync(skill, "skill://git-workflow/references/UNLISTED.md"); + Console.WriteLine(); + + // 5. An unlisted file is a change to the skill. While acting on the held entry, the host must not read it. + Console.WriteLine("=== unlisted file ==="); + const string UnlistedFileUri = "skill://git-workflow/references/UNLISTED.md"; + bool isListed = skill.Resources.Entries.Any(file => new Uri(file.Uri) == new Uri(UnlistedFileUri)); + try + { + await client.ReadSkillResourceAsync(skill, UnlistedFileUri, cancellationToken: cancellationToken); + } + catch (InvalidDataException e) when (!isListed) + { + Console.WriteLine($" refused: {e.Message}"); + } } -catch (SkillVerificationException e) + +static long GetTotalSize(Skill skill) { - Console.WriteLine($" refused: {e.Message}"); + try + { + return skill.Resources.Entries!.Sum(file => file.Size); + } + catch (OverflowException exception) + { + throw new OverflowException( + $"The manifest for '{skill.Uri}' has a total size greater than Int64.MaxValue.", exception); + } } static string Indent(string text) => diff --git a/samples/SkillsClient/README.md b/samples/SkillsClient/README.md index 08e716b72..c79a1c63d 100644 --- a/samples/SkillsClient/README.md +++ b/samples/SkillsClient/README.md @@ -1,15 +1,15 @@ # Skills Client Sample A console client that consumes [Agent Skills](https://agentskills.io/) over MCP through the Skills extension -([SEP-2640](https://github.com/modelcontextprotocol/ext-skills/blob/main/specification/stable/skills.mdx)), +([SEP-2640](https://github.com/modelcontextprotocol/ext-skills/blob/main/specification/beta/skills.mdx)), doing what a host's skill-loading path does: 1. Connects over Streamable HTTP and checks that the server declares `io.modelcontextprotocol/skills` - (`client.SupportsSkills()`). -2. Pages through the catalog with `skills/list` using the per-page overload (`client.ListSkillsAsync(requestParams)`), + (`client.ServerCapabilities.Extensions`). +2. Pages through the catalog with `skills/list` using `client.ListSkillsAsync(requestParams)`, so each page's `ttlMs` and `cacheScope` hints are shown with that page's entries. Only a missing cursor ends the - listing; the sample stops after 100 pages and refuses a repeated cursor. When the hints are not needed, - `client.ListSkillsAsync()` follows the pages itself, within 100 pages and 1,000 entries by default. + listing; the sample stops after 100 pages and refuses a repeated cursor. Both safeguards are this sample's + application policy, not behavior added by the SDK's single-page method. 3. Retrieves one skill by URI with `skills/get` (`client.GetSkillAsync(uri)`), the way a host confirms a URI it found in server instructions. 4. Reads the skill's files with `resources/read` and verifies manifest membership, URI, digest and size @@ -31,27 +31,30 @@ Then run the client in another: dotnet run --project samples/SkillsClient/SkillsClient.csproj ``` -To run against another Streamable HTTP server, pass its endpoint: +To run against another instance of this sample server, pass its endpoint: ```bash dotnet run --project samples/SkillsClient/SkillsClient.csproj -- https://skills.example.com/mcp ``` -Expected output (abridged): +Press Ctrl+C to cancel connection or an in-flight operation and exit cleanly. The same cancellation token is +forwarded through listing, get, and verified reads. + +Expected output (abridged; byte counts and digests vary with checkout line endings): ``` === skills/list === - page 1 caching hints: ttlMs=300000 cacheScope=Public + page 1 caching hints: ttlMs=0 cacheScope=Private skill://git-workflow/SKILL.md name: git-workflow - manifest: 3 file(s), 1523 bytes + manifest: 3 file(s), bytes skill://refunds/SKILL.md name: refunds ... === skills/get === skill://git-workflow/SKILL.md (git-workflow) - skill://git-workflow/SKILL.md 724 bytes sha256:… + skill://git-workflow/SKILL.md bytes sha256: ... === resources/read (verified) === @@ -70,11 +73,14 @@ Expected output (abridged): from the same server. Treat skill content as untrusted model input and tag it with its originating server. - Digest verification is not frontmatter verification. The sample also parses the fetched `SKILL.md` frontmatter with YamlDotNet, a full YAML parser (`YamlFrontmatterReader.cs`), converts it to JSON with YAML 1.2 core-schema - typing, and compares it to the entry. It rejects the skill on a difference, on invalid YAML, on a raw character the - server also rejects (one outside YAML's printable set, or a CR without a line feed, NEL, LS or PS, which YamlDotNet - would accept, drop or read as a line break), on a tag it does not convert, on frontmatter JSON cannot represent, or - on frontmatter past its limits on nesting depth and alias expansion, including an alias cycle. A host can use any - full YAML parser, but never the entry's own frontmatter. + typing, and compares it to the entry. A difference or parse failure rejects the load. See the + [Skills guide](../../docs/concepts/skills/skills.md) for parsing and verification details. - This is not a complete host: approval binding, origin isolation, and permission decisions are left out. -- The ordinary list helpers remain strict. `ListSkillsWithDiagnosticsAsync` is an explicit alternative - for obtaining valid entries together with `RejectedEntries`; it never treats an invalid entry as loadable. +- The sample requires text `SKILL.md` contents and a single matching content item. These are sample limitations, + not SDK restrictions. +- Listing is strict: a malformed or invalid entry fails the page rather than being silently omitted. + Applications own pagination and any count, byte, or time budget; the SDK imposes no per-skill file-count or + total-byte ceiling. +- Client entry-validation and integrity failures use `InvalidDataException`, without a Skills-specific exception + subclass. RPC failures retain the usual MCP exception types. +- The Skills APIs are experimental (`MCPEXP005`); this sample opts in through its project file. diff --git a/samples/SkillsClient/SkillsClient.csproj b/samples/SkillsClient/SkillsClient.csproj index 7428e384c..f42605b98 100644 --- a/samples/SkillsClient/SkillsClient.csproj +++ b/samples/SkillsClient/SkillsClient.csproj @@ -5,7 +5,7 @@ net8.0 enable enable - $(NoWarn);MCPEXP001 + $(NoWarn);MCPEXP001;MCPEXP005 diff --git a/samples/SkillsServer/DatabaseStyleHandlers.cs b/samples/SkillsServer/DatabaseStyleHandlers.cs new file mode 100644 index 000000000..171356c43 --- /dev/null +++ b/samples/SkillsServer/DatabaseStyleHandlers.cs @@ -0,0 +1,184 @@ +using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol; +using ModelContextProtocol.Extensions.Skills; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using System.Security.Claims; +using System.Text; + +namespace SkillsServer; + +// An illustrative application repository backed by in-memory rows, not a database implementation. +// In a real repository, push tenant/permission/cursor predicates and Take into the database query. +internal static class DatabaseStyleHandlers +{ + public static IMcpServerBuilder WithRepositoryHandlers(this IMcpServerBuilder builder) + { + builder.Services.AddScoped(); + return builder + .WithSkills(options => + { + options.ListSkillsHandler = (context, cancellationToken) => + GetRepository(context) + .ListSkillsAsync(context.User, context.Params.Cursor, cancellationToken); + options.GetSkillHandler = async (context, cancellationToken) => new GetSkillResult + { + Skill = await GetRepository(context) + .FindSkillAsync(context.User, context.Params.Uri, cancellationToken) ?? + throw new McpProtocolException("Skill is unavailable.", McpErrorCode.InvalidParams), + }; + }) + .WithListResourcesHandler((context, cancellationToken) => + GetRepository(context) + .ListFilesAsync(context.User, context.Params.Cursor, cancellationToken)) + .WithReadResourceHandler(async (context, cancellationToken) => new ReadResourceResult + { + Contents = + [ + await GetRepository(context) + .ReadFileAsync(context.User, context.Params.Uri, cancellationToken) ?? + throw new McpProtocolException("Resource is unavailable.", McpErrorCode.InvalidParams), + ], + }); + } + + private static ExampleSkillRepository GetRepository(MessageContext context) => + (context.Services ?? throw new InvalidOperationException( + "The example repository handlers require request services. Pass the configured service provider to McpServer.Create, or create the server through dependency injection.")) + .GetRequiredService(); +} + +internal sealed class ExampleSkillRepository(ExampleSkillStore store) +{ + private const int PageSize = 1; + private static readonly UTF8Encoding s_strictUtf8 = new(false, true); + + public ValueTask ListSkillsAsync(ClaimsPrincipal? user, string? cursor, CancellationToken cancellationToken) + { + var (rows, nextCursor) = QueryPage(user, cursor, cancellationToken); + return new(new ListSkillsResult + { + Skills = rows.Select(row => row.Skill.ProtocolSkill!).ToList(), + NextCursor = nextCursor, + }); + } + + public ValueTask ListFilesAsync(ClaimsPrincipal? user, string? cursor, CancellationToken cancellationToken) + { + var (rows, nextCursor) = QueryPage(user, cursor, cancellationToken); + return new(new ListResourcesResult + { + Resources = rows.Select(row => new Resource + { + Uri = row.Skill.Id, + Name = row.Skill.ProtocolSkill!.Name!, + MimeType = "text/markdown", + }).ToList(), + NextCursor = nextCursor, + }); + } + + public ValueTask FindSkillAsync(ClaimsPrincipal? user, string uri, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + return new(FindVisibleRow(user, uri)?.Skill.ProtocolSkill); + } + + public ValueTask ReadFileAsync(ClaimsPrincipal? user, string uri, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + var row = FindVisibleRow(user, uri); + return new(row is null ? null : new TextResourceContents + { + Uri = row.Skill.Id, + MimeType = "text/markdown", + Text = row.Markdown, + }); + } + + private ExampleSkillRow? FindVisibleRow(ClaimsPrincipal? user, string uri) + { + var visible = VisibleRows(user); + var row = store.FindRow(uri); + return row is not null && visible.Contains(row) ? row : null; + } + + private (ExampleSkillRow[] Rows, string? NextCursor) QueryPage( + ClaimsPrincipal? user, string? cursor, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + string? after = DecodeCursor(cursor); + var rows = VisibleRows(user) + .Where(row => row.Listed && (after is null || string.CompareOrdinal(row.Skill.Id, after) > 0)) + .OrderBy(row => row.Skill.Id, StringComparer.Ordinal) + .Take(PageSize + 1) + .ToArray(); + bool hasMore = rows.Length > PageSize; + var page = hasMore ? rows.Take(PageSize).ToArray() : rows; + return (page, hasMore ? Convert.ToBase64String(Encoding.UTF8.GetBytes(page[page.Length - 1].Skill.Id)) : null); + } + + private IEnumerable VisibleRows(ClaimsPrincipal? user) + { + string? tenant = user?.FindFirst("tenant")?.Value; + if (user?.Identity?.IsAuthenticated != true || string.IsNullOrEmpty(tenant)) + { + throw new McpProtocolException("An authenticated tenant is required.", McpErrorCode.InvalidRequest); + } + + return store.Rows.Where(row => row.Tenant == tenant && user.IsInRole(row.RequiredRole)); + } + + private static string? DecodeCursor(string? cursor) + { + if (cursor is null) + { + return null; + } + + try + { + return s_strictUtf8.GetString(Convert.FromBase64String(cursor)); + } + catch (Exception exception) when (exception is FormatException or DecoderFallbackException) + { + throw new McpProtocolException("Invalid cursor.", McpErrorCode.InvalidParams); + } + } +} + +internal sealed class ExampleSkillStore +{ + private readonly McpServerSkillCollection _skills = new(); + + public ExampleSkillStore(IReadOnlyList rows) + { + Rows = Array.AsReadOnly(rows.ToArray()); + foreach (var row in Rows) + { + _skills.Add(row.Skill); + } + } + + public IReadOnlyList Rows { get; } + + public ExampleSkillRow? FindRow(string uri) => + _skills.TryGetPrimitive(uri, out var skill) ? + Rows.Single(row => ReferenceEquals(row.Skill, skill)) : null; +} + +// One SKILL.md file per row keeps the example focused on request services and query semantics. +internal sealed record ExampleSkillRow(string Tenant, string RequiredRole, bool Listed, McpServerSkill Skill, string Markdown) +{ + public static ExampleSkillRow Create(string tenant, string name, string requiredRole = "reader", bool listed = true) + { + string markdown = $"---\nname: {name}\ndescription: Instructions for {tenant}\n---\n# {name}\n"; + var skill = McpServerSkillPackage.Create( + [McpServerSkillFile.FromText("SKILL.md", markdown)], + new McpServerSkillPackageCreateOptions + { + Uri = $"skill://store/{tenant}/{name}/SKILL.md", + }).Skill; + return new(tenant, requiredRole, listed, skill, markdown); + } +} diff --git a/samples/SkillsServer/Program.cs b/samples/SkillsServer/Program.cs index f16c7c0c1..e80ddb4ba 100644 --- a/samples/SkillsServer/Program.cs +++ b/samples/SkillsServer/Program.cs @@ -1,7 +1,9 @@ // Demonstrates serving Agent Skills over MCP with the Skills extension (SEP-2640) from a Streamable HTTP server. // -// Each skill is a directory under ./Skills containing a SKILL.md and, optionally, supporting files. -// WithSkillsFromDirectory reads every skill directory, parses the YAML frontmatter of each SKILL.md, computes the +// Each skill is an immediate child directory of ./Skills containing a SKILL.md and, optionally, supporting files. +// WithSkillsFromSubdirectories discovers skills in those immediate child directories, not deeper directories. +// Selected files within each skill are collected recursively; a nested SKILL.md is not a separate registration. +// The method parses the YAML frontmatter of each skill's root SKILL.md, computes the // SHA-256 digest and size of every file, and registers both the skills' entries (what skills/list and skills/get // return) and the resources that serve their files (what resources/read returns). @@ -11,8 +13,8 @@ var builder = WebApplication.CreateBuilder(args); // Every immediate subdirectory of ./Skills that contains a SKILL.md becomes a skill. The frontmatter is read from -// each SKILL.md, and the skill's URI is skill://{name}/SKILL.md. Use McpServerSkill.CreateFromDirectory or -// McpServerSkill.Create with WithSkills for finer control, for example an organizational URI prefix per skill. +// each SKILL.md, and the skill's URI is skill://{name}/SKILL.md. Use McpServerSkillPackage.CreateFromDirectory or +// McpServerSkillPackage.Create with WithSkillPackages for finer control, for example an organizational URI per skill. string skillsRoot = Path.Combine(AppContext.BaseDirectory, "Skills"); builder.Services @@ -26,12 +28,7 @@ "Before making commits in this repository, load the skill at skill://git-workflow/SKILL.md."; }) .WithHttpTransport() - .WithSkillsFromDirectory(skillsRoot, configure: options => - { - // Every caller sees the same catalog, so the listing may be shared by intermediaries for a while. - options.TimeToLive = TimeSpan.FromMinutes(5); - options.CacheScope = CacheScope.Public; - }); + .WithSkillsFromSubdirectories(skillsRoot); var app = builder.Build(); diff --git a/samples/SkillsServer/README.md b/samples/SkillsServer/README.md index 48e247d9f..19330aa7f 100644 --- a/samples/SkillsServer/README.md +++ b/samples/SkillsServer/README.md @@ -1,21 +1,29 @@ # Skills Server Sample -A Streamable HTTP MCP server that serves two [Agent Skills](https://agentskills.io/) through the MCP Skills -extension ([SEP-2640](https://github.com/modelcontextprotocol/ext-skills/blob/main/specification/stable/skills.mdx)). +A Streamable HTTP MCP server that serves [Agent Skills](https://agentskills.io/) through the MCP Skills +extension ([SEP-2640](https://github.com/modelcontextprotocol/ext-skills/blob/main/specification/beta/skills.mdx)). -Each skill is a directory under [`Skills/`](Skills) with a `SKILL.md` and supporting files: +Each skill is an immediate child directory of [`Skills/`](Skills) with a `SKILL.md` and supporting files: | Skill URI | Files | | ---------------------------------------- | -------------------------------------------------------------- | | `skill://git-workflow/SKILL.md` | `SKILL.md`, `references/COMMIT_STYLE.md`, `templates/PULL_REQUEST.md` | | `skill://refunds/SKILL.md` | `SKILL.md`, `policy/REFUND_POLICY.md`, `examples/approved.md`, `examples/declined.md` | -`WithSkillsFromDirectory` reads every skill directory, parses the YAML frontmatter of each `SKILL.md`, computes the +`WithSkillsFromSubdirectories` discovers skills in immediate child directories containing a `SKILL.md`. +It does not search deeper directories for additional skills. Selected files within each discovered skill are +collected recursively; a nested `SKILL.md` is a file of that skill, not a separate registration. +The method parses the YAML frontmatter of each skill's root `SKILL.md`, computes the SHA-256 digest and size of every file, and registers the `skills/list` and `skills/get` methods together with the resources that serve the files, in one call. Each skill's URI is `skill://{name}/SKILL.md`, where `name` comes from -its frontmatter; pass a `uriPrefix` to place skills under an organizational path such as `skill://acme/billing/`. +its frontmatter; set `McpServerSkillPackageDiscoveryOptions.UriPrefix` to place skills under an organizational path +such as `skill://acme/billing/`. Directory discovery excludes dot-prefixed, hidden and system entries by default. The explicit -`includeHiddenAndSystemFiles: true` overload opts in; included links are still rejected. +`IncludeHiddenAndSystemFiles` option opts in; included links are still rejected. + +Use `WithSkillFromDirectory` for a single skill directory or the standalone `McpServerSkillPackage` factories for +explicit authoring. `WithSkillPackages` registers each package's skill and resources through the ordinary, +independent first-wins paths. Configure runtime handlers and filters separately with `WithSkills(options => ...)`. ## Run @@ -39,7 +47,35 @@ Any MCP host that supports Streamable HTTP can also be pointed at `http://localh `skill://acme/billing/refunds/SKILL.md`; only the final segment must equal the skill's `name`. - The server's instructions point the agent at `skill://git-workflow/SKILL.md` directly. A host can confirm a URI with `skills/get` and read it with `resources/read` without ever listing the catalog. -- The listing is identical for every caller, so it advertises `cacheScope: public` with a five-minute `ttlMs`. - A catalog that varies by principal must not do that. +- Registered operations use conservative caching hints (`private`, immediately stale). Application handlers and filters + can provide per-result hints; public caching requires a result identical for every caller. - See [docs/concepts/skills/skills.md](../../docs/concepts/skills/skills.md) for the full walkthrough, - including how to implement a custom `IMcpSkillCatalog`. + including the primitive collection and typed custom handlers that resolve request-scoped application services. +- The Skills APIs are experimental (`MCPEXP005`); this sample opts in through its project file. + +## Database-style request handlers + +[DatabaseStyleHandlers.cs](DatabaseStyleHandlers.cs) is a compact alternative configuration using the same +`context.Services.GetRequiredService()` pattern for Skills list/get and resource list/read. +It is compiled and tested, but not enabled by this directory-backed server. It is an alternative recipe for an +application with authentication, not an authenticated mode of the default server/client. Such an application +could register fixed sample rows and call `WithRepositoryHandlers()`: + +```csharp +using SkillsServer; + +builder.Services.AddSingleton(new ExampleSkillStore( +[ + ExampleSkillRow.Create("acme", "refunds"), + ExampleSkillRow.Create("acme", "direct-only", listed: false), +])); +builder.Services.AddMcpServer().WithHttpTransport().WithRepositoryHandlers(); +``` + +The store is **in-memory illustrative data, not a database implementation**. Supply an authenticated principal +with a `tenant` claim and the appropriate role using the application's authentication middleware. The example +does not create identities or implement an authentication scheme. The repository applies tenant and permission +predicates before pagination; direct skill lookup and file reads apply the same policy without requiring listing +membership. Real database code should execute those predicates and the page limit in the query and pass the +handler's cancellation token into async database calls. The fixed sample builds one SDK URI lookup index so get +and read resolve aliases to the same canonical row; it is not a pattern for loading an entire database into memory. diff --git a/samples/SkillsServer/SkillsServer.csproj b/samples/SkillsServer/SkillsServer.csproj index 2833a3dd7..e926f406b 100644 --- a/samples/SkillsServer/SkillsServer.csproj +++ b/samples/SkillsServer/SkillsServer.csproj @@ -4,7 +4,7 @@ net9.0 enable enable - $(NoWarn);MCPEXP001 + $(NoWarn);MCPEXP001;MCPEXP005 diff --git a/src/Common/Experimentals.cs b/src/Common/Experimentals.cs index e61f84199..11b2ddf8e 100644 --- a/src/Common/Experimentals.cs +++ b/src/Common/Experimentals.cs @@ -18,6 +18,9 @@ namespace ModelContextProtocol; /// package is one such consumer. These APIs remain experimental until additional /// extensibility scenarios validate the design. /// +/// +/// MCPEXP005 covers the experimental MCP Skills extension APIs. +/// /// /// /// When an experimental API is associated with an experimental specification, the message @@ -65,6 +68,21 @@ internal static class Experimentals /// public const string Apps_Url = "https://github.com/modelcontextprotocol/csharp-sdk/blob/main/docs/list-of-diagnostics.md#mcpexp003"; + /// + /// Diagnostic ID for experimental MCP Skills extension APIs. + /// + public const string Skills_DiagnosticId = "MCPEXP005"; + + /// + /// Message for the experimental MCP Skills extension APIs. + /// + public const string Skills_Message = "The MCP Skills extension is experimental and subject to change."; + + /// + /// URL for the experimental MCP Skills extension APIs. + /// + public const string Skills_Url = "https://github.com/modelcontextprotocol/csharp-sdk/blob/main/docs/list-of-diagnostics.md#mcpexp005"; + /// /// Diagnostic ID for SDK extensibility APIs that enable independently packaged features, /// such as Tasks, without requiring Core awareness of those features. diff --git a/src/ModelContextProtocol.Core/Client/StdioClientSessionTransport.cs b/src/ModelContextProtocol.Core/Client/StdioClientSessionTransport.cs index bb446af55..66a5f6400 100644 --- a/src/ModelContextProtocol.Core/Client/StdioClientSessionTransport.cs +++ b/src/ModelContextProtocol.Core/Client/StdioClientSessionTransport.cs @@ -15,13 +15,14 @@ internal sealed class StdioClientSessionTransport : StreamClientSessionTransport private readonly int? _processId; public StdioClientSessionTransport(StdioClientTransportOptions options, Process process, string endpointName, Queue stderrRollingLog, DataReceivedEventHandler errorHandler, ILoggerFactory? loggerFactory) : - base(process.StandardInput.BaseStream, process.StandardOutput.BaseStream, encoding: null, endpointName, loggerFactory) + base(process.StandardInput.BaseStream, process.StandardOutput.BaseStream, encoding: null, endpointName, loggerFactory, startReading: false) { _options = options; _process = process; _stderrRollingLog = stderrRollingLog; _errorHandler = errorHandler; try { _processId = process.Id; } catch { } + StartReading(); } /// @@ -91,7 +92,7 @@ protected override async ValueTask CleanupAsync(Exception? error = null, Cancell try { StdioClientTransport.DisposeProcess( - _process, + _process, processRunning: true, _options.ShutdownTimeout, beforeDispose: () => SetDisconnected(new ClientTransportClosedException(BuildCompletionDetails(error)))); @@ -178,7 +179,7 @@ private StdioClientCompletionDetails BuildCompletionDetails(Exception? error) Exception = error, ProcessId = _processId, }; - + try { if (StdioClientTransport.HasExited(_process)) diff --git a/src/ModelContextProtocol.Core/Client/StreamClientSessionTransport.cs b/src/ModelContextProtocol.Core/Client/StreamClientSessionTransport.cs index 7a69c1c83..3b1f86a00 100644 --- a/src/ModelContextProtocol.Core/Client/StreamClientSessionTransport.cs +++ b/src/ModelContextProtocol.Core/Client/StreamClientSessionTransport.cs @@ -36,11 +36,12 @@ internal class StreamClientSessionTransport : TransportBase /// /// Optional factory for creating loggers. If null, a NullLogger is used. /// + /// Whether to start reading immediately rather than after derived initialization. /// - /// This constructor starts a background task to read messages from the server output stream. + /// By default, this constructor starts a background task to read messages from the server output stream. /// The transport will be marked as connected once initialized. /// - public StreamClientSessionTransport(Stream serverInput, Stream serverOutput, Encoding? encoding, string endpointName, ILoggerFactory? loggerFactory) + public StreamClientSessionTransport(Stream serverInput, Stream serverOutput, Encoding? encoding, string endpointName, ILoggerFactory? loggerFactory, bool startReading = true) : base(endpointName, loggerFactory) { Throw.IfNull(serverInput); @@ -55,6 +56,15 @@ public StreamClientSessionTransport(Stream serverInput, Stream serverOutput, Enc SetConnected(); + if (startReading) + { + StartReading(); + } + } + + /// Starts reading after state used by overridden cleanup methods has been initialized. + protected void StartReading() + { // Start reading messages in the background. We use the rarer pattern of new Task + Start // in order to ensure that the body of the task will always see _readTask initialized. // It is then able to reliably null it out on completion. diff --git a/src/ModelContextProtocol.Core/CompatibilitySuppressions.xml b/src/ModelContextProtocol.Core/CompatibilitySuppressions.xml index 0497d618a..ace4ea983 100644 --- a/src/ModelContextProtocol.Core/CompatibilitySuppressions.xml +++ b/src/ModelContextProtocol.Core/CompatibilitySuppressions.xml @@ -1,3 +1,33 @@  - \ No newline at end of file + + + + CP0002 + M:ModelContextProtocol.Server.McpServerRequestHandler.get_Handler + lib/net10.0/ModelContextProtocol.Core.dll + lib/net10.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Server.McpServerRequestHandler.get_Handler + lib/net8.0/ModelContextProtocol.Core.dll + lib/net8.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Server.McpServerRequestHandler.get_Handler + lib/net9.0/ModelContextProtocol.Core.dll + lib/net9.0/ModelContextProtocol.Core.dll + true + + + CP0002 + M:ModelContextProtocol.Server.McpServerRequestHandler.get_Handler + lib/netstandard2.0/ModelContextProtocol.Core.dll + lib/netstandard2.0/ModelContextProtocol.Core.dll + true + + \ No newline at end of file diff --git a/src/ModelContextProtocol.Core/ModelContextProtocol.Core.csproj b/src/ModelContextProtocol.Core/ModelContextProtocol.Core.csproj index f3eec2350..6c06d2003 100644 --- a/src/ModelContextProtocol.Core/ModelContextProtocol.Core.csproj +++ b/src/ModelContextProtocol.Core/ModelContextProtocol.Core.csproj @@ -63,6 +63,7 @@ + diff --git a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs index 6d4579f66..f929e9a79 100644 --- a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs +++ b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs @@ -1,11 +1,19 @@ using Microsoft.Extensions.AI; +using Json.Schema; using Microsoft.Extensions.DependencyInjection; using ModelContextProtocol.Protocol; +using System.Collections; +using System.Collections.Concurrent; using System.ComponentModel; +using System.Globalization; using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; using System.Reflection; +using System.Runtime.CompilerServices; using System.Text.Json; using System.Text.Json.Nodes; +using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; using System.Text.RegularExpressions; namespace ModelContextProtocol.Server; @@ -15,6 +23,14 @@ internal sealed partial class AIFunctionMcpServerTool : McpServerTool { private readonly IReadOnlyList _metadata; private readonly bool? _returnsCallToolResult; + private OutputSchemaCache? _outputSchema; + private OutputSchemaCache? _alternateOutputSchema; + private static readonly ConditionalWeakTable s_sourceContracts = new(); + private static readonly ConditionalWeakTable s_schemaEvaluationCosts = new(); + private static readonly TimeSpan s_recoveryRegexTimeout = TimeSpan.FromMilliseconds(100); + private static readonly JsonElement s_propertyNameInstance = JsonSerializer.SerializeToElement(string.Empty, McpJsonUtilities.JsonContext.Default.String); + [ThreadStatic] + private static SourceMemberSerializationState? t_serializingSourceMember; /// /// Creates an instance for a method, specified via a instance. @@ -243,7 +259,6 @@ private AIFunctionMcpServerTool( AIFunction = function; ProtocolTool = tool; - _metadata = metadata; _returnsCallToolResult = returnsCallToolResult; } @@ -319,8 +334,24 @@ public override async ValueTask InvokeAsync( return callToolResult; } - JsonElement? structuredContent = CreateStructuredResponse(result, request.Server.NegotiatedProtocolVersion); - return result switch + // These results bypass user serialization contracts in CreateStructuredResponse. + bool isSerializedJson = result is JsonElement or JsonNode; + var recoveryTarget = request.StructuredContentRecoveryTarget; + McpServerTool? matchedTool = recoveryTarget?.Tool ?? request.MatchedPrimitive as McpServerTool; + JsonElement? matchedSchema = isSerializedJson ? null : + recoveryTarget is { } target ? target.Schema : matchedTool?.ProtocolTool.OutputSchema; + JsonElement? recoverySchema = isSerializedJson ? null : GetRecoverySchema(matchedTool, matchedSchema); + RepairTraversalBudget? sharedBudget = !isSerializedJson && recoveryTarget is not null ? new() : null; + string? protocolVersion = request.Server.NegotiatedProtocolVersion; + JsonElement? structuredContent = CreateStructuredResponse( + result, protocolVersion, recoverySchema, out JsonElement? originalContent, + out JsonElement? naturalContent, sharedBudget); + (List Identities, Action Callback)? recovery = + sharedBudget is not null && originalContent is not null && structuredContent is not null + ? CreateStructuredContentRecovery( + result, originalContent, naturalContent, structuredContent, recoveryTarget!, protocolVersion, sharedBudget) + : null; + CallToolResult response = result switch { AIContent aiContent => new() { @@ -361,6 +392,113 @@ public override async ValueTask InvokeAsync( StructuredContent = structuredContent, }, }; + + if (recovery is { } plan) + { + recoveryTarget!.Register(plan.Identities, plan.Callback); + } + + return response; + } + + private (List Identities, Action Callback)? CreateStructuredContentRecovery( + object? originalResult, JsonElement? originalContent, JsonElement? naturalContent, + JsonElement? structuredContent, + RequestContext.StructuredContentRecoveryState recoveryTarget, + string? protocolVersion, RepairTraversalBudget sharedBudget) + { + const int maxRecoveryTargets = 128; + List<(McpServerTool Tool, JsonElement? Schema)> targets = [(recoveryTarget.Tool, recoveryTarget.Schema)]; + List<(McpServerTool Tool, JsonElement? Content)> projections = []; + try + { + bool hasCustomInvocation = false; + McpServerTool current = recoveryTarget.Tool; + while (current is DelegatingMcpServerTool delegating) + { + sharedBudget.Spend(1); + if (delegating.HasCustomInvocation) + { + hasCustomInvocation = true; + if (targets.Count == maxRecoveryTargets) + { + targets.RemoveRange(1, targets.Count - 1); + break; + } + targets.Add((delegating.InnerTool, delegating.InnerTool.ProtocolTool.OutputSchema)); + } + current = delegating.InnerTool; + } + if (!hasCustomInvocation) + { + return null; + } + + JsonElement? preferredContent = naturalContent; + JsonElement? preferredStructuredContent = structuredContent; + // Capture repairs while the CLR source still belongs to this invocation. + for (int i = targets.Count - 1; i >= 0 && !sharedBudget.IsRecoveryStopped; i--) + { + var target = targets[i]; + JsonElement? recovered = AddMissingRequiredNullableProperties( + originalContent, originalResult, target.Schema, out bool isValid, sharedBudget, preferredContent); + if (isValid) + { + if (!Nullable.Equals(recovered, preferredContent)) + { + if (recovered is { } content) + { + sharedBudget.SpendCopy(content); + } + preferredStructuredContent = ShapeStructuredResponse(recovered, protocolVersion); + } + projections.Add((target.Tool, preferredStructuredContent)); + preferredContent = recovered; + } + } + } + catch (Exception exception) when (IsNonFatalRecoveryFailure(exception)) + { + sharedBudget.IsRecoveryStopped = true; + } + if (projections.Count == 0) + { + return null; + } + + List identities = [structuredContent!.Value]; + foreach (var projection in projections) + { + if (projection.Content is { } content) + { + identities.Add(content); + } + } + + return (identities, (tool, finalResult) => + { + if (finalResult.IsError == true) + { + return; + } + + // Accept only this invocation's retained projections, never replaced or reserialized JSON. + bool retained = Nullable.Equals(finalResult.StructuredContent, structuredContent); + JsonElement? recovered = null; + foreach (var projection in projections) + { + retained |= Nullable.Equals(finalResult.StructuredContent, projection.Content); + if (ReferenceEquals(projection.Tool, tool)) + { + recovered = projection.Content; + } + } + if (retained && recovered is not null) + { + finalResult.StructuredContent = recovered; + } + } + ); } /// Creates a name to use based on the supplied method and naming policy. @@ -762,27 +900,50 @@ private static void RewriteRefProperty(JsonObject schema, string keyword, string } } - private JsonElement? CreateStructuredResponse(object? aiFunctionResult, string? negotiatedProtocolVersion) + private JsonElement? CreateStructuredResponse( + object? aiFunctionResult, string? negotiatedProtocolVersion, JsonElement? recoverySchema, + out JsonElement? originalContent, out JsonElement? naturalContent, RepairTraversalBudget? sharedBudget) { + originalContent = null; + naturalContent = null; if (ProtocolTool.OutputSchema is null) { // Only provide structured responses if the tool has an output schema defined. return null; } - JsonElement? elementResult = aiFunctionResult switch + JsonElement? elementResult; + switch (aiFunctionResult) { - JsonElement jsonElement => jsonElement, - JsonNode node => JsonSerializer.SerializeToElement(node, McpJsonUtilities.JsonContext.Default.JsonNode), - null => null, - _ => JsonSerializer.SerializeToElement(aiFunctionResult, AIFunction.JsonSerializerOptions.GetTypeInfo(typeof(object))), - }; + case JsonElement jsonElement: + elementResult = jsonElement; + break; + case JsonNode node: + elementResult = JsonSerializer.SerializeToElement(node, McpJsonUtilities.JsonContext.Default.JsonNode); + break; + case null: + elementResult = null; + break; + default: + JsonSerializerOptions serializerOptions = AIFunction.JsonSerializerOptions; + JsonTypeInfo typeInfo = serializerOptions.GetTypeInfo(typeof(object)); + elementResult = JsonSerializer.SerializeToElement(aiFunctionResult, typeInfo); + break; + } + originalContent = elementResult; + naturalContent = AddMissingRequiredNullableProperties(elementResult, aiFunctionResult, recoverySchema, out _, sharedBudget); + return ShapeStructuredResponse(naturalContent, negotiatedProtocolVersion); + } + + private JsonElement? ShapeStructuredResponse(JsonElement? elementResult, string? negotiatedProtocolVersion) + { // Pre-SEP-2106 clients expect the {"result": } envelope for non-object // schemas. SEP-2106 clients see the natural shape. The classification is decided // fresh per request from the stored natural schema. if (!McpSessionHandler.SupportsNaturalOutputSchemas(negotiatedProtocolVersion) && - ShouldWrapValueForLegacyWire(ProtocolTool.OutputSchema.Value)) + ProtocolTool.OutputSchema is { } outputSchema && + ShouldWrapValueForLegacyWire(outputSchema)) { JsonNode? resultNode = elementResult is { } v ? JsonSerializer.SerializeToNode(v, McpJsonUtilities.JsonContext.Default.JsonElement) @@ -796,6 +957,3150 @@ private static void RewriteRefProperty(JsonObject schema, string keyword, string return elementResult; } + private JsonElement? AddMissingRequiredNullableProperties( + JsonElement? elementResult, + object? originalResult, + JsonElement? recoverySchema, + out bool isValid, + RepairTraversalBudget? sharedBudget = null, + JsonElement? preferredContent = null) + { + isValid = false; + JsonElement? fallbackContent = preferredContent ?? elementResult; + if (sharedBudget?.IsRecoveryStopped == true || + elementResult is not { } result || + recoverySchema is not { } outputSchema || + result.ValueKind is not (JsonValueKind.Object or JsonValueKind.Array)) + { + return fallbackContent; + } + + try + { + OutputSchemaCache cachedSchema = GetOutputSchema(outputSchema); + JsonNode? schemaNode = cachedSchema.Node; + JsonSchema? schema = cachedSchema.Schema; + if (schemaNode is null || schema is null) + { + return fallbackContent; + } + + RepairTraversalBudget budget = sharedBudget ?? new(); + // A wrapper may mutate the CLR source after serialization. Keep an already-valid projection. + if (preferredContent is { } preferred && !preferred.Equals(result) && IsSchemaValid(schema, preferred, budget)) + { + isValid = true; + return preferred; + } + if (IsSchemaValid(schema, result, budget)) + { + isValid = true; + return elementResult; + } + if (HasDuplicatePropertyNames(result)) + { + return fallbackContent; + } + budget.SpendCopy(result); + JsonNode? resultNode = JsonSerializer.SerializeToNode(result, McpJsonUtilities.JsonContext.Default.JsonElement); + if (resultNode is null) + { + return fallbackContent; + } + + JsonNode? repairedResult = RepairStructuredResult( + resultNode, + schemaNode, + schema, + originalResult is null ? null : new SourceRecoveryContext( + originalResult, AIFunction.JsonSerializerOptions.GetTypeInfo(typeof(object)), + fallbackType: GetDeclaredReturnType(originalResult)), + AIFunction.JsonSerializerOptions, + traversalBudget: budget); + + if (repairedResult is null) + { + return fallbackContent; + } + + budget.SpendCopy(repairedResult); + JsonElement repaired = JsonSerializer.SerializeToElement(repairedResult, McpJsonUtilities.JsonContext.Default.JsonNode); + isValid = true; + return repaired; + } + catch (TargetInvocationException exception) when ( + exception.InnerException is { } inner && !IsNonFatalRecoveryFailure(inner)) + { + System.Runtime.ExceptionServices.ExceptionDispatchInfo.Capture(exception.InnerException!).Throw(); + throw; + } + catch (Exception exception) when (IsNonFatalRecoveryFailure(exception)) + { + if (sharedBudget is not null) + { + sharedBudget.IsRecoveryStopped = true; + } + return fallbackContent; + } + } + + private static bool IsNonFatalRecoveryFailure(Exception exception) + { + int remaining = 128; + return Inspect(exception, ref remaining); + + static bool Inspect(Exception exception, ref int remaining) + { + if (remaining-- <= 0 || + exception is OperationCanceledException or OutOfMemoryException or StackOverflowException or AccessViolationException) + { + return false; + } + + if (exception is AggregateException aggregate) + { + foreach (Exception inner in aggregate.InnerExceptions) + { + if (!Inspect(inner, ref remaining)) + { + return false; + } + } + return true; + } + + return exception.InnerException is null || Inspect(exception.InnerException, ref remaining); + } + } + + private JsonElement? GetRecoverySchema(McpServerTool? matchedTool, JsonElement? matchedSchema) + { + McpServerTool schemaTool = matchedTool ?? this; + McpServerTool current = schemaTool; + while (current is DelegatingMcpServerTool delegating) + { + if (delegating.HasCustomInvocation) + { + // A transforming wrapper's schema describes its result, not the inner CLR value. + schemaTool = delegating.InnerTool; + } + current = delegating.InnerTool; + } + + return ReferenceEquals(current, this) + ? ReferenceEquals(schemaTool, matchedTool) ? matchedSchema : schemaTool.ProtocolTool.OutputSchema + : ProtocolTool.OutputSchema; + } + + private Type? GetDeclaredReturnType(object result) + { + Type? returnType = AIFunction.UnderlyingMethod?.ReturnType; + if (returnType is { IsGenericType: true } && + (returnType.GetGenericTypeDefinition() == typeof(Task<>) || returnType.GetGenericTypeDefinition() == typeof(ValueTask<>))) + { + returnType = returnType.GetGenericArguments()[0]; + } + + if (returnType is null || returnType == typeof(object) || !returnType.IsInstanceOfType(result)) + { + return null; + } + + return returnType; + } + + /// + /// Restores omitted required properties only when the complete result validates against the + /// published schema. The serializer can omit a value because of either the global ignore + /// policy or a member-level . Candidate values are read only + /// from members of the active serialization contract, without serializing unrelated members. + /// + private static JsonNode? RepairStructuredResult( + JsonNode resultNode, + JsonNode schemaNode, + JsonSchema schema, + SourceRecoveryContext? originalResult, + JsonSerializerOptions serializerOptions, + int remainingDepth = 32, + JsonNode? schemaDocument = null, + SchemaReferenceResolver? references = null, + List? dynamicScope = null, + RepairTraversalBudget? traversalBudget = null, + List? inheritedConditions = null, + bool includeValidAlternatives = false) + { + // An explicit schema may deliberately omit optional nulls. Preserve that valid output + // before looking for missing required members. + if (remainingDepth <= 0) + { + return null; + } + + const int maxCandidateSetEvaluations = 256; + Queue pending = new(); + Queue fallback = new(); + HashSet visited = new(StringComparer.Ordinal) { resultNode.ToJsonString() }; + schemaDocument ??= schemaNode; + traversalBudget ??= new(); + if (references is null) + { + traversalBudget.SpendCopy(schemaDocument, immutable: true); + references = new(schemaDocument, schema.BaseUri); + } + // Cache predicates independently of their callers. Conditional expectations and + // instance paths remain on each group and are checked on every tentative repair. + Dictionary<(JsonNode Schema, string Scope), JsonSchema?> branchSchemas = traversalBudget.BranchSchemas; + Dictionary conjunctiveSchemas = new(StringComparer.Ordinal); + List unevaluatedGroups = []; + Dictionary unevaluatedCandidates = new(StringComparer.Ordinal); + SchemaReferenceResolver? evaluationReferences = null; + bool skippedValidAlternatives = false; + + HashSet<(string Path, string Schema)> GetUnevaluatedApplications(JsonNode instance) + { + HashSet<(string, string)> applications = []; + try + { + if (evaluationReferences is null) + { + traversalBudget.SpendCopy(schema.Root.Source); + evaluationReferences = new( + JsonSerializer.SerializeToNode(schema.Root.Source, McpJsonUtilities.JsonContext.Default.JsonElement)!, + schema.BaseUri); + } + traversalBudget.SpendCopy(instance); + JsonElement element = JsonSerializer.SerializeToElement(instance, McpJsonUtilities.JsonContext.Default.JsonNode); + traversalBudget.SpendEvaluation(schema, element, detailed: true); + Stack results = new(); + results.Push(schema.Evaluate(element, new EvaluationOptions { OutputFormat = OutputFormat.Hierarchical })); + while (results.Count > 0) + { + traversalBudget.Spend(1); + EvaluationResults evaluation = results.Pop(); + string path = evaluation.EvaluationPath.ToString(); + // Evaluation paths retain caller provenance even when SchemaLocation is imprecise across references. + if ((path.EndsWith("/unevaluatedProperties", StringComparison.Ordinal) || + path.EndsWith("/unevaluatedItems", StringComparison.Ordinal)) && + evaluationReferences.TryResolveEvaluationPath(path, out Uri? application)) + { + applications.Add((evaluation.InstanceLocation.ToString(), application!.AbsoluteUri)); + } + + if (evaluation.Details is { } details) + { + traversalBudget.Spend(details.Count); + foreach (EvaluationResults detail in details) + { + results.Push(detail); + } + } + } + } + catch (Exception ex) when (ex is JsonException or JsonSchemaException or RefResolutionException or InvalidOperationException or ArgumentException) + { + applications.Clear(); + } + + return applications; + } + + JsonNode FinalizeRepair(JsonNode instance) + { + if (unevaluatedGroups.Count == 0) + { + return instance; + } + + HashSet<(string Path, string Schema)> applications = GetUnevaluatedApplications(instance); + HashSet checkedKeys = new(StringComparer.Ordinal); + foreach (RepairCandidateGroup group in unevaluatedGroups) + { + if (group.Unevaluated is not { } origin || + applications.Contains((origin.InstancePath, references.GetSchemaUri(origin.Schema).AbsoluteUri))) + { + continue; + } + + foreach (string key in group.Keys) + { + if (!checkedKeys.Add(key)) + { + continue; + } + if (checkedKeys.Count > maxCandidateSetEvaluations) + { + return instance; + } + traversalBudget.Spend(1); + + if (!unevaluatedCandidates.TryGetValue(key, out RepairCandidate? candidate)) + { + continue; + } + + string parentPath = candidate.ParentPath; + string name = candidate.PropertyName; + if ((TryGetNodeAtPath(resultNode, parentPath, out JsonNode? originalParent) && + originalParent is JsonObject originalObject && originalObject.ContainsKey(name)) || + !TryGetNodeAtPath(instance, parentPath, out JsonNode? parent) || + parent is not JsonObject objectParent || !objectParent.TryGetPropertyValue(name, out JsonNode? value)) + { + continue; + } + + objectParent.Remove(name); + if (!IsRepairValid(instance)) + { + objectParent[name] = value; + } + } + } + + return instance; + } + + JsonSchema? GetBranchSchema(JsonNode branch, List scope) + { + var schemaKey = (branch, references.GetScopeKey(scope)); + if (!branchSchemas.TryGetValue(schemaKey, out JsonSchema? branchSchema)) + { + traversalBudget.SpendSchema(schemaDocument); + JsonNode document = schemaDocument.DeepClone(); + references.BindDynamicReferences(schemaDocument, document, scope); + if (document is JsonObject documentObject) + { + documentObject["$id"] = references.GetSchemaUri(schemaDocument).AbsoluteUri; + } + + JsonObject branchDocument = new() + { + ["$ref"] = references.GetSchemaUri(branch).AbsoluteUri, + ["$defs"] = new JsonObject { ["document"] = document }, + }; + if (references.SupplementalSchemas.Contains(branch)) + { + traversalBudget.SpendSchema(branch); + branchDocument["$defs"]!["scope"] = branch.DeepClone(); + } + TryBuildJsonSchema(branchDocument, out branchSchema); + branchSchemas.Add(schemaKey, branchSchema); + } + + return branchSchema; + } + + bool AreConditionsValid(JsonNode instance, List conditions) + { + foreach (RepairCondition condition in conditions) + { + JsonSchema? conditionSchema = GetBranchSchema(condition.Schema, condition.DynamicScope); + if (conditionSchema is null || + !TryGetNodeAtPath(instance, condition.InstancePath, out JsonNode? conditionInstance) || + conditionInstance is null || + IsCandidateValid(conditionSchema, ProjectConditionInstance(conditionInstance, condition.Schema, traversalBudget)) != condition.Expected) + { + return false; + } + } + + return true; + } + + bool IsCandidateValid(JsonSchema candidateSchema, JsonNode? instance) + { + traversalBudget.SpendCopy(instance); + return IsSchemaValid(candidateSchema, instance, traversalBudget); + } + + bool IsRepairValid(JsonNode instance) => + IsCandidateValid(schema, instance) && + (inheritedConditions is null || AreConditionsValid(instance, inheritedConditions)); + + if (IsRepairValid(resultNode)) + { + return null; + } + + JsonNode? EnqueueRepairs(JsonNode instance, int depth) + { + if (depth >= remainingDepth) + { + return null; + } + + Dictionary contexts = new(StringComparer.Ordinal); + List groups = []; + HashSet<(string Path, string Schema)>? applications = null; + SchemaTraversal traversal = new(traversalBudget, (subschema, path) => + (applications ??= GetUnevaluatedApplications(instance)).Contains((path, references.GetSchemaUri(subschema).AbsoluteUri)), + (subschema, value, scope) => GetBranchSchema(subschema, scope) is { } compiled && IsCandidateValid(compiled, value), + includeValidAlternatives); + CollectSchemaContexts( + instance, schemaNode, references.GetResourceRoot(schemaNode), string.Empty, contexts, traversal, + groups, references, dynamicScope); + skippedValidAlternatives |= traversal.SkippedValidAlternatives; + traversalBudget.Spend(groups.Count); + unevaluatedGroups.AddRange(groups.Where(static group => group.Unevaluated is not null)); + if (inheritedConditions is { Count: > 0 }) + { + foreach (RepairCandidateGroup group in groups) + { + traversalBudget.Spend(inheritedConditions.Count + group.Conditions.Count); + } + groups = groups.Select(group => group with + { + Conditions = [.. inheritedConditions, .. group.Conditions], + }).ToList(); + } + + // Conjunctive child schemas must qualify together, not as independent alternatives. + var envelopes = groups.Where(static group => group.EnclosesInstance) + .GroupBy(group => (group.InstancePath, Scope: references.HasDynamicReferences ? references.GetScopeKey(group.DynamicScope) : string.Empty)) + .ToDictionary(static bucket => bucket.Key, static bucket => bucket.ToList()); + HashSet combinedEnvelopes = []; + List qualifiedGroups = []; + foreach (RepairCandidateGroup group in groups) + { + if (combinedEnvelopes.Contains(group)) + { + continue; + } + + if (!group.EnclosesInstance) + { + qualifiedGroups.Add(group); + continue; + } + + var key = (group.InstancePath, references.HasDynamicReferences ? references.GetScopeKey(group.DynamicScope) : string.Empty); + List conjuncts = envelopes[key].Where(other => + { + traversalBudget.Spend(1 + other.Choices.Count + other.Conditions.Count + (other.Alternatives?.Count ?? 0)); + return other.Choices.SequenceEqual(group.Choices) && other.Conditions.SequenceEqual(group.Conditions) && + (other.Alternatives ?? []).SequenceEqual(group.Alternatives ?? []); + }).ToList(); + if (conjuncts.Count == 1) + { + qualifiedGroups.Add(group); + continue; + } + + string schemaKey = string.Join("\n", conjuncts.Select(item => references.GetSchemaUri(item.Schema).AbsoluteUri) + .Distinct(StringComparer.Ordinal).OrderBy(static uri => uri, StringComparer.Ordinal)); + if (!conjunctiveSchemas.TryGetValue(schemaKey, out JsonNode? conjunction)) + { + JsonArray clauses = []; + foreach (string uri in schemaKey.Split('\n')) + { + traversalBudget.SpendText(uri); + clauses.Add((JsonNode)new JsonObject { ["$ref"] = uri }); + } + conjunction = new JsonObject { ["$id"] = "https://mcp.invalid/recovery/" + Guid.NewGuid().ToString("N"), ["allOf"] = clauses }; + references.AddSupplementalSchema(conjunction); + conjunctiveSchemas.Add(schemaKey, conjunction); + } + + qualifiedGroups.Add(group with + { + Schema = conjunction, + Keys = CombineKeys(direct: false), + DirectKeys = CombineKeys(direct: true), + }); + combinedEnvelopes.UnionWith(conjuncts); + + HashSet CombineKeys(bool direct) + { + HashSet keys = new(StringComparer.Ordinal); + foreach (RepairCandidateGroup conjunct in conjuncts) + { + HashSet source = direct ? conjunct.DirectKeys : conjunct.Keys; + traversalBudget.Spend(source.Count); + keys.UnionWith(source); + } + return keys; + } + } + groups = qualifiedGroups; + + List candidates = CreateRepairCandidates( + instance, originalResult, serializerOptions, contexts, traversalBudget); + if (unevaluatedGroups.Count > 0) + { + foreach (RepairCandidate candidate in candidates) + { + unevaluatedCandidates[candidate.Key] = candidate; + } + } + if (candidates.Count == 0) + { + return null; + } + + foreach (RepairCandidateGroup group in groups) + { + traversalBudget.Spend(group.Keys.Count); + } + traversalBudget.Spend(candidates.Count); + HashSet grouped = new(groups.SelectMany(static group => group.Keys), StringComparer.Ordinal); + Dictionary candidatesByKey = candidates.ToDictionary(static candidate => candidate.Key, StringComparer.Ordinal); + List ungrouped = candidates.Where(candidate => !grouped.Contains(candidate.Key)).ToList(); + Dictionary<(JsonNode Applicator, string Path), JsonNode> exclusiveChoices = new(); + Dictionary stableConditions = new(); + RepairMutationPaths? mutations = null; + traversalBudget.SpendCopy(instance); + JsonNode combinedResult = instance.DeepClone(); + bool hasCombinedRepairs = false; + HashSet completedInstances = new(StringComparer.Ordinal); + foreach (RepairCandidate candidate in ungrouped) + { + ApplyRepairCandidate(combinedResult, candidate, traversalBudget); + hasCombinedRepairs = true; + } + + foreach (RepairCandidateGroup group in groups) + { + // A guard untouched by every candidate cannot be rescued by searching subsets. + bool rejected = false; + foreach (RepairCondition condition in group.Conditions) + { + if (!stableConditions.TryGetValue(condition, out bool? matches)) + { + matches = IsConditionUnaffected(condition, mutations ??= new(candidates, traversalBudget), traversalBudget) + ? AreConditionsValid(instance, [condition]) + : null; + stableConditions.Add(condition, matches); + } + if (matches == false) + { + rejected = true; + break; + } + } + if (rejected) + { + continue; + } + + string enclosingPath = group.InstancePath; + while (enclosingPath.Length > 0 && !completedInstances.Contains(enclosingPath)) + { + enclosingPath = enclosingPath.Substring(0, enclosingPath.LastIndexOf('/')); + } + if (enclosingPath.Length > 0) + { + continue; + } + + if (group.Choices.Any(choice => + exclusiveChoices.TryGetValue((choice.Applicator, choice.InstancePath), out JsonNode? selected) && + !ReferenceEquals(selected, choice.Branch))) + { + continue; + } + + if (!TryGetNodeAtPath(combinedResult, group.InstancePath, out JsonNode? currentBranch) || currentBranch is null) + { + continue; + } + + JsonSchema? branchSchema = GetBranchSchema(group.Schema, group.DynamicScope); + if (branchSchema is null) + { + continue; + } + + // A leaf must not lock an enclosing oneOf branch whose assertions fail. + // Once that ancestor has qualified, avoid revalidating its entire subtree. + List unqualifiedAncestors = group.Choices.Where(choice => + !(ReferenceEquals(choice.Branch, group.Schema) && choice.InstancePath == group.InstancePath) && + !exclusiveChoices.ContainsKey((choice.Applicator, choice.InstancePath))).ToList(); + + // Compose independently valid subtree repairs before searching whole-document subsets. + List groupCandidates = []; + foreach (string key in group.Keys) + { + traversalBudget.Spend(1); + if (candidatesByKey.TryGetValue(key, out RepairCandidate? candidate)) + { + groupCandidates.Add(candidate); + } + } + + foreach (bool includeDescendants in new[] { false, true }) + { + List branchCandidates = includeDescendants + ? groupCandidates + : groupCandidates.Where(candidate => + candidate.ParentPath == group.InstancePath && group.DirectKeys.Contains(candidate.Key)).ToList(); + traversalBudget.SpendCopy(currentBranch); + JsonNode branchResult = currentBranch.DeepClone(); + foreach (RepairCandidate candidate in branchCandidates) + { + ApplyRepairCandidate(branchResult, candidate with { ParentPath = candidate.ParentPath.Substring(group.InstancePath.Length) }, traversalBudget); + } + + bool branchIsValid = IsCandidateValid(branchSchema, branchResult); + if (!branchIsValid && (branchCandidates.Count > 0 || !includeDescendants) && depth + 1 < remainingDepth && + !(group.InstancePath.Length == 0 && ReferenceEquals(group.Schema, schemaNode) && + references.GetScopeKey(group.DynamicScope) == + references.GetScopeKey(dynamicScope ?? [references.GetResourceRoot(schemaNode)]))) + { + SourceRecoveryContext? branchSource = null; + if (originalResult is not null) + { + TryGetSourceObjectAtPath(originalResult, instance, group.InstancePath, out branchSource); + } + + if (RepairStructuredResult( + branchResult, group.Schema, branchSchema, branchSource, + serializerOptions, remainingDepth - depth - 1, schemaDocument, references, + group.DynamicScope, traversalBudget, + group.Conditions.Where(condition => + condition.InstancePath == group.InstancePath || + condition.InstancePath.StartsWith(group.InstancePath + "/", StringComparison.Ordinal)) + .Select(condition => condition with + { + InstancePath = condition.InstancePath.Substring(group.InstancePath.Length), + }).ToList(), includeValidAlternatives) is JsonNode repairedBranch) + { + branchResult = repairedBranch; + branchIsValid = true; + } + } + + if (branchIsValid && (group.Conditions.Count > 0 || unqualifiedAncestors.Count > 0)) + { + // Conditions belong to the caller's instance and dynamic scope, which + // may differ from the nested branch being repaired. Test the tentative + // replacement, not the original instance or an isolated leaf schema. + JsonNode tentativeResult = branchResult; + if (group.InstancePath.Length > 0) + { + ReplaceRepairBranch(currentBranch, group.InstancePath, branchResult); + tentativeResult = combinedResult; + } + + try + { + branchIsValid = AreConditionsValid(tentativeResult, group.Conditions); + foreach (RepairExclusiveChoice ancestor in unqualifiedAncestors) + { + if (!branchIsValid) + { + break; + } + + JsonSchema? ancestorSchema = GetBranchSchema(ancestor.Branch, ancestor.DynamicScope); + branchIsValid = ancestorSchema is not null && + TryGetNodeAtPath(tentativeResult, ancestor.InstancePath, out JsonNode? ancestorInstance) && + ancestorInstance is not null && + IsCandidateValid(ancestorSchema, ancestorInstance); + } + } + finally + { + if (group.InstancePath.Length > 0) + { + ReplaceRepairBranch(branchResult, group.InstancePath, currentBranch); + } + } + } + + if (branchIsValid) + { + if (group.InstancePath.Length == 0) + { + combinedResult = branchResult; + } + else + { + ReplaceRepairBranch(currentBranch, group.InstancePath, branchResult); + } + + hasCombinedRepairs = true; + if (group.EnclosesInstance) + { + completedInstances.Add(group.InstancePath); + } + foreach (RepairExclusiveChoice choice in group.Choices) + { + exclusiveChoices[(choice.Applicator, choice.InstancePath)] = choice.Branch; + } + + break; + } + } + } + + if (hasCombinedRepairs) + { + // The common case composes independent repairs. Avoid allocating subset queues + // (or repeatedly scanning the full candidate list) when that result already works. + if (IsRepairValid(combinedResult)) + { + return combinedResult; + } + + pending.Enqueue(new(combinedResult, [], depth + 1)); + } + + pending.Enqueue(new(instance, candidates, depth + 1)); + foreach (RepairCandidateGroup group in groups) + { + if (pending.Count >= maxCandidateSetEvaluations) + { + break; + } + + traversalBudget.Spend(ungrouped.Count); + List branchCandidates = [.. ungrouped]; + foreach (string key in group.Keys) + { + traversalBudget.Spend(1); + if (candidatesByKey.TryGetValue(key, out RepairCandidate? candidate)) + { + branchCandidates.Add(candidate); + } + } + + if (branchCandidates.Count > 0 && branchCandidates.Count < candidates.Count && + pending.Count < maxCandidateSetEvaluations) + { + pending.Enqueue(new(instance, branchCandidates, depth + 1)); + } + } + + for (int i = 0; i < candidates.Count && fallback.Count < maxCandidateSetEvaluations; i++) + { + traversalBudget.Spend(1); + fallback.Enqueue(new(instance, [candidates[i]], depth + 1)); + } + + for (int i = 0; i < candidates.Count && fallback.Count < maxCandidateSetEvaluations; i++) + { + for (int j = i + 1; j < candidates.Count && fallback.Count < maxCandidateSetEvaluations; j++) + { + traversalBudget.Spend(2); + fallback.Enqueue(new(instance, [candidates[i], candidates[j]], depth + 1)); + } + } + + return null; + } + + if (EnqueueRepairs(resultNode, 0) is JsonNode composedResult) + { + return FinalizeRepair(composedResult); + } + for (int evaluations = 0; evaluations < maxCandidateSetEvaluations && (pending.Count > 0 || fallback.Count > 0);) + { + RepairAttempt attempt = pending.Count > 0 ? pending.Dequeue() : fallback.Dequeue(); + traversalBudget.SpendCopy(attempt.Result); + JsonNode candidateResult = attempt.Result.DeepClone(); + foreach (RepairCandidate candidate in attempt.Candidates) + { + ApplyRepairCandidate(candidateResult, candidate, traversalBudget); + } + + traversalBudget.SpendCopy(candidateResult); + if (!visited.Add(candidateResult.ToJsonString())) + { + continue; + } + + evaluations++; + if (IsRepairValid(candidateResult)) + { + return FinalizeRepair(candidateResult); + } + + if (EnqueueRepairs(candidateResult, attempt.Depth) is JsonNode repairedResult) + { + return FinalizeRepair(repairedResult); + } + } + + // A valid composition can still need another branch's evaluated-property annotations. + // Prefer ordinary recovery before considering changes to already-valid choices. + return !includeValidAlternatives && skippedValidAlternatives && + s_schemaEvaluationCosts.GetValue(schema, static compiled => new(compiled.Root)).HasUnevaluated + ? RepairStructuredResult( + resultNode, schemaNode, schema, originalResult, serializerOptions, remainingDepth, + schemaDocument, references, dynamicScope, traversalBudget, inheritedConditions, + includeValidAlternatives: true) + : null; + } + + private static bool TrySetSchemaRegexTimeouts(JsonNode source, JsonSchemaNode root) + { + SchemaReferenceResolver references = new(source, root.BaseUri); + HashSet visited = [root]; + Stack pending = new(); + pending.Push(root); + List nodes = []; + while (pending.Count > 0) + { + JsonSchemaNode node = pending.Pop(); + nodes.Add(node); + foreach (KeywordData keyword in node.Keywords) + { + // Do not borrow mutable nodes or unbounded regexes from a global registry. + if (keyword.Handler.Name is "$ref" or "$dynamicRef" or "$recursiveRef" && + !references.CanResolveReference(node.BaseUri, keyword.RawValue.GetString()!)) + { + return false; + } + foreach (JsonSchemaNode child in keyword.Subschemas) + { + if (visited.Add(child)) + { + pending.Push(child); + } + } + } + } + + foreach (JsonSchemaNode node in nodes) + { + foreach (KeywordData keyword in node.Keywords) + { + if (keyword.Handler.Name == "pattern" && keyword.Value is Regex pattern) + { + keyword.Value = new Regex(pattern.ToString(), pattern.Options, s_recoveryRegexTimeout); + } + else if (keyword.Handler.Name == "patternProperties" && keyword.Value is Dictionary patterns) + { + Dictionary bounded = new(patterns.Count, patterns.Comparer); + foreach (KeyValuePair entry in patterns) + { + bounded.Add(entry.Key, new Regex(entry.Value.ToString(), entry.Value.Options, s_recoveryRegexTimeout)); + } + keyword.Value = bounded; + } + } + } + return true; + } + + private static bool TryBuildJsonSchema(JsonNode schemaNode, out JsonSchema? schema) + { + try + { + JsonElement schemaElement = JsonSerializer.SerializeToElement(schemaNode, McpJsonUtilities.JsonContext.Default.JsonNode); + schema = JsonSchema.Build(schemaElement, new BuildOptions { SchemaRegistry = new SchemaRegistry() }); + if (!TrySetSchemaRegexTimeouts(schemaNode, schema.Root)) + { + schema = null; + return false; + } + return true; + } + catch (Exception ex) when (ex is JsonException or JsonSchemaException or RefResolutionException or InvalidOperationException or ArgumentException) + { + schema = null; + return false; + } + } + + private OutputSchemaCache GetOutputSchema(JsonElement outputSchema) + { + OutputSchemaCache? cachedSchema = Volatile.Read(ref _outputSchema); + if (cachedSchema is not null && cachedSchema.Source.Equals(outputSchema)) + { + return cachedSchema; + } + + OutputSchemaCache? alternateSchema = Volatile.Read(ref _alternateOutputSchema); + if (alternateSchema is not null && alternateSchema.Source.Equals(outputSchema)) + { + return alternateSchema; + } + + JsonNode? schemaNode = JsonSerializer.SerializeToNode(outputSchema, McpJsonUtilities.JsonContext.Default.JsonElement); + JsonSchema? schema = null; + if (schemaNode is not null) + { + TryBuildJsonSchema(schemaNode, out schema); + } + + Volatile.Write(ref _alternateOutputSchema, cachedSchema); + cachedSchema = new(outputSchema, schemaNode, schema); + Volatile.Write(ref _outputSchema, cachedSchema); + return cachedSchema; + } + + private sealed record OutputSchemaCache(JsonElement Source, JsonNode? Node, JsonSchema? Schema); + + private static bool IsSchemaValid(JsonSchema schema, JsonNode? instance, RepairTraversalBudget budget) + { + try + { + JsonElement instanceElement = JsonSerializer.SerializeToElement(instance, McpJsonUtilities.JsonContext.Default.JsonNode); + return IsSchemaValid(schema, instanceElement, budget); + } + catch (Exception ex) when (ex is JsonException or JsonSchemaException or RefResolutionException or InvalidOperationException or ArgumentException) + { + return false; + } + } + + private static bool IsSchemaValid(JsonSchema schema, JsonElement instance, RepairTraversalBudget budget) + { + try + { + budget.SpendEvaluation(schema, instance); + return schema.Evaluate(instance, new EvaluationOptions { OutputFormat = OutputFormat.Flag }).IsValid; + } + catch (Exception ex) when (ex is JsonException or JsonSchemaException or RefResolutionException or InvalidOperationException or ArgumentException) + { + return false; + } + } + + private static List CreateRepairCandidates( + JsonNode resultNode, + SourceRecoveryContext? originalResult, + JsonSerializerOptions serializerOptions, + Dictionary contexts, + RepairTraversalBudget budget) + { + List candidates = []; + HashSet seen = new(StringComparer.Ordinal); + + foreach (KeyValuePair contextEntry in contexts) + { + if (!TryGetNodeAtPath(resultNode, contextEntry.Key, out JsonNode? parentNode) || parentNode is not JsonObject parentObject) + { + continue; + } + + foreach (string propertyName in contextEntry.Value.Required) + { + budget.SpendText(contextEntry.Key); + budget.SpendText(propertyName); + if (parentObject.ContainsKey(propertyName)) + { + continue; + } + + string candidateKey = AppendJsonPointer(contextEntry.Key, propertyName); + if (!seen.Add(candidateKey)) + { + continue; + } + + if (TryGetSourceValue( + originalResult, + resultNode, + contextEntry.Key, + propertyName, + serializerOptions, + out JsonNode? sourceValue)) + { + candidates.Add(new RepairCandidate(contextEntry.Key, propertyName, sourceValue)); + } + } + } + + return candidates; + } + + private static void ApplyRepairCandidate(JsonNode resultNode, RepairCandidate candidate, RepairTraversalBudget budget) + { + if (TryGetNodeAtPath(resultNode, candidate.ParentPath, out JsonNode? parentNode) && parentNode is JsonObject parentObject) + { + budget.SpendCopy(candidate.Value); + parentObject[candidate.PropertyName] = candidate.Value?.DeepClone(); + } + } + + private sealed class SchemaObjectContext + { + internal HashSet Required { get; } = new(StringComparer.Ordinal); + } + + private sealed record RepairCandidate(string ParentPath, string PropertyName, JsonNode? Value) + { + internal string Key => AppendJsonPointer(ParentPath, PropertyName); + } + + private sealed record RepairAttempt(JsonNode Result, List Candidates, int Depth); + + private sealed record RepairExclusiveChoice( + JsonNode Applicator, JsonNode Branch, string InstancePath, List DynamicScope); + + private sealed record RepairCondition( + JsonNode Schema, string InstancePath, List DynamicScope, bool Expected); + + private static JsonNode ProjectConditionInstance(JsonNode instance, JsonNode schema, RepairTraversalBudget budget) + { + if (instance is not JsonObject instanceObject || schema is not JsonObject schemaObject || + schemaObject.Any(static property => property.Key is not ("properties" or "required" or "type" or "minProperties" or "maxProperties"))) + { + return instance; + } + + // These keywords cannot observe unrelated properties. In particular, do not + // copy a large sibling collection for every small descendant repair. + JsonObject projection = new(); + if (schemaObject["properties"] is JsonObject properties) + { + foreach (var property in properties) + { + if (instanceObject.TryGetPropertyValue(property.Key, out JsonNode? value)) + { + budget.SpendCopy(value); + projection[property.Key] = value?.DeepClone(); + } + } + } + + if (schemaObject["required"] is JsonArray required) + { + foreach (JsonNode? item in required) + { + if (item is JsonValue name && name.TryGetValue(out string? propertyName) && + propertyName is not null && !projection.ContainsKey(propertyName) && + instanceObject.ContainsKey(propertyName)) + { + projection[propertyName] = null; + } + } + } + + if (schemaObject.ContainsKey("minProperties") || schemaObject.ContainsKey("maxProperties")) + { + foreach (KeyValuePair property in instanceObject) + { + budget.SpendText(property.Key); + if (!projection.ContainsKey(property.Key)) + { + projection[property.Key] = null; + } + } + } + + return projection; + } + + private static bool IsConditionUnaffected( + RepairCondition condition, RepairMutationPaths mutations, RepairTraversalBudget budget) + { + if (condition.Schema is not JsonObject schema || + schema.Any(static property => property.Key is not ("properties" or "required" or "type" or "minProperties" or "maxProperties"))) + { + return false; + } + + for (string path = condition.InstancePath; path.Length > 0; path = path.Substring(0, path.LastIndexOf('/'))) + { + budget.SpendText(path); + if (mutations.Additions.Contains(path)) + { + return false; + } + } + + if (mutations.DirectAdditions.TryGetValue(condition.InstancePath, out HashSet? added)) + { + if (schema.ContainsKey("minProperties") || schema.ContainsKey("maxProperties")) + { + return false; + } + if (schema["required"] is JsonArray required) + { + foreach (JsonNode? item in required) + { + budget.Spend(1); + if (item?.GetValue() is string name && added.Contains(name)) + { + return false; + } + } + } + } + + if (schema["properties"] is JsonObject properties) + { + foreach (KeyValuePair property in properties) + { + budget.SpendText(property.Key); + if (mutations.AffectedAncestors.Contains(AppendJsonPointer(condition.InstancePath, property.Key))) + { + return false; + } + } + } + + return true; + } + + private sealed class RepairMutationPaths + { + internal HashSet Additions { get; } = new(StringComparer.Ordinal); + internal HashSet AffectedAncestors { get; } = new(StringComparer.Ordinal); + internal Dictionary> DirectAdditions { get; } = new(StringComparer.Ordinal); + + internal RepairMutationPaths(List candidates, RepairTraversalBudget budget) + { + foreach (RepairCandidate candidate in candidates) + { + budget.SpendText(candidate.ParentPath); + budget.SpendText(candidate.PropertyName); + string path = candidate.Key; + Additions.Add(path); + if (!DirectAdditions.TryGetValue(candidate.ParentPath, out HashSet? names)) + { + names = new(StringComparer.Ordinal); + DirectAdditions.Add(candidate.ParentPath, names); + } + names.Add(candidate.PropertyName); + for (; path.Length > 0; path = path.Substring(0, path.LastIndexOf('/'))) + { + budget.SpendText(path); + AffectedAncestors.Add(path); + } + } + } + } + + private sealed record RepairCandidateGroup( + HashSet Keys, HashSet DirectKeys, string InstancePath, JsonNode Schema, + List DynamicScope, List Choices, List Conditions, + RepairUnevaluatedApplication? Unevaluated = null, bool EnclosesInstance = false, + List? Alternatives = null); + + private sealed record RepairUnevaluatedApplication(JsonNode Schema, string InstancePath); + + private static void ReplaceRepairBranch(JsonNode currentBranch, string instancePath, JsonNode replacement) + { + string segment = DecodeJsonPointerSegment(instancePath.Substring(instancePath.LastIndexOf('/') + 1)); + if (currentBranch.Parent is JsonArray parentArray) + { + parentArray[int.Parse(segment, NumberStyles.None, CultureInfo.InvariantCulture)] = replacement; + } + else if (currentBranch.Parent is JsonObject parentObject) + { + parentObject[segment] = replacement; + } + } + + private static string DecodeJsonPointerSegment(string segment) => segment.Replace("~1", "/").Replace("~0", "~"); + + private sealed class RepairTraversalBudget + { + internal bool IsRecoveryStopped { get; set; } + + internal const int MaxEvaluationWork = 65_536; + // Shared by document attempts and recursive branch repairs, including cached merges. + // Costs include copied entries, JSON nodes, and 16-character text units. + private int _remainingWork = 2_000_000; + private int _remainingCopyWork = 1_000_000; + // Compiled branch schemas retain copies of the complete source document. + private int _remainingSchemaWork = 65_536; + private int _remainingEvaluationWork = MaxEvaluationWork; + private readonly Dictionary _immutableCosts = new(); + internal Dictionary<(JsonNode Schema, string Scope), JsonSchema?> BranchSchemas { get; } = new(); + + internal void Spend(int work) + { + if (work < 0 || work > _remainingWork) + { + throw new RecoveryBudgetExceededException(); + } + + _remainingWork -= work; + } + + internal void SpendText(string text) => Spend(1 + text.Length / 16); + + internal void SpendEvaluation(JsonSchema schema, JsonElement instance, bool detailed = false) + { + SchemaEvaluationCost evaluation = s_schemaEvaluationCosts.GetValue(schema, static compiled => new(compiled.Root)); + if (!detailed && !evaluation.HasUnevaluated) + { + return; + } + + int instanceCost = 0; + Measure(instance, ref instanceCost, 0); + int cost = evaluation.GetCost(instance, instanceCost, _remainingEvaluationWork); + if (cost > _remainingEvaluationWork) + { + throw new RecoveryBudgetExceededException(); + } + _remainingEvaluationWork -= cost; + } + + internal void SpendContexts(Dictionary contexts) + { + Spend(contexts.Count); + foreach (KeyValuePair entry in contexts) + { + Spend(entry.Value.Required.Count); + } + } + + internal void SpendSchema(JsonNode schema) + { + int cost = GetCopyCost(schema, immutable: true); + if (cost > _remainingSchemaWork) + { + throw new RecoveryBudgetExceededException(); + } + + SpendCopyCost(cost); + _remainingSchemaWork -= cost; + } + + internal void SpendCopy(JsonNode? node, bool immutable = false) => SpendCopyCost(GetCopyCost(node, immutable)); + + internal void SpendCopy(JsonElement element) + { + int cost = 0; + Measure(element, ref cost, 0); + SpendCopyCost(cost); + } + + private void SpendCopyCost(int cost) + { + if (cost > _remainingCopyWork) + { + throw new RecoveryBudgetExceededException(); + } + _remainingCopyWork -= cost; + } + + private int GetCopyCost(JsonNode? node, bool immutable) + { + if (immutable && node is not null && _immutableCosts.TryGetValue(node, out int cached)) + { + return cached; + } + + int cost = 0; + Measure(node, ref cost, 0); + if (immutable && node is not null) + { + _immutableCosts[node] = cost; + } + return cost; + } + + private void AddCost(ref int cost, int work, int depth) + { + if (depth >= 256 || work > _remainingCopyWork - cost) + { + throw new RecoveryBudgetExceededException(); + } + cost += work; + } + + private void Measure(JsonNode? node, ref int cost, int depth) + { + AddCost(ref cost, 1, depth); + if (node is JsonObject obj) + { + foreach (KeyValuePair property in obj) + { + AddCost(ref cost, 1 + property.Key.Length / 16, depth); + Measure(property.Value, ref cost, depth + 1); + } + } + else if (node is JsonArray array) + { + foreach (JsonNode? item in array) + { + Measure(item, ref cost, depth + 1); + } + } + else if (node is JsonValue value && value.TryGetValue(out string? text) && text is not null) + { + AddCost(ref cost, text.Length / 16, depth); + } + else if (node is JsonValue number && number.GetValueKind() == JsonValueKind.Number) + { + AddCost(ref cost, number.ToJsonString().Length / 16, depth); + } + } + + private void Measure(JsonElement element, ref int cost, int depth) + { + AddCost(ref cost, 1, depth); + switch (element.ValueKind) + { + case JsonValueKind.Object: + foreach (JsonProperty property in element.EnumerateObject()) + { + AddCost(ref cost, 1 + property.Name.Length / 16, depth); + Measure(property.Value, ref cost, depth + 1); + } + break; + case JsonValueKind.Array: + foreach (JsonElement item in element.EnumerateArray()) + { + Measure(item, ref cost, depth + 1); + } + break; + case JsonValueKind.String: + AddCost(ref cost, element.GetString()!.Length / 16, depth); + break; + case JsonValueKind.Number: + AddCost(ref cost, element.GetRawText().Length / 16, depth); + break; + } + } + } + + private sealed class RecoveryBudgetExceededException : Exception; + + private sealed class SchemaEvaluationCost + { + internal bool HasUnevaluated { get; } + private readonly JsonSchemaNode _root; + private readonly int _expandedCost; + private readonly Dictionary<(Uri Resource, string Anchor), JsonSchemaNode> _dynamicTargets = new(); + private bool _needsInstanceCost; + + internal SchemaEvaluationCost(JsonSchemaNode root) + { + _root = root; + HashSet visited = [root]; + Stack pending = new(); + pending.Push(root); + while (pending.Count > 0) + { + JsonSchemaNode node = pending.Pop(); + foreach (KeywordData keyword in node.Keywords) + { + HasUnevaluated |= keyword.Handler.Name is "unevaluatedProperties" or "unevaluatedItems"; + if (keyword.Handler.Name == "$dynamicAnchor") + { + _dynamicTargets[(node.BaseUri, keyword.RawValue.GetString()!)] = node; + } + foreach (JsonSchemaNode child in keyword.Subschemas) + { + if (visited.Add(child)) + { + pending.Push(child); + } + } + } + } + + _expandedCost = Measure(root, new(), new(), 0); + } + + internal int GetCost(JsonElement instance, int instanceCost, int limit) + { + if (!_needsInstanceCost) + { + return _expandedCost > limit / instanceCost ? limit + 1 : _expandedCost * instanceCost; + } + + int remaining = limit; + int cost = MeasureInstance(_root, instance, string.Empty, false, [], new(), new(), 0, ref remaining); + return Math.Max(cost, limit - remaining); + } + + private int Measure( + JsonSchemaNode node, Dictionary completed, HashSet active, int depth) + { + const int limit = RepairTraversalBudget.MaxEvaluationWork + 1; + if (completed.TryGetValue(node, out int cached)) + { + return cached; + } + if (depth >= 128 || !active.Add(node)) + { + _needsInstanceCost = true; + return limit; + } + + int cost = 1; + foreach (KeywordData keyword in node.Keywords) + { + if (keyword.Handler.Name is "$defs" or "definitions") + { + continue; + } + + cost++; + if (keyword.Handler.Name is "$dynamicRef" or "$recursiveRef" && keyword.Subschemas.Length == 0) + { + _needsInstanceCost = true; + cost = limit; + } + foreach (JsonSchemaNode child in keyword.Subschemas) + { + cost = Math.Min(limit, cost + Measure(child, completed, active, depth + 1)); + if (cost >= limit) + { + break; + } + } + if (cost >= limit) + { + break; + } + } + + active.Remove(node); + completed[node] = cost; + return cost; + } + + private int MeasureInstance( + JsonSchemaNode node, JsonElement instance, string path, bool propertyName, List scope, + Dictionary<(JsonSchemaNode, string, string, bool), int> completed, + HashSet<(JsonSchemaNode, string, string, bool)> active, int depth, ref int remaining) + { + const int limit = RepairTraversalBudget.MaxEvaluationWork + 1; + if (--remaining < 0 || depth >= 128) + { + return limit; + } + if (!scope.Contains(node.BaseUri)) + { + scope = [.. scope, node.BaseUri]; + } + foreach (Uri resource in scope) + { + remaining -= 1 + resource.AbsoluteUri.Length / 16; + if (remaining < 0) + { + return limit; + } + } + var state = (node, path, string.Join("\n", scope), propertyName); + if (completed.TryGetValue(state, out int cached)) + { + return cached; + } + if (!active.Add(state)) + { + return limit; + } + + int cost = 1; + foreach (KeywordData keyword in node.Keywords) + { + string name = keyword.Handler.Name; + if (name is "$defs" or "definitions") + { + continue; + } + cost++; + if (name == "$recursiveRef" && keyword.Subschemas.Length == 0) + { + // Legacy recursive scope can stop at an intervening non-recursive resource. + return limit; + } + if (name == "$dynamicRef" && keyword.Subschemas.Length == 0) + { + JsonSchemaNode? target = null; + if (Uri.TryCreate(node.BaseUri, keyword.RawValue.GetString(), out Uri? reference)) + { + string anchor = Uri.UnescapeDataString(reference.Fragment.TrimStart('#')); + foreach (Uri resource in scope) + { + if (_dynamicTargets.TryGetValue((resource, anchor), out target)) + { + break; + } + } + } + cost = target is null ? limit : + Math.Min(limit, cost + MeasureInstance(target, instance, path, propertyName, scope, completed, active, depth + 1, ref remaining)); + } + foreach (JsonSchemaNode child in keyword.Subschemas) + { + if (name == "properties") + { + string property = child.RelativePath[child.RelativePath.SegmentCount - 1].ToString(); + if (instance.ValueKind == JsonValueKind.Object && instance.TryGetProperty(property, out JsonElement value)) + { + cost += MeasureInstance(child, value, AppendPath(path, property, ref remaining), false, + scope, completed, active, depth + 1, ref remaining); + } + } + else if (name is "patternProperties" or "additionalProperties" or "unevaluatedProperties" or "propertyNames") + { + if (instance.ValueKind == JsonValueKind.Object) + { + foreach (JsonProperty property in instance.EnumerateObject()) + { + // Property-name schemas cannot observe the property's value. + JsonElement value = name == "propertyNames" ? s_propertyNameInstance : property.Value; + cost += MeasureInstance(child, value, AppendPath(path, property.Name, ref remaining), name == "propertyNames", + scope, completed, active, depth + 1, ref remaining); + if (cost >= limit) + { + break; + } + } + } + } + else if (name is "items" or "prefixItems" or "contains" or "unevaluatedItems" or "additionalItems") + { + if (instance.ValueKind == JsonValueKind.Array) + { + int index = 0; + foreach (JsonElement value in instance.EnumerateArray()) + { + cost += MeasureInstance(child, value, AppendPath(path, (index++).ToString(CultureInfo.InvariantCulture), ref remaining), false, + scope, completed, active, depth + 1, ref remaining); + if (cost >= limit) + { + break; + } + } + } + } + else + { + cost += MeasureInstance(child, instance, path, propertyName, scope, completed, active, depth + 1, ref remaining); + } + if (cost >= limit) + { + break; + } + } + if (cost >= limit) + { + cost = limit; + break; + } + } + + active.Remove(state); + completed[state] = cost; + return cost; + } + + private static string AppendPath(string path, string segment, ref int remaining) + { + remaining -= 1 + path.Length / 16 + segment.Length / 16; + if (remaining < 0) + { + throw new RecoveryBudgetExceededException(); + } + return AppendJsonPointer(path, segment); + } + } + + private sealed class SchemaTraversal( + RepairTraversalBudget budget, Func appliesUnevaluated, + Func, bool> isBranchValid, bool includeValidAlternatives = false) + { + internal RepairTraversalBudget Budget { get; } = budget; + internal Func AppliesUnevaluated { get; } = appliesUnevaluated; + internal Func, bool> IsBranchValid { get; } = isBranchValid; + internal bool IncludeValidAlternatives { get; } = includeValidAlternatives; + internal bool SkippedValidAlternatives { get; set; } + internal Dictionary<(JsonNode Schema, string Path, string Scope), SchemaTraversalResult> Completed { get; } = new(); + internal HashSet<(JsonNode Schema, string Path, string Scope)> Active { get; } = []; + internal int IncompleteVersion { get; set; } + } + + private sealed record SchemaTraversalResult( + Dictionary Contexts, List Groups); + + private static string GetSchemaPointer(JsonNode schema, JsonNode resource) + { + string pointer = string.Empty; + for (JsonNode node = schema; !ReferenceEquals(node, resource) && node.Parent is JsonNode parent; node = parent) + { + string segment = parent is JsonArray array + ? array.IndexOf(node).ToString(CultureInfo.InvariantCulture) + : ((JsonObject)parent).First(property => ReferenceEquals(property.Value, node)).Key; + pointer = AppendJsonPointer(string.Empty, segment) + pointer; + } + + return pointer; + } + + private static void CollectSchemaContexts( + JsonNode? instanceNode, + JsonNode? schemaNode, + JsonNode schemaRoot, + string instancePath, + Dictionary contexts, + SchemaTraversal traversal, + List groups, + SchemaReferenceResolver references, + List? dynamicScope = null) + { + if (schemaNode is JsonValue schemaValue && schemaValue.TryGetValue(out _)) + { + return; + } + + if (schemaNode is not JsonObject schemaObject) + { + return; + } + + dynamicScope ??= [schemaRoot]; + if (!ReferenceEquals(schemaRoot, schemaObject) && schemaObject.ContainsKey("$id")) + { + schemaRoot = schemaObject; + dynamicScope = SchemaReferenceResolver.EnterResource(dynamicScope, schemaRoot); + } + + var state = (schemaNode, instancePath, references.GetScopeKey(dynamicScope)); + traversal.Budget.Spend(1); + + if (traversal.Completed.TryGetValue(state, out SchemaTraversalResult? completed)) + { + traversal.Budget.SpendContexts(completed.Contexts); + traversal.Budget.Spend(completed.Groups.Count); + MergeSchemaContexts(contexts, completed.Contexts); + groups.AddRange(completed.Groups); + + return; + } + + if (traversal.Active.Count >= 256 || !traversal.Active.Add(state)) + { + traversal.IncompleteVersion++; + return; + } + + int incompleteVersion = traversal.IncompleteVersion; + Dictionary destinationContexts = contexts; + List destinationGroups = groups; + contexts = new(StringComparer.Ordinal); + groups = []; + + foreach (string referenceKeyword in new[] { "$ref", "$dynamicRef" }) + { + if (schemaObject[referenceKeyword] is JsonValue refValue && + refValue.TryGetValue(out string? refText) && + refText is not null) + { + if (references.TryResolve(schemaRoot, refText, out JsonNode? referencedSchema, out JsonNode referencedResource)) + { + if (referenceKeyword == "$dynamicRef" && referencedSchema is JsonObject referencedObject && + referencedObject["$dynamicAnchor"]?.GetValue() is string anchor && + refText.IndexOf('#') is int fragmentStart && fragmentStart >= 0 && + string.Equals(Uri.UnescapeDataString(refText[(fragmentStart + 1)..]), anchor, StringComparison.Ordinal)) + { + foreach (JsonNode resource in dynamicScope) + { + if (TryFindSchemaAnchor(resource, anchor, out JsonNode? dynamicSchema, dynamicOnly: true)) + { + referencedSchema = dynamicSchema; + referencedResource = resource; + break; + } + } + } + + CollectSchemaContexts(instanceNode, referencedSchema, referencedResource, instancePath, contexts, traversal, groups, references, + SchemaReferenceResolver.EnterResource(dynamicScope, referencedResource)); + } + } + } + + SchemaObjectContext context = GetOrCreateSchemaContext(contexts, instancePath); + if (schemaObject["required"] is JsonArray required) + { + foreach (JsonNode? requiredProperty in required) + { + if (requiredProperty?.GetValue() is string propertyName) + { + traversal.Budget.SpendText(propertyName); + context.Required.Add(propertyName); + } + } + } + + if (schemaObject["properties"] is JsonObject properties) + { + foreach (KeyValuePair property in properties) + { + if (property.Value is null) + { + continue; + } + + if (instanceNode is not JsonObject instanceObject || + !instanceObject.TryGetPropertyValue(property.Key, out JsonNode? propertyInstance) || + propertyInstance is null) + { + continue; + } + + CollectSchemaBranch( + propertyInstance, + property.Value, + schemaRoot, + AppendJsonPointer(instancePath, property.Key), + contexts, + traversal, + groups, references, dynamicScope, enclosesInstance: true); + } + } + + if (instanceNode is JsonObject objectInstance) + { + JsonObject? patternProperties = schemaObject["patternProperties"] as JsonObject; + JsonNode? additionalProperties = schemaObject["additionalProperties"]; + foreach (KeyValuePair property in objectInstance) + { + bool isExplicitProperty = schemaObject["properties"] is JsonObject explicitProperties && + explicitProperties.ContainsKey(property.Key); + + bool matchedPattern = false; + if (patternProperties is not null) + { + foreach (KeyValuePair patternProperty in patternProperties) + { + traversal.Budget.SpendText(patternProperty.Key); + traversal.Budget.SpendText(property.Key); + if (patternProperty.Value is not null && + Regex.IsMatch(property.Key, patternProperty.Key, RegexOptions.None, s_recoveryRegexTimeout)) + { + matchedPattern = true; + CollectSchemaBranch( + property.Value, + patternProperty.Value, + schemaRoot, + AppendJsonPointer(instancePath, property.Key), + contexts, + traversal, + groups, references, dynamicScope, enclosesInstance: true); + } + } + } + + if (!isExplicitProperty && !matchedPattern && additionalProperties is not null) + { + CollectSchemaBranch( + property.Value, + additionalProperties, + schemaRoot, + AppendJsonPointer(instancePath, property.Key), + contexts, + traversal, + groups, references, dynamicScope, enclosesInstance: true); + } + + if (schemaObject["unevaluatedProperties"] is JsonNode unevaluatedPropertySchema && + traversal.AppliesUnevaluated(unevaluatedPropertySchema, AppendJsonPointer(instancePath, property.Key))) + { + CollectSchemaBranch( + property.Value, unevaluatedPropertySchema, schemaRoot, + AppendJsonPointer(instancePath, property.Key), contexts, traversal, groups, references, dynamicScope, + unevaluated: new(unevaluatedPropertySchema, AppendJsonPointer(instancePath, property.Key))); + } + } + } + + if (instanceNode is JsonArray arrayInstance) + { + JsonArray? prefixItems = schemaObject["prefixItems"] as JsonArray; + JsonNode? items = schemaObject["items"]; + double minimumMatches = schemaObject["minContains"] is JsonValue minimum && + minimum.TryGetValue(out double count) ? count : 1; + bool repairContains = schemaObject["contains"] is JsonNode contains && + arrayInstance.Count(item => traversal.IsBranchValid(contains, item, dynamicScope)) < minimumMatches; + for (int i = 0; i < arrayInstance.Count; i++) + { + JsonNode? itemSchema = prefixItems is not null && i < prefixItems.Count ? prefixItems[i] : items; + if (itemSchema is not null) + { + CollectSchemaBranch( + arrayInstance[i], + itemSchema, + schemaRoot, + AppendJsonPointer(instancePath, i.ToString(CultureInfo.InvariantCulture)), + contexts, + traversal, + groups, references, dynamicScope, enclosesInstance: true); + } + + foreach (string keyword in new[] { "contains", "unevaluatedItems" }) + { + if (schemaObject[keyword] is JsonNode subschema && + (keyword != "contains" || repairContains) && + (keyword != "unevaluatedItems" || + traversal.AppliesUnevaluated(subschema, AppendJsonPointer(instancePath, i.ToString(CultureInfo.InvariantCulture))))) + { + CollectSchemaBranch( + arrayInstance[i], subschema, schemaRoot, + AppendJsonPointer(instancePath, i.ToString(CultureInfo.InvariantCulture)), + contexts, traversal, groups, references, dynamicScope, + unevaluated: keyword == "unevaluatedItems" + ? new(subschema, AppendJsonPointer(instancePath, i.ToString(CultureInfo.InvariantCulture))) : null); + } + } + } + } + + if (instanceNode is JsonObject dependencyInstance && + schemaObject["dependentRequired"] is JsonObject dependentRequired) + { + foreach (KeyValuePair dependency in dependentRequired) + { + if (dependencyInstance.ContainsKey(dependency.Key) && dependency.Value is JsonArray requiredProperties) + { + foreach (JsonNode? requiredProperty in requiredProperties) + { + if (requiredProperty?.GetValue() is string propertyName) + { + traversal.Budget.SpendText(propertyName); + context.Required.Add(propertyName); + } + } + } + } + } + + if (instanceNode is JsonObject dependencyInstanceForSchemas && + schemaObject["dependentSchemas"] is JsonObject dependentSchemas) + { + foreach (KeyValuePair dependentSchema in dependentSchemas) + { + if (dependentSchema.Value is not null && dependencyInstanceForSchemas.ContainsKey(dependentSchema.Key)) + { + CollectSchemaContexts( + instanceNode, + dependentSchema.Value, + schemaRoot, + instancePath, + contexts, + traversal, + groups, references, dynamicScope); + } + } + } + + foreach (string compositionKeyword in new[] { "allOf", "anyOf", "oneOf" }) + { + if (schemaObject[compositionKeyword] is not JsonArray composition) + { + continue; + } + + if (compositionKeyword != "allOf") + { + int validBranches = composition.Count(branch => branch is not null && + traversal.IsBranchValid(branch, instanceNode, dynamicScope)); + if ((compositionKeyword == "anyOf" && validBranches > 0) || + (compositionKeyword == "oneOf" && validBranches == 1)) + { + if (!traversal.IncludeValidAlternatives) + { + traversal.SkippedValidAlternatives = true; + continue; + } + } + } + + foreach (JsonNode? branch in composition) + { + if (branch is not null) + { + if (compositionKeyword == "allOf") + { + CollectSchemaContexts( + instanceNode, branch, schemaRoot, instancePath, contexts, + traversal, groups, references, dynamicScope); + } + else + { + CollectSchemaBranch(instanceNode, branch, schemaRoot, instancePath, contexts, traversal, groups, references, dynamicScope, + compositionKeyword == "oneOf" ? composition : null, + alternativeApplicator: compositionKeyword == "anyOf" ? composition : null); + } + } + } + } + + if (schemaObject["if"] is JsonNode ifSchema) + { + if (schemaObject["then"] is JsonNode thenSchema) + { + CollectSchemaBranch(instanceNode, thenSchema, schemaRoot, instancePath, contexts, traversal, groups, references, dynamicScope, + condition: new(ifSchema, instancePath, + SchemaReferenceResolver.EnterResource(dynamicScope, references.GetResourceRoot(ifSchema)), true)); + } + + if (schemaObject["else"] is JsonNode elseSchema) + { + CollectSchemaBranch(instanceNode, elseSchema, schemaRoot, instancePath, contexts, traversal, groups, references, dynamicScope, + condition: new(ifSchema, instancePath, + SchemaReferenceResolver.EnterResource(dynamicScope, references.GetResourceRoot(ifSchema)), false)); + } + } + + traversal.Budget.Spend(groups.Count); + groups = groups.Distinct().ToList(); + traversal.Active.Remove(state); + // A cycle or depth cutoff produces a partial summary that cannot be reused + // when this schema is reached later through a different caller. + if (incompleteVersion == traversal.IncompleteVersion) + { + traversal.Completed[state] = new(contexts, groups); + } + traversal.Budget.SpendContexts(contexts); + traversal.Budget.Spend(groups.Count); + MergeSchemaContexts(destinationContexts, contexts); + destinationGroups.AddRange(groups); + } + + private static void MergeSchemaContexts( + Dictionary destination, + Dictionary source) + { + foreach (KeyValuePair entry in source) + { + GetOrCreateSchemaContext(destination, entry.Key).Required.UnionWith(entry.Value.Required); + } + } + + private static void CollectSchemaBranch( + JsonNode? instanceNode, + JsonNode branch, + JsonNode schemaRoot, + string instancePath, + Dictionary contexts, + SchemaTraversal traversal, + List groups, + SchemaReferenceResolver references, + List dynamicScope, + JsonNode? exclusiveApplicator = null, + RepairCondition? condition = null, + RepairUnevaluatedApplication? unevaluated = null, + bool enclosesInstance = false, + JsonNode? alternativeApplicator = null) + { + Dictionary branchContexts = new(StringComparer.Ordinal); + List branchGroups = []; + CollectSchemaContexts( + instanceNode, branch, schemaRoot, instancePath, branchContexts, + traversal, branchGroups, references, dynamicScope); + traversal.Budget.SpendContexts(branchContexts); + List branchScope = SchemaReferenceResolver.EnterResource(dynamicScope, references.GetResourceRoot(branch)); + List choices = exclusiveApplicator is null + ? [] : [new(exclusiveApplicator, branch, instancePath, branchScope)]; + List conditions = condition is null ? [] : [condition]; + List alternatives = alternativeApplicator is null + ? [] : [new(alternativeApplicator, branch, instancePath, branchScope)]; + HashSet group = new(StringComparer.Ordinal); + foreach (KeyValuePair entry in branchContexts) + { + GetOrCreateSchemaContext(contexts, entry.Key).Required.UnionWith(entry.Value.Required); + foreach (string property in entry.Value.Required) + { + traversal.Budget.SpendText(entry.Key); + traversal.Budget.SpendText(property); + group.Add(AppendJsonPointer(entry.Key, property)); + } + } + + if (group.Count > 0) + { + traversal.Budget.Spend(group.Count); + foreach (RepairCandidateGroup nested in branchGroups) + { + traversal.Budget.Spend(nested.Keys.Count); + } + HashSet directKeys = new(group, StringComparer.Ordinal); + directKeys.ExceptWith(branchGroups.SelectMany(static nested => nested.Keys)); + // Restore this branch's own discriminator before composing nested selectors. + // Nested alternatives must not be added en masse before re-evaluating them. + groups.Add(new(group, directKeys, instancePath, branch, branchScope, choices, conditions, unevaluated, enclosesInstance, alternatives)); + } + + foreach (RepairCandidateGroup branchGroup in branchGroups) + { + traversal.Budget.Spend(1 + choices.Count + branchGroup.Choices.Count + + conditions.Count + branchGroup.Conditions.Count + + alternatives.Count + (branchGroup.Alternatives?.Count ?? 0)); + groups.Add(branchGroup with + { + Choices = [.. choices, .. branchGroup.Choices], + Conditions = [.. conditions, .. branchGroup.Conditions], + Unevaluated = unevaluated ?? branchGroup.Unevaluated, + Alternatives = [.. alternatives, .. branchGroup.Alternatives ?? []], + }); + } + } + + private static SchemaObjectContext GetOrCreateSchemaContext(Dictionary contexts, string instancePath) + { + if (!contexts.TryGetValue(instancePath, out SchemaObjectContext? context)) + { + contexts[instancePath] = context = new SchemaObjectContext(); + } + + return context; + } + + private static string AppendJsonPointer(string parentPath, string segment) + { + string escapedSegment = segment.Replace("~", "~0").Replace("/", "~1"); + return parentPath.Length == 0 ? "/" + escapedSegment : parentPath + "/" + escapedSegment; + } + + private static bool TryGetNodeAtPath(JsonNode? root, string path, out JsonNode? value) + { + value = root; + if (path.Length == 0) + { + return root is not null; + } + + if (!path.StartsWith("/", StringComparison.Ordinal)) + { + value = null; + return false; + } + + foreach (string rawSegment in path.Substring(1).Split('/')) + { + string segment = DecodeJsonPointerSegment(rawSegment); + if (value is JsonObject objectValue && objectValue.TryGetPropertyValue(segment, out JsonNode? objectChild)) + { + value = objectChild; + } + else if (value is JsonArray arrayValue && int.TryParse(segment, NumberStyles.None, CultureInfo.InvariantCulture, out int index) && + index >= 0 && index < arrayValue.Count) + { + value = arrayValue[index]; + } + else + { + value = null; + return false; + } + } + + return true; + } + + private static bool TryGetSourceValue( + SourceRecoveryContext? sourceRoot, + JsonNode serializedRoot, + string parentPath, + string propertyName, + JsonSerializerOptions serializerOptions, + out JsonNode? value) + { + value = null; + if (sourceRoot is null || + !TryGetSourceObjectAtPath(sourceRoot, serializedRoot, parentPath, out SourceRecoveryContext? parent) || + parent is null || + !TryGetNodeAtPath(serializedRoot, parentPath, out JsonNode? serializedParent) || + parent.GetChild(propertyName, serializedParent) is not { Property: { } property, Owner: { } owner } member) + { + return false; + } + + if (!WasOmittedBySupportedIgnoreCondition(owner, member.Value, property, serializerOptions)) + { + return false; + } + + return TrySerializeSourceMember(member, out value); + } + + private static bool WasOmittedBySupportedIgnoreCondition( + object sourceParent, + object? value, + JsonPropertyInfo? propertyInfo, + JsonSerializerOptions serializerOptions) + { + JsonIgnoreAttribute? ignoreAttribute = GetIgnoreAttribute(propertyInfo); + + if (propertyInfo is not null && IsExcludedReadOnlyMember(propertyInfo, serializerOptions)) + { + return false; + } + + if (value is null && serializerOptions.RespectNullableAnnotations && propertyInfo is { IsGetNullable: false }) + { + return false; + } + + if (propertyInfo?.ShouldSerialize is { } shouldSerialize && + (ignoreAttribute is null || !IsJsonIgnoreConditionPredicate(shouldSerialize)) && + !shouldSerialize(sourceParent, value)) + { + return false; + } + + JsonIgnoreCondition ignoreCondition = ignoreAttribute?.Condition ?? serializerOptions.DefaultIgnoreCondition; + // For an absent value-type member, the serializer already compared against default. + // Boxed Equals can disagree with the EqualityComparer used by the serializer. + return ignoreCondition switch + { + JsonIgnoreCondition.WhenWritingNull => value is null, + JsonIgnoreCondition.WhenWritingDefault => + propertyInfo is not null && + (value is null || (propertyInfo.PropertyType.IsValueType && + Nullable.GetUnderlyingType(propertyInfo.PropertyType) is null)), + _ => false, + }; + } + + private static JsonIgnoreAttribute? GetIgnoreAttribute(JsonPropertyInfo? propertyInfo) => + propertyInfo?.AttributeProvider is ICustomAttributeProvider attributeProvider + ? attributeProvider.GetCustomAttributes(typeof(JsonIgnoreAttribute), inherit: true) + .OfType() + .FirstOrDefault() + : null; + + private static bool IsExcludedReadOnlyMember(JsonPropertyInfo propertyInfo, JsonSerializerOptions serializerOptions) + { + if (propertyInfo.Set is not null || + (!serializerOptions.IgnoreReadOnlyProperties && !serializerOptions.IgnoreReadOnlyFields)) + { + return false; + } + + // AttributeProvider is optional and does not control serialization. Use the configured + // capability to retain readonly-member, converter, and explicitly set ShouldSerialize rules. + return !SourceMemberCapabilities.CanSerialize(propertyInfo); + } + + private static class SourceMemberCapabilities + { + internal static readonly Func CanSerialize = CreateCanSerialize(); + +#if !NETSTANDARD2_0 + [DynamicDependency("get_CanSerialize", typeof(JsonPropertyInfo))] +#endif + private static Func CreateCanSerialize() => + typeof(JsonPropertyInfo).GetProperty("CanSerialize", BindingFlags.Instance | BindingFlags.NonPublic) + ?.GetMethod?.CreateDelegate(typeof(Func)) is Func canSerialize + ? canSerialize + : static _ => false; + } + + // System.Text.Json uses this generated delegate for JsonIgnore conditions; evaluate those from metadata instead. + private static bool IsJsonIgnoreConditionPredicate(Delegate predicate) + { + Type? generatedClosureType = predicate.Method.DeclaringType; + Type? propertyInfoType = generatedClosureType?.DeclaringType; + + return predicate.Method.Name.StartsWith("b__", StringComparison.Ordinal) && + propertyInfoType?.Assembly == typeof(JsonPropertyInfo).Assembly && + string.Equals(propertyInfoType.Name, "JsonPropertyInfo`1", StringComparison.Ordinal); + } + + private static bool TryGetSourceObjectAtPath( + SourceRecoveryContext sourceRoot, + JsonNode serializedRoot, + string path, + out SourceRecoveryContext? value) + { + value = sourceRoot; + if (path.Length == 0) + { + return true; + } + + if (!path.StartsWith("/", StringComparison.Ordinal)) + { + value = null; + return false; + } + + JsonNode? serializedParent = serializedRoot; + foreach (string rawSegment in path.Substring(1).Split('/')) + { + string segment = rawSegment.Replace("~1", "/").Replace("~0", "~"); + if (value?.GetChild(segment, serializedParent) is not { } child) + { + value = null; + return false; + } + + value = child; + serializedParent = serializedParent switch + { + JsonObject jsonObject => jsonObject[segment], + JsonArray jsonArray when int.TryParse(segment, NumberStyles.None, CultureInfo.InvariantCulture, out int index) && + index >= 0 && index < jsonArray.Count => jsonArray[index], + _ => null, + }; + } + + return true; + } + + private sealed class SourceRecoveryContext( + object? value, JsonTypeInfo contract, JsonPropertyInfo? property = null, + object? owner = null, JsonTypeInfo? ownerContract = null, SourceRecoveryContext? parent = null, + Type? fallbackType = null) + { + private readonly Dictionary _children = new(StringComparer.Ordinal); + private Dictionary, JsonTypeInfo>? _scopeContracts; + private JsonTypeInfo? _activeContract; + private bool _dictionaryRead; + private bool _extensionDataRead; + private object? _extensionData; + + internal object? Value { get; } = value; + internal JsonTypeInfo Contract { get; } = contract; + internal JsonPropertyInfo? Property { get; } = property; + internal object? Owner { get; } = owner; + internal JsonTypeInfo? OwnerContract { get; } = ownerContract; + internal SourceRecoveryContext? Parent { get; } = parent; + + internal SourceRecoveryContext? GetChild(string name, JsonNode? serializedParent) + { + if (_children.TryGetValue(name, out SourceRecoveryContext? child)) + { + return child; + } + + try + { + child = GetChildCore(name, serializedParent); + } + catch (NotSupportedException exception) when (IsNonFatalRecoveryFailure(exception)) + { + // Missing metadata and opaque converter contracts cannot authorize recovery. + child = null; + } + + _children[name] = child; + return child; + } + + private SourceRecoveryContext? GetChildCore(string name, JsonNode? serializedParent) + { + if (Value is null || Property?.CustomConverter is not null) + { + return null; + } + + JsonSerializerOptions options = Contract.Options; + JsonTypeInfo typeInfo = GetActiveContract(); + if (options.ReferenceHandler is { } handler && !ReferenceEquals(handler, ReferenceHandler.IgnoreCycles) && + serializedParent is JsonObject referenceObject) + { + if (referenceObject.ContainsKey("$ref")) + { + return null; + } + + if (typeInfo.Kind == JsonTypeInfoKind.Enumerable && name == "$values" && + referenceObject["$values"] is JsonArray) + { + return new(Value, typeInfo, parent: this); + } + } + + if (typeInfo.Kind == JsonTypeInfoKind.Dictionary && + serializedParent is JsonObject serializedDictionary && Value is IEnumerable entries) + { + if (!_dictionaryRead) + { + _dictionaryRead = true; + JsonTypeInfo keyContract = options.GetTypeInfo(typeInfo.KeyType!); + foreach (object entry in entries) + { + if (!TryGetDictionaryEntry(entry, out object? entryKey, out object? entryValue)) + { + continue; + } + + // Non-generic dictionaries dispatch non-string keys through the object converter. + JsonTypeInfo entryKeyContract = Value is IDictionary && keyContract.Type == typeof(string) && entryKey is not string + ? options.GetTypeInfo(typeof(object)) + : keyContract; + if (!TrySerializeDictionaryKey(entryKey, entryKeyContract, out string serializedKey) || + !serializedDictionary.ContainsKey(serializedKey)) + { + continue; + } + + _children[serializedKey] = _children.ContainsKey(serializedKey) + ? null + : new(entryValue, options.GetTypeInfo(typeInfo.ElementType!), parent: this); + } + } + + return _children.TryGetValue(name, out SourceRecoveryContext? entryContext) ? entryContext : null; + } + + if (typeInfo.Kind == JsonTypeInfoKind.Enumerable && serializedParent is JsonArray serializedArray && + int.TryParse(name, NumberStyles.None, CultureInfo.InvariantCulture, out int index) && + index >= 0 && index < serializedArray.Count) + { + if (Value is IList list) + { + return index < list.Count ? new(list[index], options.GetTypeInfo(typeInfo.ElementType!), parent: this) : null; + } + + if (TryGetGenericListElement(Value, typeInfo.ElementType!, index, out object? element)) + { + return new(element, options.GetTypeInfo(typeInfo.ElementType!), parent: this); + } + } + + if (typeInfo.Kind != JsonTypeInfoKind.Object) + { + return null; + } + + SourceObjectContract sourceContract = s_sourceContracts.GetValue(typeInfo, static contract => new(contract)); + if (sourceContract.Properties.TryGetValue(name, out JsonPropertyInfo? member)) + { + if (member.Get is null || IsExcludedReadOnlyMember(member, options)) + { + return null; + } + + if (serializedParent is JsonObject serializedObject && serializedObject.ContainsKey(name) && + sourceContract.ExtensionData is { } extension && + GetExtensionData(extension) is { } collisionData && + (extension.CustomConverter is not null || collisionData switch + { + IDictionary data => data.ContainsKey(name), + IDictionary data => data.ContainsKey(name), + JsonObject data => data.ContainsKey(name), + _ => true, + })) + { + // The JSON path may belong to extension data instead of this CLR member. + return null; + } + + object? memberValue = member.Get(Value); + if (serializedParent is JsonObject parentObject && parentObject.ContainsKey(name) && + member.ShouldSerialize?.Invoke(Value, memberValue) == false) + { + return null; + } + + JsonTypeInfo memberContract = options.GetTypeInfo(member.PropertyType); + return new(memberValue, memberContract, member, Value, typeInfo, this); + } + + JsonPropertyInfo? extensionData = sourceContract.ExtensionData; + if (extensionData is null || extensionData.CustomConverter is not null) + { + return null; + } + + object? extensionValue = GetExtensionData(extensionData); + if (extensionValue is null) + { + return null; + } + + JsonTypeInfo extensionType = options.GetTypeInfo(extensionData.PropertyType); + if (extensionType.Kind != JsonTypeInfoKind.Dictionary) + { + return null; + } + + object? extensionEntry; + if (extensionValue is IDictionary objectData && objectData.TryGetValue(name, out extensionEntry)) + { + return new(extensionEntry, options.GetTypeInfo(extensionType.ElementType!), parent: this); + } + + if (extensionValue is IDictionary elementData && elementData.TryGetValue(name, out JsonElement elementEntry)) + { + return new(elementEntry, options.GetTypeInfo(typeof(JsonElement)), parent: this); + } + + return null; + } + +#if !NETSTANDARD2_0 + [DynamicDependency(DynamicallyAccessedMemberTypes.PublicProperties, typeof(IReadOnlyList<>))] + [DynamicDependency(DynamicallyAccessedMemberTypes.PublicProperties, typeof(IList<>))] +#endif + [UnconditionalSuppressMessage("Trimming", "IL2075", Justification = "The indexed interface properties are preserved by DynamicDependency.")] + private static bool TryGetGenericListElement(object value, Type elementType, int index, out object? element) + { + element = null; + foreach (Type contract in value.GetType().GetInterfaces()) + { + if (!contract.IsGenericType || contract.GetGenericArguments()[0] != elementType) + { + continue; + } + + Type definition = contract.GetGenericTypeDefinition(); + if (definition == typeof(IList<>) || definition == typeof(IReadOnlyList<>)) + { + PropertyInfo? item = contract.GetProperty("Item"); + if (item is not null) + { + try + { + element = item.GetValue(value, [index]); + return true; + } + catch (TargetInvocationException ex) when (ex.InnerException is ArgumentOutOfRangeException or IndexOutOfRangeException) + { + return false; + } + } + } + } + + return false; + } + + private object? GetExtensionData(JsonPropertyInfo property) + { + if (!_extensionDataRead) + { + _extensionDataRead = true; + if (property.Get is { } getter && !IsExcludedReadOnlyMember(property, Contract.Options)) + { + object? data = getter(Value!); + if (property.ShouldSerialize?.Invoke(Value!, data) != false) + { + _extensionData = data; + } + } + } + + return _extensionData; + } + + internal object GetSerializationOwner() => + GetActiveContract().Type.IsValueType ? RuntimeHelpers.GetObjectValue(Value!)! : Value!; + + internal JsonTypeInfo GetScopeContract(JsonTypeInfo childContract) + { + _scopeContracts ??= new(); + if (!_scopeContracts.TryGetValue(childContract, out JsonTypeInfo? scope)) + { + scope = CreateSerializationObjectContract(GetActiveContract()); + JsonPropertyInfo child = JsonMetadataServices.CreatePropertyInfo(scope.Options, new() + { + DeclaringType = typeof(object), + IsProperty = true, + IsPublic = true, + PropertyName = "value", + JsonPropertyName = "value", + PropertyTypeInfo = childContract, + Getter = static _ => t_serializingSourceMember!.GetNextOwner(), + }); + child.ShouldSerialize = static (_, _) => true; + scope.Properties.Add(child); + scope.MakeReadOnly(); + _scopeContracts.Add(childContract, scope); + } + + return scope; + } + + private JsonTypeInfo GetActiveContract() => _activeContract ??= ResolveActiveContract(); + + private JsonTypeInfo ResolveActiveContract() + { + JsonSerializerOptions options = Contract.Options; + Type runtimeType = Value!.GetType(); + if (runtimeType == Contract.Type) + { + return Contract; + } + + if (Contract.Type == typeof(object) && Contract.Converter.GetType().Assembly == typeof(JsonSerializer).Assembly) + { + try + { + return options.GetTypeInfo(runtimeType); + } + catch (NotSupportedException) when (runtimeType.IsDefined(typeof(CompilerGeneratedAttribute), false) && + typeof(IEnumerable).IsAssignableFrom(runtimeType) && fallbackType is not null) + { + JsonTypeInfo fallbackContract = options.GetTypeInfo(fallbackType); + if (fallbackContract.Kind != JsonTypeInfoKind.Enumerable) + { + throw; + } + + return fallbackContract; + } + } + + if (Nullable.GetUnderlyingType(Contract.Type) is { } underlyingType && + Contract.Converter.GetType().Assembly == typeof(JsonSerializer).Assembly) + { + return options.GetTypeInfo(underlyingType); + } + + if (Contract.PolymorphismOptions is not { } polymorphism) + { + return Contract; + } + + foreach (JsonDerivedType derived in polymorphism.DerivedTypes) + { + if (derived.DerivedType == runtimeType) + { + return options.GetTypeInfo(runtimeType); + } + } + + if (polymorphism.UnknownDerivedTypeHandling == JsonUnknownDerivedTypeHandling.FallBackToNearestAncestor) + { + Type? nearest = null; + foreach (JsonDerivedType derived in polymorphism.DerivedTypes) + { + if (derived.DerivedType.IsAssignableFrom(runtimeType) && + (nearest is null || nearest.IsAssignableFrom(derived.DerivedType))) + { + nearest = derived.DerivedType; + } + } + + if (nearest is not null) + { + return options.GetTypeInfo(nearest); + } + } + + return Contract; + } + } + + private sealed class SourceObjectContract + { + private readonly JsonTypeInfo _contract; + private readonly ConcurrentDictionary>> _serializationContracts = new(); + + internal Dictionary Properties { get; } = new(StringComparer.Ordinal); + internal JsonPropertyInfo? ExtensionData { get; } + + internal SourceObjectContract(JsonTypeInfo contract) + { + _contract = contract; + foreach (JsonPropertyInfo property in contract.Properties) + { + if (property.IsExtensionData) + { + ExtensionData = property; + } + else + { + Properties.Add(property.Name, property); + } + } + } + + internal JsonTypeInfo GetSerializationContract(JsonPropertyInfo property) => + _serializationContracts.GetOrAdd(property, member => new(() => CreateSerializationContract(member))).Value; + + [System.Diagnostics.CodeAnalysis.UnconditionalSuppressMessage( + "Trimming", "IL2026", + Justification = "The property type metadata is cached in the original options before CreateJsonPropertyInfo reuses its typed property factory.")] + [System.Diagnostics.CodeAnalysis.UnconditionalSuppressMessage( + "Aot", "IL3050", + Justification = "CreateJsonPropertyInfo uses the cached property type metadata, so no new generic types are constructed.")] + private JsonTypeInfo CreateSerializationContract(JsonPropertyInfo property) + { + JsonSerializerOptions options = _contract.Options; + _ = options.GetTypeInfo(property.PropertyType); + JsonTypeInfo contract = CreateSerializationObjectContract(_contract); + JsonPropertyInfo candidate = contract.CreateJsonPropertyInfo(property.PropertyType, property.Name); + candidate.Get = static _ => t_serializingSourceMember!.Value; + candidate.ShouldSerialize = static (_, _) => true; + candidate.CustomConverter = GetPropertyConverter(property, property.PropertyType); + candidate.NumberHandling = property.NumberHandling; + candidate.IsGetNullable = property.IsGetNullable; + contract.Properties.Add(candidate); + contract.MakeReadOnly(); + return contract; + } + } + + private static JsonTypeInfo CreateSerializationObjectContract(JsonTypeInfo original) + { + JsonConverter converter = original.Kind == JsonTypeInfoKind.Object + ? original.Converter + : JsonMetadataServices.CreateObjectInfo(original.Options, new() + { + PropertyMetadataInitializer = static _ => [], + }).Converter; + // Collections use a single-property object view to seed the same reference stack + // without enumerating unrelated entries. These ancestor envelopes are never published. + JsonTypeInfo contract = JsonMetadataServices.CreateValueInfo(original.Options, converter); + contract.NumberHandling = original.NumberHandling; + contract.PolymorphismOptions = null; + contract.OnSerializing = null; + contract.OnSerialized = null; + return contract; + } + + private sealed class SourceMemberSerializationState(object? value, Stack? owners) + { + internal object? Value { get; } = value; + internal object GetNextOwner() => owners!.Pop().GetSerializationOwner(); + } + +#if !NETSTANDARD2_0 + [System.Diagnostics.CodeAnalysis.DynamicDependency( + System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicProperties, typeof(KeyValuePair<,>))] +#endif + [System.Diagnostics.CodeAnalysis.UnconditionalSuppressMessage( + "Trimming", "IL2075", + Justification = "The reflected properties belong to KeyValuePair<,>, whose public properties are explicitly preserved.")] + private static bool TryGetDictionaryEntry(object entry, out object? key, out object? value) + { + if (entry is DictionaryEntry dictionaryEntry) + { + key = dictionaryEntry.Key; + value = dictionaryEntry.Value; + return true; + } + + Type type = entry.GetType(); + if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(KeyValuePair<,>)) + { + key = type.GetProperty(nameof(KeyValuePair.Key))!.GetValue(entry); + value = type.GetProperty(nameof(KeyValuePair.Value))!.GetValue(entry); + return true; + } + + key = value = null; + return false; + } + +#if !NETSTANDARD2_0 + [System.Diagnostics.CodeAnalysis.DynamicDependency( + nameof(JsonConverter.WriteAsPropertyName), typeof(JsonConverter<>))] +#endif + [System.Diagnostics.CodeAnalysis.UnconditionalSuppressMessage( + "Trimming", "IL2075", + Justification = "The reflected method belongs to JsonConverter, whose WriteAsPropertyName method is explicitly preserved.")] + private static bool TrySerializeDictionaryKey(object? key, JsonTypeInfo contract, out string name) + { + name = string.Empty; + try + { + if (key is string stringValue && contract.Type == typeof(string) && + contract.Converter.GetType().Assembly == typeof(JsonSerializer).Assembly) + { + name = contract.Options.DictionaryKeyPolicy is { } policy ? policy.ConvertName(stringValue) : stringValue; + if (name is not null) + { + return true; + } + + name = string.Empty; + return false; + } + + using MemoryStream stream = new(); + using (Utf8JsonWriter writer = new(stream)) + { + writer.WriteStartObject(); + if (contract.Converter is JsonConverter stringConverter && key is string stringKey) + { + stringConverter.WriteAsPropertyName(writer, stringKey, contract.Options); + } + else if (contract.Converter is JsonConverter objectConverter && key is not null) + { + objectConverter.WriteAsPropertyName(writer, key, contract.Options); + } + else + { + Type? converterType = contract.Converter.GetType(); + while (converterType is not null && + (!converterType.IsGenericType || converterType.GetGenericTypeDefinition() != typeof(JsonConverter<>))) + { + converterType = converterType.BaseType; + } + + converterType?.GetMethod(nameof(JsonConverter.WriteAsPropertyName))? + .Invoke(contract.Converter, [writer, key, contract.Options]); + } + + writer.WriteNullValue(); + writer.WriteEndObject(); + } + + using JsonDocument document = JsonDocument.Parse(stream.ToArray()); + name = document.RootElement.EnumerateObject().Single().Name; + return true; + } + catch (TargetInvocationException exception) when ( + exception.InnerException is { } inner && !IsNonFatalRecoveryFailure(inner)) + { + System.Runtime.ExceptionServices.ExceptionDispatchInfo.Capture(exception.InnerException!).Throw(); + throw; + } + catch (Exception exception) when (IsNonFatalRecoveryFailure(exception)) + { + return false; + } + } + + private static bool TrySerializeSourceMember(SourceRecoveryContext member, out JsonNode? serializedValue) + { + serializedValue = null; + try + { + JsonTypeInfo originalContract = member.OwnerContract!; + JsonSerializerOptions options = originalContract.Options; + JsonPropertyInfo property = member.Property!; + JsonTypeInfo contract = s_sourceContracts.GetValue(originalContract, static owner => new(owner)) + .GetSerializationContract(property); + SourceRecoveryContext ownerContext = member.Parent!; + Stack? owners = null; + int scopeDepth = 0; + if (ReferenceEquals(options.ReferenceHandler, ReferenceHandler.IgnoreCycles) && ownerContext.Parent is not null) + { + owners = new(); + owners.Push(ownerContext); + while (ownerContext.Parent is { } ancestor) + { + contract = ancestor.GetScopeContract(contract); + owners.Push(ancestor); + ownerContext = ancestor; + scopeDepth++; + } + owners.Pop(); + } + + JsonElement element; + SourceMemberSerializationState? previous = t_serializingSourceMember; + try + { + // Serialization is synchronous; restore the slot for nested invocations. + t_serializingSourceMember = new(member.Value, owners); + // Value-type serialization must not track the original box as a reference cycle. + element = JsonSerializer.SerializeToElement(ownerContext.GetSerializationOwner(), contract); + for (int i = 0; i < scopeDepth; i++) + { + element = element.GetProperty("value"); + } + element = element.GetProperty(property.Name); + } + finally + { + t_serializingSourceMember = previous; + } + if (options.ReferenceHandler is { } handler && !ReferenceEquals(handler, ReferenceHandler.IgnoreCycles) && + ContainsReferenceMetadata(element)) + { + return false; + } + + serializedValue = JsonSerializer.SerializeToNode(element, McpJsonUtilities.JsonContext.Default.JsonElement); + return true; + } + catch (Exception exception) when (IsNonFatalRecoveryFailure(exception)) + { + return false; + } + } + + private static bool ContainsReferenceMetadata(JsonElement element) => + element.ValueKind switch + { + JsonValueKind.Object => element.EnumerateObject().Any(static property => + property.Name is "$id" or "$ref" || ContainsReferenceMetadata(property.Value)), + JsonValueKind.Array => element.EnumerateArray().Any(static item => ContainsReferenceMetadata(item)), + _ => false, + }; + + private static bool HasDuplicatePropertyNames(JsonElement element) + { + if (element.ValueKind == JsonValueKind.Object) + { + HashSet names = new(StringComparer.Ordinal); + foreach (JsonProperty property in element.EnumerateObject()) + { + if (!names.Add(property.Name) || HasDuplicatePropertyNames(property.Value)) + { + return true; + } + } + } + else if (element.ValueKind == JsonValueKind.Array) + { + return element.EnumerateArray().Any(static item => HasDuplicatePropertyNames(item)); + } + + return false; + } + + private static JsonConverter? GetPropertyConverter(JsonPropertyInfo? propertyInfo, Type propertyType) + { + if (propertyInfo is null) + { + return null; + } + + if (propertyInfo.CustomConverter is JsonConverter customConverter) + { + return customConverter; + } + + if (propertyInfo.AttributeProvider is ICustomAttributeProvider attributeProvider && + attributeProvider.GetCustomAttributes(typeof(JsonConverterAttribute), inherit: true) + .OfType() + .FirstOrDefault() is { } converterAttribute) + { + return converterAttribute.CreateConverter(propertyType); + } + + return null; + } + + private sealed class SchemaReferenceResolver + { + private readonly Dictionary _resources = new(); + private readonly Dictionary _resourceIds = new(); + private readonly List _dynamicSchemas = []; + private readonly JsonNode _root; + private readonly Uri? _rootUri; + internal HashSet SupplementalSchemas { get; } = []; + internal bool HasDynamicReferences => _dynamicSchemas.Count != 0; + + internal bool CanResolveReference(Uri resourceUri, string reference) => + _resources.TryGetValue(resourceUri, out JsonNode? resource) && + TryResolve(resource, reference, out _, out _); + + internal SchemaReferenceResolver(JsonNode schema, Uri? rootUri = null) + { + _root = schema; + _rootUri = rootUri; + Register(schema, new Uri("https://mcp.invalid/schema"), isRoot: true); + } + + internal void AddSupplementalSchema(JsonNode schema) + { + Register(schema, new Uri("https://mcp.invalid/recovery")); + SupplementalSchemas.Add(schema); + } + + internal bool TryResolveEvaluationPath(string path, out Uri? schemaUri) + { + schemaUri = null; + JsonNode? node = _root; + List scope = [GetResourceRoot(_root)]; + foreach (string segment in path.Split('/').Skip(1).Select(DecodeJsonPointerSegment)) + { + if (node is JsonObject obj) + { + if (segment is "$ref" or "$dynamicRef" && + obj[segment] is JsonValue value && value.TryGetValue(out string? reference) && + reference is not null) + { + if (!TryResolve(GetResourceRoot(node), reference, out JsonNode? target, out JsonNode resource)) + { + return false; + } + + if (segment == "$dynamicRef" && target is JsonObject targetObject && + targetObject["$dynamicAnchor"]?.GetValue() is string anchor && + reference.IndexOf('#') is int fragment && fragment >= 0 && + Uri.UnescapeDataString(reference.Substring(fragment + 1)) == anchor) + { + foreach (JsonNode caller in scope) + { + if (TryFindSchemaAnchor(caller, anchor, out JsonNode? bound, dynamicOnly: true)) + { + target = bound; + resource = caller; + break; + } + } + } + + node = target; + scope = EnterResource(scope, resource); + } + else + { + node = obj[segment]; + } + } + else if (node is JsonArray array && + int.TryParse(segment, NumberStyles.None, CultureInfo.InvariantCulture, out int index) && + index >= 0 && index < array.Count) + { + node = array[index]; + } + else + { + return false; + } + + if (node is null) + { + return false; + } + + if (_resourceIds.ContainsKey(node)) + { + scope = EnterResource(scope, node); + } + } + + schemaUri = GetSchemaUri(node!); + return true; + } + + internal Uri GetSchemaUri(JsonNode schema) + { + JsonNode resource = GetResourceRoot(schema); + string pointer = GetSchemaPointer(schema, resource); + return pointer.Length == 0 ? _resourceIds[resource] : new Uri(_resourceIds[resource], "#" + Uri.EscapeDataString(pointer)); + } + + internal JsonNode GetResourceRoot(JsonNode schema) + { + JsonNode resource = schema; + while (!_resourceIds.ContainsKey(resource) && resource.Parent is JsonNode parent) + { + resource = parent; + } + + return resource; + } + + internal static List EnterResource(List scope, JsonNode resource) => + scope.Contains(resource) ? scope : [.. scope, resource]; + + internal string GetScopeKey(List scope) => + string.Join("\n", scope.Select(resource => _resourceIds[resource].AbsoluteUri)); + + internal void BindDynamicReferences(JsonNode document, JsonNode copy, List scope) + { + // JsonSchemaNode exposes evaluation metadata, but EvaluationContext cannot be + // constructed with a caller scope. Bind only names already fixed by that scope: + // entering further resources cannot change their outermost dynamic anchor. + foreach (JsonNode node in _dynamicSchemas) + { + if (node is not JsonObject schemaObject || + schemaObject["$dynamicRef"] is not JsonValue value || + !value.TryGetValue(out string? reference) || + reference is null || + !TryResolve(GetResourceRoot(node), reference, out JsonNode? target, out _) || + target is not JsonObject targetObject || + targetObject["$dynamicAnchor"]?.GetValue() is not string anchor || + reference.IndexOf('#') < 0 || + !string.Equals(Uri.UnescapeDataString(reference[(reference.IndexOf('#') + 1)..]), anchor, StringComparison.Ordinal)) + { + continue; + } + + foreach (JsonNode resource in scope) + { + if (TryFindSchemaAnchor(resource, anchor, out JsonNode? bound, dynamicOnly: true) && + bound is not null && + TryGetNodeAtPath(copy, GetSchemaPointer(node, document), out JsonNode? copiedNode) && + copiedNode is JsonObject copiedObject) + { + copiedObject.Remove("$dynamicRef"); + if (copiedObject.ContainsKey("$ref")) + { + JsonArray allOf = copiedObject["allOf"] as JsonArray ?? []; + if (allOf.Parent is null) + { + copiedObject["allOf"] = allOf; + } + + allOf.Add((JsonNode)new JsonObject { ["$ref"] = GetSchemaUri(bound).AbsoluteUri }); + } + else + { + copiedObject["$ref"] = GetSchemaUri(bound).AbsoluteUri; + } + break; + } + } + } + } + + private void Register(JsonNode? schema, Uri baseUri, bool isRoot = false) + { + if (schema is not JsonObject schemaObject) + { + return; + } + + if (schemaObject.ContainsKey("$dynamicRef")) + { + _dynamicSchemas.Add(schema); + } + bool isResource = isRoot; + if (isRoot && _rootUri is not null) + { + baseUri = _rootUri; + } + else if (schemaObject["$id"] is JsonValue idValue && idValue.TryGetValue(out string? id) && + Uri.TryCreate(baseUri, id, out Uri? resourceUri)) + { + baseUri = resourceUri; + isResource = true; + } + + if (isResource) + { + _resourceIds[schema] = baseUri; + _resources[new UriBuilder(baseUri) { Fragment = string.Empty }.Uri] = schema; + } + + foreach (string keyword in new[] { "$defs", "definitions", "properties", "patternProperties", "dependentSchemas" }) + { + if (schemaObject[keyword] is JsonObject schemas) + { + foreach (KeyValuePair child in schemas) + { + Register(child.Value, baseUri); + } + } + } + + foreach (string keyword in new[] { "allOf", "anyOf", "oneOf", "prefixItems" }) + { + if (schemaObject[keyword] is JsonArray schemas) + { + foreach (JsonNode? child in schemas) + { + Register(child, baseUri); + } + } + } + + foreach (string keyword in new[] { "items", "additionalProperties", "unevaluatedProperties", "unevaluatedItems", "contains", "if", "then", "else", "not", "propertyNames" }) + { + Register(schemaObject[keyword], baseUri); + } + } + + internal bool TryResolve(JsonNode currentResource, string reference, out JsonNode? schema, out JsonNode resource) + { + schema = null; + resource = currentResource; + if (!_resourceIds.TryGetValue(currentResource, out Uri? baseUri) || + !Uri.TryCreate(baseUri, reference, out Uri? targetUri) || + !_resources.TryGetValue(new UriBuilder(targetUri) { Fragment = string.Empty }.Uri, out JsonNode? targetResource) || + !TryResolveLocalReference(targetResource, targetUri.Fragment.Length == 0 ? "#" : targetUri.Fragment, out schema)) + { + return false; + } + + resource = targetResource; + for (JsonNode? node = schema; node is not null; node = node.Parent) + { + if (_resourceIds.ContainsKey(node)) + { + resource = node; + break; + } + } + + return true; + } + } + + private static bool TryResolveLocalReference(JsonNode schemaRoot, string reference, out JsonNode? resolvedSchema) + { + resolvedSchema = null; + if (!reference.StartsWith("#", StringComparison.Ordinal)) + { + return false; + } + + string pointer = Uri.UnescapeDataString(reference[1..]); + if (pointer.Length == 0) + { + resolvedSchema = schemaRoot; + return true; + } + + if (!pointer.StartsWith("/", StringComparison.Ordinal)) + { + return TryFindSchemaAnchor(schemaRoot, pointer, out resolvedSchema); + } + + JsonNode? current = schemaRoot; + foreach (string rawSegment in pointer[1..].Split('/')) + { + string segment = rawSegment.Replace("~1", "/").Replace("~0", "~"); + if (current is JsonObject currentObject && currentObject.TryGetPropertyValue(segment, out JsonNode? child)) + { + current = child; + } + else if (current is JsonArray currentArray && int.TryParse(segment, out int index) && index >= 0 && index < currentArray.Count) + { + current = currentArray[index]; + } + else + { + return false; + } + } + + resolvedSchema = current; + return true; + } + + private static bool TryFindSchemaAnchor(JsonNode? node, string anchor, out JsonNode? resolvedSchema, bool dynamicOnly = false, bool isResourceRoot = true) + { + resolvedSchema = null; + if (node is not JsonObject schemaObject || (!isResourceRoot && schemaObject.ContainsKey("$id"))) + { + return false; + } + + if ((!dynamicOnly && schemaObject["$anchor"]?.GetValue() == anchor) || + schemaObject["$dynamicAnchor"]?.GetValue() == anchor) + { + resolvedSchema = node; + return true; + } + + foreach (string keyword in new[] { "$defs", "definitions", "properties", "patternProperties", "dependentSchemas" }) + { + if (schemaObject[keyword] is JsonObject schemas) + { + foreach (KeyValuePair schema in schemas) + { + if (TryFindSchemaAnchor(schema.Value, anchor, out resolvedSchema, dynamicOnly, isResourceRoot: false)) + { + return true; + } + } + } + } + + foreach (string keyword in new[] { "allOf", "anyOf", "oneOf", "prefixItems" }) + { + if (schemaObject[keyword] is JsonArray schemas) + { + foreach (JsonNode? schema in schemas) + { + if (TryFindSchemaAnchor(schema, anchor, out resolvedSchema, dynamicOnly, isResourceRoot: false)) + { + return true; + } + } + } + } + + foreach (string keyword in new[] { "items", "additionalProperties", "unevaluatedProperties", "unevaluatedItems", "contains", "if", "then", "else", "not", "propertyNames" }) + { + if (TryFindSchemaAnchor(schemaObject[keyword], anchor, out resolvedSchema, dynamicOnly, isResourceRoot: false)) + { + return true; + } + } + + return false; + } + private static CallToolResult ConvertAIContentEnumerableToCallToolResult(IEnumerable contentItems, JsonElement? structuredContent) { List contentList = []; @@ -903,4 +4208,4 @@ private static bool IsPrimitiveHeaderType(Type type) type == typeof(uint) || type == typeof(long); } -} \ No newline at end of file +} diff --git a/src/ModelContextProtocol.Core/Server/DelegatingMcpServerTool.cs b/src/ModelContextProtocol.Core/Server/DelegatingMcpServerTool.cs index d2bce270f..f9471746e 100644 --- a/src/ModelContextProtocol.Core/Server/DelegatingMcpServerTool.cs +++ b/src/ModelContextProtocol.Core/Server/DelegatingMcpServerTool.cs @@ -6,6 +6,10 @@ namespace ModelContextProtocol.Server; /// /// This is recommended as a base type when building tools that can be chained around an underlying . /// The default implementation simply passes each call to the inner tool instance. +/// When an invocation override retains the original element, +/// server dispatch can apply required-member recovery against the registered tool's effective output schema. +/// Calling the base invocation also finalizes recovery for the inner forwarding segment before an override consumes it. +/// Overrides that replace or reserialize structured content are responsible for matching their advertised schema. /// public abstract class DelegatingMcpServerTool : McpServerTool { @@ -35,9 +39,24 @@ protected DelegatingMcpServerTool(McpServerTool innerTool) /// public override ValueTask InvokeAsync( - RequestContext request, + RequestContext request, CancellationToken cancellationToken = default) => - _innerTool.InvokeAsync(request, cancellationToken); + HasCustomInvocation && request is { StructuredContentRecoveryTarget: { } recoveryTarget } + ? InvokeInnerToolAsync(request, recoveryTarget, cancellationToken) + : _innerTool.InvokeAsync(request, cancellationToken); + + private async ValueTask InvokeInnerToolAsync( + RequestContext request, + RequestContext.StructuredContentRecoveryState recoveryTarget, + CancellationToken cancellationToken) + { + CallToolResult result = await _innerTool.InvokeAsync(request, cancellationToken).ConfigureAwait(false); + if (ReferenceEquals(request.StructuredContentRecoveryTarget, recoveryTarget)) + { + request.RecoverStructuredContent(_innerTool, result); + } + return result; + } /// public override string ToString() => _innerTool.ToString(); diff --git a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs index fce7efa1f..3250c8a55 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs @@ -1202,6 +1202,12 @@ private void ConfigureCustomRequestHandlers(McpServerOptions options) $"A custom request handler registered through {nameof(McpServerOptions)}.{nameof(McpServerOptions.RequestHandlers)} has an empty {nameof(McpServerRequestHandler.RoutingNameParameter)}."); } + if (entry.Handler is not { } handler) + { + throw new InvalidOperationException( + $"The custom request handler for '{entry.Method}' has a null {nameof(McpServerRequestHandler.Handler)}."); + } + // Custom handlers are registered after all built-in handlers, so a method already present // belongs to a built-in method (e.g. initialize, tools/call) or an earlier custom handler. // Silently overwriting it would bypass the built-in handler's filters and protocol gating, @@ -1213,14 +1219,15 @@ private void ConfigureCustomRequestHandlers(McpServerOptions options) $"uses the method '{entry.Method}', which is already handled by the server. Custom handlers cannot replace built-in methods or other custom handlers."); } - SetRawHandler(entry.Method, entry.Handler); + SetRawHandler(entry.Method, handler); } #pragma warning restore MCPEXP002 } - private void SetRawHandler(string method, Func> handler) + private void SetRawHandler(string method, McpRequestHandler handler) { - _requestHandlers[method] = (request, ct) => handler(request, ct).AsTask(); + _requestHandlers[method] = (request, ct) => + InvokeHandlerAsync(handler, request.Params, request, ct).AsTask(); } private void ConfigureResources(McpServerOptions options) @@ -1603,7 +1610,7 @@ await originalListToolsHandler(request, cancellationToken).ConfigureAwait(false) { if (request.MatchedPrimitive is McpServerTool tool) { - return tool.InvokeAsync(request, cancellationToken); + return InvokeToolAsync(tool, request, cancellationToken); } return originalHandler(request, cancellationToken); @@ -1636,7 +1643,37 @@ private static async ValueTask> InvokeToolWith RequestContext request, CancellationToken cancellationToken) { - return await tool.InvokeAsync(request, cancellationToken).ConfigureAwait(false); + return await InvokeToolAsync(tool, request, cancellationToken).ConfigureAwait(false); + } + + private static ValueTask InvokeToolAsync( + McpServerTool tool, + RequestContext request, + CancellationToken cancellationToken) => + tool is DelegatingMcpServerTool || request.StructuredContentRecoveryTarget is not null + ? InvokeToolWithRecoveryAsync(tool, request, cancellationToken) + : tool.InvokeAsync(request, cancellationToken); + + private static async ValueTask InvokeToolWithRecoveryAsync( + McpServerTool tool, + RequestContext request, + CancellationToken cancellationToken) + { + var previousTarget = request.StructuredContentRecoveryTarget; + var recoveryTarget = new RequestContext.StructuredContentRecoveryState( + tool, tool.ProtocolTool.OutputSchema); + request.StructuredContentRecoveryTarget = recoveryTarget; + try + { + CallToolResult result = await tool.InvokeAsync(request, cancellationToken).ConfigureAwait(false); + request.RecoverStructuredContent(tool, result); + return result; + } + finally + { + recoveryTarget.Clear(); + request.StructuredContentRecoveryTarget = previousTarget; + } } private McpRequestHandler> BuildComposedCallToolHandler( diff --git a/src/ModelContextProtocol.Core/Server/McpServerOptions.cs b/src/ModelContextProtocol.Core/Server/McpServerOptions.cs index 2a26868a1..c70218128 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerOptions.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerOptions.cs @@ -210,12 +210,16 @@ public McpServerFilters Filters /// /// /// - /// Each registers a raw JSON-RPC method handler that - /// bypasses the typed handler infrastructure. This enables extensions to register handlers + /// Each registers a JSON-RPC method handler that + /// controls its own serialization and bypasses the typed request filters. This enables extensions to register handlers /// for methods not known to Core at compile time. /// /// - /// Handlers registered here take precedence over built-in handlers for the same method. + /// Handlers receive the request's server and services through + /// and use the same service scope lifecycle as built-in request handlers, honoring . + /// + /// + /// Each method must be unique. Custom handlers cannot replace built-in handlers or other custom handlers. /// /// [Experimental(Experimentals.Extensibility_DiagnosticId, UrlFormat = Experimentals.Extensibility_Url)] diff --git a/src/ModelContextProtocol.Core/Server/McpServerRequestHandler.cs b/src/ModelContextProtocol.Core/Server/McpServerRequestHandler.cs index af8a94091..897a8c887 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerRequestHandler.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerRequestHandler.cs @@ -12,8 +12,15 @@ namespace ModelContextProtocol.Server; /// /// Custom request handlers are registered via and /// are invoked when a JSON-RPC request with the matching is received. -/// The handler receives the raw and returns a serialized -/// response, giving extensions full control over request/response serialization. +/// The handler receives a with raw JSON parameters and returns a +/// serialized response, giving extensions full control over request/response serialization. +/// +/// +/// Custom handlers use the same service scope lifecycle as built-in request handlers, honoring +/// and asynchronously disposing any scope the server creates. +/// Use to resolve request-scoped services; the provider on +/// may be the server's longer-lived provider. +/// Custom handlers do not pass through the typed request filters. /// /// [Experimental(Experimentals.Extensibility_DiagnosticId, UrlFormat = Experimentals.Extensibility_Url)] @@ -39,8 +46,11 @@ public sealed class McpServerRequestHandler /// Gets the handler function that processes incoming requests for the specified method. /// /// - /// The handler receives the full and a , - /// and returns a serialized response (or for void methods). + /// The handler receives the raw JSON parameters in and the + /// full in . + /// Composed handlers may replace without changing the + /// original request, so read operation parameters from the context rather than from the raw request. + /// It returns a serialized response, or for void methods. /// - public required Func> Handler { get; init; } + public required McpRequestHandler Handler { get; init; } } diff --git a/src/ModelContextProtocol.Core/Server/McpServerToolCreateOptions.cs b/src/ModelContextProtocol.Core/Server/McpServerToolCreateOptions.cs index b0b6b3de7..ae094fda8 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerToolCreateOptions.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerToolCreateOptions.cs @@ -124,8 +124,16 @@ public sealed class McpServerToolCreateOptions /// The default is . /// /// + /// /// When enabled, the tool will attempt to populate the /// and provide structured content in the property. + /// + /// + /// Recovery of required members omitted by null or default-value serialization policies is best effort. + /// Recovery does not re-enumerate non-indexable collections, such as sets, queues, or linked lists. + /// Materialize these collections into arrays or lists when their elements need this recovery. + /// If safe recovery cannot produce a schema-valid result, the original serialized content is retained. + /// /// public bool UseStructuredContent { get; set; } diff --git a/src/ModelContextProtocol.Core/Server/RequestContext.cs b/src/ModelContextProtocol.Core/Server/RequestContext.cs index 596712eea..c02a53919 100644 --- a/src/ModelContextProtocol.Core/Server/RequestContext.cs +++ b/src/ModelContextProtocol.Core/Server/RequestContext.cs @@ -1,4 +1,5 @@ using ModelContextProtocol.Protocol; +using System.Text.Json; namespace ModelContextProtocol.Server; @@ -47,6 +48,71 @@ public RequestContext(McpServer server, JsonRpcRequest jsonRpcRequest) /// public IMcpServerPrimitive? MatchedPrimitive { get; set; } + private readonly AsyncLocal _structuredContentRecoveryTarget = new(); + + internal StructuredContentRecoveryState? StructuredContentRecoveryTarget + { + get => _structuredContentRecoveryTarget.Value is { IsActive: true } target ? target : null; + set => _structuredContentRecoveryTarget.Value = value; + } + + internal void RecoverStructuredContent(McpServerTool tool, CallToolResult result) => + StructuredContentRecoveryTarget?.Recover(tool, result); + + internal sealed class StructuredContentRecoveryState(McpServerTool tool, JsonElement? schema) + { + private readonly object _lock = new(); + private readonly Dictionary> _plans = []; + private bool _isActive = true; + + internal McpServerTool Tool { get; } = tool; + internal JsonElement? Schema { get; } = schema; + internal bool IsActive => Volatile.Read(ref _isActive); + + internal void Register(IEnumerable identities, Action recovery) + { + lock (_lock) + { + if (!_isActive) + { + return; + } + + foreach (JsonElement identity in identities) + { + _plans[identity] = recovery; + } + } + } + + internal void Recover(McpServerTool tool, CallToolResult result) + { + if (result.IsError == true || result.StructuredContent is not { } identity) + { + return; + } + + Action? recovery; + lock (_lock) + { + if (!_isActive || !_plans.TryGetValue(identity, out recovery)) + { + return; + } + } + recovery(tool, result); + } + + internal void Clear() + { + lock (_lock) + { + _isActive = false; + _plans.Clear(); + } + } + } + /// /// Gets the JSON-RPC request associated with this context. /// diff --git a/src/ModelContextProtocol.Extensions.Skills/Client/McpSkillsClientExtensions.cs b/src/ModelContextProtocol.Extensions.Skills/Client/McpSkillsClientExtensions.cs index 2bd3fc792..4638335b0 100644 --- a/src/ModelContextProtocol.Extensions.Skills/Client/McpSkillsClientExtensions.cs +++ b/src/ModelContextProtocol.Extensions.Skills/Client/McpSkillsClientExtensions.cs @@ -1,134 +1,83 @@ using ModelContextProtocol.Client; using ModelContextProtocol.Protocol; +using System.Diagnostics.CodeAnalysis; using System.Text.Json; -using System.Text.Json.Nodes; -using System.Text.Json.Serialization.Metadata; namespace ModelContextProtocol.Extensions.Skills; /// -/// Extension methods for to discover skills and verify their file manifests under the -/// MCP Skills extension (SEP-2640). +/// Extension methods for and to discover skills and +/// verify their file manifests under the MCP Skills extension (SEP-2640). /// /// /// These methods validate entries and verify file content against manifests. They do not verify a skill's /// frontmatter, which a host must still do before loading it; see . /// +[Experimental(Experimentals.Skills_DiagnosticId, UrlFormat = Experimentals.Skills_Url)] public static class McpSkillsClientExtensions { /// /// Gets whether the server declared the MCP Skills extension in its capabilities. /// - /// The client. - /// if the server serves skills/list and skills/get; otherwise, . - /// is . + /// The server's advertised capabilities. + /// if the server declared the MCP Skills extension; otherwise, . + /// is . /// /// Clients must issue skills/list and skills/get only after observing the server's declaration. - /// The other methods in this class throw when it is absent. + /// The list and get methods in this class throw when it is absent. /// - public static bool SupportsSkills(this McpClient client) + [Experimental(Experimentals.Skills_DiagnosticId, UrlFormat = Experimentals.Skills_Url)] + public static bool SupportsSkills(this ServerCapabilities capabilities) { #if NET - ArgumentNullException.ThrowIfNull(client); + ArgumentNullException.ThrowIfNull(capabilities); #else - if (client is null) throw new ArgumentNullException(nameof(client)); + if (capabilities is null) throw new ArgumentNullException(nameof(capabilities)); #endif - return client.ServerCapabilities?.Extensions?.ContainsKey(SkillsProtocol.ExtensionId) == true; + return capabilities.Extensions?.ContainsKey(SkillsProtocol.ExtensionId) == true; } /// - /// Retrieves the listed skills, following at most 100 pages and collecting at most 1,000 entries. + /// Retrieves the listed skills by automatically handling pagination. /// /// The client. + /// Optional metadata and progress token for each request. /// The to monitor for cancellation requests. The default is . - /// The listed skills. + /// The listed skills, with every entry structurally validated but not compared with its files. /// is . /// The server did not declare the MCP Skills extension. - /// - /// The server returned an entry that violates the specification (for example, a name that does not match its - /// URI, a malformed digest, or a manifest over the per-skill limits). Hosts must not load such entries. - /// - /// - /// The request failed, a pagination cursor repeated, or the listing exceeded the page or entry limit. - /// + /// The server returned an invalid skill entry. + /// The request failed or the server returned an error response. /// /// - /// The listing may be empty or partial: a server whose catalog is large or generated may list fewer skills than - /// it serves. Do not treat an empty listing as proof that a server has no skills; a skill's entry can always be - /// retrieved by URI with . + /// A server may list fewer skills than it serves. An empty listing does not prevent direct retrieval by URI + /// with . /// /// - /// This overload aggregates every page and does not surface the per-result caching hints. To read those, use - /// , which returns one page at a time. - /// Use to choose different limits. - /// Exceeding a limit fails the operation rather than returning a silently truncated listing. + /// Every call fetches all listing pages without caching. Per-page cache hints are not returned; use + /// to read them or to + /// enforce application-owned pagination budgets. /// /// - public static ValueTask> ListSkillsAsync(this McpClient client, CancellationToken cancellationToken = default) => - ListSkillsAsync(client, maxPages: 100, maxSkills: 1000, cancellationToken); - - /// - /// Retrieves the listed skills with explicit page and entry limits. - /// - /// The client. - /// The maximum number of pages to request. Must be positive. - /// The maximum number of entries to collect. Must be positive. - /// The token used to cancel the operation. - /// The complete listing within the supplied limits. - /// is . - /// A limit is not positive. - /// The server did not declare the Skills extension. - /// An entry is invalid. - /// The request failed, a cursor repeated, or a limit was exceeded. - /// - /// Cursors are opaque, including an empty string. Only a null cursor ends the listing. - /// Use the paged overload when the application needs to stream a larger listing. - /// + [Experimental(Experimentals.Skills_DiagnosticId, UrlFormat = Experimentals.Skills_Url)] public static async ValueTask> ListSkillsAsync( this McpClient client, - int maxPages, - int maxSkills, + RequestOptions? options = null, CancellationToken cancellationToken = default) { -#if NET - ArgumentNullException.ThrowIfNull(client); -#else - if (client is null) throw new ArgumentNullException(nameof(client)); -#endif - if (maxPages <= 0) - { - throw new ArgumentOutOfRangeException(nameof(maxPages), "The page limit must be positive."); - } - if (maxSkills <= 0) - { - throw new ArgumentOutOfRangeException(nameof(maxSkills), "The skill limit must be positive."); - } - - var skills = new List(); - var requestParams = new ListSkillsRequestParams(); - var cursors = new HashSet(StringComparer.Ordinal); - for (int pageNumber = 0; pageNumber < maxPages; pageNumber++) + List? skills = null; + ListSkillsRequestParams requestParams = new() { Meta = options?.GetMetaForRequest() }; + do { - cancellationToken.ThrowIfCancellationRequested(); - var page = await ListSkillsAsync(client, requestParams, cancellationToken).ConfigureAwait(false); - if (page.Skills.Count > maxSkills - skills.Count) - { - throw new McpException($"The skill listing exceeded the limit of {maxSkills} entries. Use the paged overload or a larger explicit limit."); - } + var page = await client.ListSkillsAsync(requestParams, cancellationToken).ConfigureAwait(false); + skills ??= new(page.Skills.Count); skills.AddRange(page.Skills); - if (page.NextCursor is null) - { - return skills; - } - if (!cursors.Add(page.NextCursor)) - { - throw new McpException("The server repeated a skills/list pagination cursor."); - } requestParams.Cursor = page.NextCursor; } + while (requestParams.Cursor is not null); - throw new McpException($"The skill listing exceeded the limit of {maxPages} pages. Use the paged overload or a larger explicit limit."); + return skills; } /// @@ -140,8 +89,14 @@ public static async ValueTask> ListSkillsAsync( /// The page with every entry structurally validated, but not compared with the skill's files. /// or is . /// The server did not declare the MCP Skills extension. - /// The server returned an entry that violates the specification. + /// The server returned an invalid skill entry. /// The request failed or the server returned an error response. + /// + /// This method sends one request and preserves its page's cursor and cache hints. Use + /// to retrieve all listing pages. + /// An empty page does not establish that the server serves no skills; a skill can also be retrieved directly by URI. + /// + [Experimental(Experimentals.Skills_DiagnosticId, UrlFormat = Experimentals.Skills_Url)] public static async ValueTask ListSkillsAsync( this McpClient client, ListSkillsRequestParams requestParams, @@ -157,18 +112,13 @@ public static async ValueTask ListSkillsAsync( ThrowIfSkillsNotSupported(client, nameof(ListSkillsAsync)); - JsonRpcRequest request = new() - { - Method = SkillsProtocol.MethodSkillsList, - Params = JsonSerializer.SerializeToNode(requestParams, McpSkillsJsonContext.Default.ListSkillsRequestParams), - }; + var result = await client.SendRequestAsync( + SkillsProtocol.MethodSkillsList, requestParams, McpSkillsJsonContext.Default.Options, + cancellationToken: cancellationToken).ConfigureAwait(false); - JsonRpcResponse response = await client.SendRequestAsync(request, cancellationToken).ConfigureAwait(false); - var result = DeserializeReceived(response.Result, McpSkillsJsonContext.Default.ListSkillsResult, SkillsProtocol.MethodSkillsList) ?? - throw new JsonException($"Unexpected JSON result in the response to '{SkillsProtocol.MethodSkillsList}'."); - if (result.Skills is null) + if (result.Skills is null || result.Skills.Any(static skill => skill is null)) { - throw new JsonException($"The response to '{SkillsProtocol.MethodSkillsList}' has no 'skills' array."); + throw new JsonException("The required 'skills' result must be an array of non-null entries."); } foreach (var skill in result.Skills) @@ -179,96 +129,28 @@ public static async ValueTask ListSkillsAsync( return result; } - /// - /// Retrieves a page's valid entries and reports each rejected entry without discarding the rest of the page. - /// - /// The client. - /// The request parameters, including the page cursor. - /// The token used to cancel the operation. - /// The valid entries, original pagination/cache hints, and explicit per-entry failures. - /// An argument is . - /// The server did not declare the Skills extension. - /// The page envelope is malformed or lacks a skills array. - /// The request failed or the server returned an error. - /// - /// This is an explicit partial-results API. The ordinary list overloads remain strict. - /// Callers must inspect ; an empty valid-entry list can - /// mean that every entry was rejected. Valid entries still require normal file/frontmatter verification. - /// - public static async ValueTask ListSkillsWithDiagnosticsAsync( - this McpClient client, - ListSkillsRequestParams requestParams, - CancellationToken cancellationToken = default) - { -#if NET - ArgumentNullException.ThrowIfNull(client); - ArgumentNullException.ThrowIfNull(requestParams); -#else - if (client is null) throw new ArgumentNullException(nameof(client)); - if (requestParams is null) throw new ArgumentNullException(nameof(requestParams)); -#endif - ThrowIfSkillsNotSupported(client, nameof(ListSkillsWithDiagnosticsAsync)); - - var request = new JsonRpcRequest - { - Method = SkillsProtocol.MethodSkillsList, - Params = JsonSerializer.SerializeToNode(requestParams, McpSkillsJsonContext.Default.ListSkillsRequestParams), - }; - var response = await client.SendRequestAsync(request, cancellationToken).ConfigureAwait(false); - if (response.Result is not JsonObject rawResult || - rawResult["skills"] is not JsonArray entries) - { - throw new JsonException($"The response to '{SkillsProtocol.MethodSkillsList}' has no 'skills' array."); - } - - var envelope = new JsonObject(); - foreach (var property in rawResult) - { - envelope[property.Key] = property.Key == "skills" ? new JsonArray() : property.Value?.DeepClone(); - } - var page = DeserializeReceived(envelope, McpSkillsJsonContext.Default.ListSkillsResult, SkillsProtocol.MethodSkillsList) ?? - throw new JsonException($"Unexpected JSON result in the response to '{SkillsProtocol.MethodSkillsList}'."); - var rejected = new List(); - for (int index = 0; index < entries.Count; index++) - { - cancellationToken.ThrowIfCancellationRequested(); - try - { - var skill = DeserializeReceived(entries[index], McpSkillsJsonContext.Default.Skill, SkillsProtocol.MethodSkillsList); - SkillValidation.ValidateReceived(skill!, SkillsProtocol.MethodSkillsList); - page.Skills.Add(skill!); - } - catch (Exception error) when (error is JsonException or SkillVerificationException) - { - rejected.Add(new SkillEntryRejection(index, error)); - } - } - - return new SkillListingResult(page, rejected.AsReadOnly()); - } - /// /// Retrieves the entry for a single skill by the URI of its SKILL.md. /// /// The client. /// The URI of the skill's SKILL.md. + /// Optional metadata and progress token for the request. /// The to monitor for cancellation requests. The default is . /// The structurally validated skill entry, not a verification of the file's frontmatter. /// or is . /// The server did not declare the MCP Skills extension. - /// - /// The server returned an entry that violates the specification, or an entry for a different URI than the one requested. - /// /// /// The request failed or the server returned an error response, including /// when the server serves no skill at . /// + /// The entry is invalid or has a different URI than the one requested. /// /// A server answers for every skill it serves, whether or not the skill appears in its listing. Use this to /// confirm a URI referenced from server instructions or another skill, and to refresh one skill's manifest /// without re-enumerating the catalog. /// - public static async ValueTask GetSkillAsync(this McpClient client, string uri, CancellationToken cancellationToken = default) + [Experimental(Experimentals.Skills_DiagnosticId, UrlFormat = Experimentals.Skills_Url)] + public static async ValueTask GetSkillAsync(this McpClient client, string uri, RequestOptions? options = null, CancellationToken cancellationToken = default) { #if NET ArgumentNullException.ThrowIfNull(uri); @@ -276,7 +158,7 @@ public static async ValueTask GetSkillAsync(this McpClient client, string if (uri is null) throw new ArgumentNullException(nameof(uri)); #endif - var result = await GetSkillAsync(client, new GetSkillRequestParams { Uri = uri }, cancellationToken).ConfigureAwait(false); + var result = await GetSkillAsync(client, new GetSkillRequestParams { Uri = uri, Meta = options?.GetMetaForRequest() }, cancellationToken).ConfigureAwait(false); return result.Skill; } @@ -289,10 +171,11 @@ public static async ValueTask GetSkillAsync(this McpClient client, string /// The result with its entry structurally validated, but not compared with the skill's files. /// or is . /// The server did not declare the MCP Skills extension. - /// - /// The server returned an entry that violates the specification, or an entry for a different URI than the one requested. + /// + /// The request failed or the server returned an error response. /// - /// The request failed or the server returned an error response. + /// The entry is invalid or has a different URI than the one requested. + [Experimental(Experimentals.Skills_DiagnosticId, UrlFormat = Experimentals.Skills_Url)] public static async ValueTask GetSkillAsync( this McpClient client, GetSkillRequestParams requestParams, @@ -307,22 +190,22 @@ public static async ValueTask GetSkillAsync( #endif ThrowIfSkillsNotSupported(client, nameof(GetSkillAsync)); + string requestedUri = requestParams.Uri; - JsonRpcRequest request = new() - { - Method = SkillsProtocol.MethodSkillsGet, - Params = JsonSerializer.SerializeToNode(requestParams, McpSkillsJsonContext.Default.GetSkillRequestParams), - }; + var result = await client.SendRequestAsync( + SkillsProtocol.MethodSkillsGet, requestParams, McpSkillsJsonContext.Default.Options, + cancellationToken: cancellationToken).ConfigureAwait(false); - JsonRpcResponse response = await client.SendRequestAsync(request, cancellationToken).ConfigureAwait(false); - var result = DeserializeReceived(response.Result, McpSkillsJsonContext.Default.GetSkillResult, SkillsProtocol.MethodSkillsGet) ?? - throw new JsonException($"Unexpected JSON result in the response to '{SkillsProtocol.MethodSkillsGet}'."); + if (result.Skill is null) + { + throw new JsonException("The required 'skill' result must be an object, not null."); + } SkillValidation.ValidateReceived(result.Skill, SkillsProtocol.MethodSkillsGet); - if (!string.Equals(result.Skill.Uri, requestParams.Uri, StringComparison.Ordinal)) + if (!SkillUriComparer.Instance.Equals(result.Skill.Uri, requestedUri)) { - throw new SkillVerificationException( - $"The server answered '{SkillsProtocol.MethodSkillsGet}' for '{requestParams.Uri}' with the entry for '{result.Skill.Uri}'."); + throw new InvalidDataException( + $"The server answered '{SkillsProtocol.MethodSkillsGet}' for '{requestedUri}' with the entry for '{result.Skill.Uri}'."); } return result; @@ -334,6 +217,7 @@ public static async ValueTask GetSkillAsync( /// The client. /// The skill entry being acted on. /// The URI of the file to read. It must be listed in 's manifest. + /// Optional metadata and progress token, forwarded to the resource read. /// The to monitor for cancellation requests. The default is . /// The manifest-verified contents. Frontmatter verification remains the host's responsibility. /// An argument is . @@ -342,7 +226,7 @@ public static async ValueTask GetSkillAsync( /// integrity; read its files with /// only if the host permits content without digest verification. Frontmatter verification is still required. /// - /// + /// /// is not listed in the manifest, the server's response does not contain contents for /// , or any returned content's size or digest does not match its manifest entry. /// @@ -351,17 +235,19 @@ public static async ValueTask GetSkillAsync( /// /// An unlisted file is treated as a verification failure before any request is sent, because the manifest is /// complete and an unlisted file is a change to the skill. To read it, refresh the entry with - /// and proceed from the new manifest. + /// and proceed from the new manifest. /// /// /// For SKILL.md, the host must also parse the fetched frontmatter and compare it with /// before loading. A digest match does not perform or replace that check. /// /// + [Experimental(Experimentals.Skills_DiagnosticId, UrlFormat = Experimentals.Skills_Url)] public static async ValueTask ReadSkillResourceAsync( this McpClient client, Skill skill, string uri, + RequestOptions? options = null, CancellationToken cancellationToken = default) { #if NET @@ -377,75 +263,22 @@ public static async ValueTask ReadSkillResourceAsync( SkillVerifier.ThrowIfDynamic(skill); if (SkillVerifier.FindResource(skill, uri) is null) { - throw new SkillVerificationException( + throw new InvalidDataException( $"'{uri}' is not listed in the manifest of skill '{skill.Uri}'. An unlisted file is a change to the skill; " + "refresh the entry with skills/get before reading it."); } - var result = await client.ReadResourceAsync(uri, cancellationToken: cancellationToken).ConfigureAwait(false); + var result = await client.ReadResourceAsync(uri, options, cancellationToken).ConfigureAwait(false); SkillVerifier.Verify(skill, uri, result); return result; } private static void ThrowIfSkillsNotSupported(McpClient client, string operationName) { - if (!SupportsSkills(client)) + if (!client.ServerCapabilities.SupportsSkills()) { throw new InvalidOperationException( $"'{operationName}' requires the server to declare the '{SkillsProtocol.ExtensionId}' extension in its capabilities, and it did not."); } } - - /// - /// Deserializes part of a response. An escaped unpaired surrogate such as "\uD800" is valid JSON syntax but - /// not text. System.Text.Json throws a for it when it reads a string from JSON text, but - /// an when it deserializes a , so that case is turned - /// into a here and fails like any other malformed entry. Other exceptions propagate - /// unchanged. - /// - private static T? DeserializeReceived(JsonNode? node, JsonTypeInfo typeInfo, string method) - { - try - { - return node is null ? default : node.Deserialize(typeInfo); - } - catch (InvalidOperationException error) when (ContainsUnreadableText(node)) - { - throw new JsonException( - $"The response to '{method}' contains a string that is not valid Unicode text, such as an unpaired surrogate.", - error); - } - } - - /// Gets whether a property name or string value in cannot be read as text. - private static bool ContainsUnreadableText(JsonNode? node) - { - try - { - switch (node) - { - case JsonObject jsonObject: - return jsonObject.Any(property => ContainsUnreadableText(property.Value)); - - case JsonArray jsonArray: - return jsonArray.Any(ContainsUnreadableText); - - case JsonValue jsonValue: - if (jsonValue.TryGetValue(out JsonElement element) && - element.ValueKind == JsonValueKind.String) - { - _ = element.GetString(); - } - - return false; - - default: - return false; - } - } - catch (InvalidOperationException) - { - return true; - } - } } diff --git a/src/ModelContextProtocol.Extensions.Skills/Client/SkillEntryRejection.cs b/src/ModelContextProtocol.Extensions.Skills/Client/SkillEntryRejection.cs deleted file mode 100644 index 2598282ad..000000000 --- a/src/ModelContextProtocol.Extensions.Skills/Client/SkillEntryRejection.cs +++ /dev/null @@ -1,23 +0,0 @@ -namespace ModelContextProtocol.Extensions.Skills; - -/// -/// Describes one entry rejected while reading a diagnostic listing page. -/// -public sealed class SkillEntryRejection -{ - internal SkillEntryRejection(int index, Exception error) - { - Index = index; - Error = error; - } - - /// - /// Gets the zero-based position in the server's original page. - /// - public int Index { get; } - - /// - /// Gets the parsing or structural-validation failure. No rejected entry is returned as a valid skill. - /// - public Exception Error { get; } -} diff --git a/src/ModelContextProtocol.Extensions.Skills/Client/SkillListingResult.cs b/src/ModelContextProtocol.Extensions.Skills/Client/SkillListingResult.cs deleted file mode 100644 index 7aeb969dc..000000000 --- a/src/ModelContextProtocol.Extensions.Skills/Client/SkillListingResult.cs +++ /dev/null @@ -1,23 +0,0 @@ -namespace ModelContextProtocol.Extensions.Skills; - -/// -/// A client-side listing page with valid entries and explicit per-entry failures. -/// -public sealed class SkillListingResult -{ - internal SkillListingResult(ListSkillsResult page, IReadOnlyList rejectedEntries) - { - Page = page; - RejectedEntries = rejectedEntries; - } - - /// - /// Gets the valid entries and the server's pagination and cache hints. - /// - public ListSkillsResult Page { get; } - - /// - /// Gets the rejected entries. Callers must inspect these even when contains no skills. - /// - public IReadOnlyList RejectedEntries { get; } -} diff --git a/src/ModelContextProtocol.Extensions.Skills/Client/SkillVerificationException.cs b/src/ModelContextProtocol.Extensions.Skills/Client/SkillVerificationException.cs deleted file mode 100644 index 16653ce52..000000000 --- a/src/ModelContextProtocol.Extensions.Skills/Client/SkillVerificationException.cs +++ /dev/null @@ -1,39 +0,0 @@ -namespace ModelContextProtocol.Extensions.Skills; - -/// -/// The exception thrown when a skill entry or its file content fails validation, including a manifest mismatch -/// or an unlisted file. Hosts can also use it for a failed frontmatter comparison. -/// -/// -/// A mismatch means the content is not what the entry described. It may be corrupted, tampered with, or stale -/// because the skill was updated after the entry was fetched. In all cases the content must not be used. To -/// recover from staleness, fetch a fresh entry with skills/get and proceed from its manifest; because the -/// manifest changed, any approval bound to the previous one is revoked. -/// -public sealed class SkillVerificationException : McpException -{ - /// - /// Initializes a new instance of the class. - /// - public SkillVerificationException() - { - } - - /// - /// Initializes a new instance of the class with a specified error message. - /// - /// The message that describes the error. - public SkillVerificationException(string message) : base(message) - { - } - - /// - /// Initializes a new instance of the class with a specified error message - /// and a reference to the inner exception that is the cause of this exception. - /// - /// The message that describes the error. - /// The exception that is the cause of the current exception. - public SkillVerificationException(string message, Exception? innerException) : base(message, innerException) - { - } -} diff --git a/src/ModelContextProtocol.Extensions.Skills/Client/SkillVerifier.cs b/src/ModelContextProtocol.Extensions.Skills/Client/SkillVerifier.cs index 3286d1937..f4489f728 100644 --- a/src/ModelContextProtocol.Extensions.Skills/Client/SkillVerifier.cs +++ b/src/ModelContextProtocol.Extensions.Skills/Client/SkillVerifier.cs @@ -1,5 +1,6 @@ using ModelContextProtocol.Client; using ModelContextProtocol.Protocol; +using System.Diagnostics.CodeAnalysis; using System.Security.Cryptography; using System.Text; @@ -25,6 +26,7 @@ namespace ModelContextProtocol.Extensions.Skills; /// and the content are consistent, not that either is trustworthy. /// /// +[Experimental(Experimentals.Skills_DiagnosticId, UrlFormat = Experimentals.Skills_Url)] public static class SkillVerifier { /// @@ -62,7 +64,7 @@ public static string ComputeDigest(ReadOnlySpan content) /// The manifest entry. /// The bytes that were read. /// is . - /// The size or digest of does not match . + /// The size or digest of does not match . public static void Verify(SkillResource expected, ReadOnlySpan content) { #if NET @@ -73,14 +75,14 @@ public static void Verify(SkillResource expected, ReadOnlySpan content) if (content.Length != expected.Size) { - throw new SkillVerificationException( + throw new InvalidDataException( $"The content of '{expected.Uri}' is {content.Length} bytes, but its manifest entry declares {expected.Size} bytes."); } string actualDigest = ComputeDigest(content); if (!string.Equals(actualDigest, expected.Digest, StringComparison.OrdinalIgnoreCase)) { - throw new SkillVerificationException( + throw new InvalidDataException( $"The content of '{expected.Uri}' has digest '{actualDigest}', but its manifest entry declares '{expected.Digest}'."); } } @@ -91,7 +93,7 @@ public static void Verify(SkillResource expected, ReadOnlySpan content) /// The manifest entry. /// The contents that were read. /// An argument is . - /// + /// /// The contents are not for 's URI, are neither text nor a blob, or their size or /// digest does not match . /// @@ -108,9 +110,9 @@ public static void Verify(SkillResource expected, ResourceContents contents) if (contents is null) throw new ArgumentNullException(nameof(contents)); #endif - if (!string.Equals(contents.Uri, expected.Uri, StringComparison.Ordinal)) + if (!SkillUriComparer.Instance.Equals(contents.Uri, expected.Uri)) { - throw new SkillVerificationException( + throw new InvalidDataException( $"The contents are for '{contents.Uri}', but verification was requested against the manifest entry for '{expected.Uri}'."); } @@ -119,7 +121,7 @@ public static void Verify(SkillResource expected, ResourceContents contents) case TextResourceContents text: if (text.Text is null) { - throw new SkillVerificationException($"The text contents of '{contents.Uri}' carry no text and cannot be verified."); + throw new InvalidDataException($"The text contents of '{contents.Uri}' carry no text and cannot be verified."); } Verify(expected, Encoding.UTF8.GetBytes(text.Text)); @@ -133,14 +135,14 @@ public static void Verify(SkillResource expected, ResourceContents contents) } catch (FormatException e) { - throw new SkillVerificationException($"The blob contents of '{contents.Uri}' are not valid Base64.", e); + throw new InvalidDataException($"The blob contents of '{contents.Uri}' are not valid Base64.", e); } Verify(expected, decoded.Span); break; default: - throw new SkillVerificationException($"The contents of '{contents.Uri}' are neither text nor a blob and cannot be verified."); + throw new InvalidDataException($"The contents of '{contents.Uri}' are neither text nor a blob and cannot be verified."); } } @@ -151,7 +153,7 @@ public static void Verify(SkillResource expected, ResourceContents contents) /// The result of reading a file of the skill. /// An argument is . /// 's manifest is , which cannot be verified. - /// + /// /// The result is empty, contains a null entry or contents for a URI the manifest does not list, /// or contains contents whose size or digest does not match the manifest. /// @@ -167,25 +169,7 @@ public static void Verify(Skill skill, ReadResourceResult result) ThrowIfDynamic(skill); - if (result.Contents is not { Count: > 0 }) - { - throw new SkillVerificationException($"The read returned no contents to verify against skill '{skill.Uri}'."); - } - - foreach (var contents in result.Contents) - { - if (contents is null) - { - throw new SkillVerificationException($"The read returned a null content entry for skill '{skill.Uri}'."); - } - - var expected = FindResource(skill, contents.Uri) ?? - throw new SkillVerificationException( - $"'{contents.Uri}' is not listed in the manifest of skill '{skill.Uri}'. An unlisted file is a change to the skill; " + - "refresh the entry with skills/get before reading it."); - - Verify(expected, contents); - } + VerifyContents(skill, result, CreateResourceIndex(skill)); } /// @@ -197,7 +181,7 @@ public static void Verify(Skill skill, ReadResourceResult result) /// The result of the read. /// An argument is . /// 's manifest is , which cannot be verified. - /// + /// /// is not listed in the manifest, the result does not contain contents for /// , or any contents in the result fail verification per . /// @@ -219,26 +203,62 @@ public static void Verify(Skill skill, string uri, ReadResourceResult result) #endif ThrowIfDynamic(skill); - if (FindResource(skill, uri) is null) - { - throw new SkillVerificationException( + var resources = CreateResourceIndex(skill); + var requested = FindResource(resources, uri) ?? + throw new InvalidDataException( $"'{uri}' is not listed in the manifest of skill '{skill.Uri}'. An unlisted file is a change to the skill; " + "refresh the entry with skills/get before reading it."); + + if (!VerifyContents(skill, result, resources, requested)) + { + throw new InvalidDataException( + $"The read of '{uri}' returned no contents for that URI. The server answered with a different file of skill '{skill.Uri}'."); } + } - Verify(skill, result); + private static Dictionary CreateResourceIndex(Skill skill) + { + var entries = skill.Resources.Entries!; + var resources = new Dictionary(entries.Count, SkillUriComparer.Instance); + foreach (var resource in entries) + { + // A null value marks an ambiguous URI, which fails only when it is looked up. + resources[resource.Uri] = resources.ContainsKey(resource.Uri) ? null : resource; + } - bool found = false; - foreach (var contents in result.Contents) + return resources; + } + + private static SkillResource? FindResource(Dictionary resources, string? uri) => + uri is not null && resources.TryGetValue(uri, out var resource) ? + resource ?? throw new InvalidDataException($"The manifest contains ambiguous entries for '{uri}'.") : null; + + private static bool VerifyContents( + Skill skill, ReadResourceResult result, Dictionary resources, SkillResource? requested = null) + { + if (result.Contents is not { Count: > 0 }) { - found |= string.Equals(contents.Uri, uri, StringComparison.Ordinal); + throw new InvalidDataException($"The read returned no contents to verify against skill '{skill.Uri}'."); } - if (!found) + bool found = false; + foreach (var contents in result.Contents) { - throw new SkillVerificationException( - $"The read of '{uri}' returned no contents for that URI. The server answered with a different file of skill '{skill.Uri}'."); + if (contents is null) + { + throw new InvalidDataException($"The read returned a null content entry for skill '{skill.Uri}'."); + } + + var expected = FindResource(resources, contents.Uri) ?? + throw new InvalidDataException( + $"'{contents.Uri}' is not listed in the manifest of skill '{skill.Uri}'. An unlisted file is a change to the skill; " + + "refresh the entry with skills/get before reading it."); + + Verify(expected, contents); + found |= ReferenceEquals(expected, requested); } + + return found; } /// @@ -262,20 +282,26 @@ internal static void ThrowIfDynamic(Skill skill) internal static SkillResource? FindResource(Skill skill, string uri) { - var resources = skill.Resources?.Resources; + var resources = skill.Resources?.Entries; if (resources is null) { return null; } + SkillResource? match = null; foreach (var resource in resources) { - if (string.Equals(resource.Uri, uri, StringComparison.Ordinal)) + if (SkillUriComparer.Instance.Equals(resource.Uri, uri)) { - return resource; + if (match is not null) + { + throw new InvalidDataException($"The manifest contains ambiguous entries for '{uri}'."); + } + + match = resource; } } - return null; + return match; } } diff --git a/src/ModelContextProtocol.Extensions.Skills/McpSkillsJsonContext.cs b/src/ModelContextProtocol.Extensions.Skills/McpSkillsJsonContext.cs index 10e451bc2..9d4467680 100644 --- a/src/ModelContextProtocol.Extensions.Skills/McpSkillsJsonContext.cs +++ b/src/ModelContextProtocol.Extensions.Skills/McpSkillsJsonContext.cs @@ -1,3 +1,4 @@ +using System.Diagnostics.CodeAnalysis; using System.Text.Json.Serialization; namespace ModelContextProtocol.Extensions.Skills; @@ -15,6 +16,7 @@ namespace ModelContextProtocol.Extensions.Skills; [JsonSerializable(typeof(ListSkillsResult))] [JsonSerializable(typeof(GetSkillRequestParams))] [JsonSerializable(typeof(GetSkillResult))] +[Experimental(Experimentals.Skills_DiagnosticId, UrlFormat = Experimentals.Skills_Url)] public sealed partial class McpSkillsJsonContext : JsonSerializerContext { } diff --git a/src/ModelContextProtocol.Extensions.Skills/ModelContextProtocol.Extensions.Skills.csproj b/src/ModelContextProtocol.Extensions.Skills/ModelContextProtocol.Extensions.Skills.csproj index 00a0bf57f..cbee7a587 100644 --- a/src/ModelContextProtocol.Extensions.Skills/ModelContextProtocol.Extensions.Skills.csproj +++ b/src/ModelContextProtocol.Extensions.Skills/ModelContextProtocol.Extensions.Skills.csproj @@ -8,7 +8,7 @@ MCP Skills extension (SEP-2640) for the .NET Model Context Protocol (MCP) SDK README.md - $(NoWarn);MCPEXP001;MCPEXP002 + $(NoWarn);MCPEXP001;MCPEXP002;MCPEXP005 - $(NoWarn);MCPEXP001;MCPEXP003 + $(NoWarn);MCPEXP001;MCPEXP003;MCPEXP005 diff --git a/tests/ModelContextProtocol.AotCompatibility.TestApp/Program.cs b/tests/ModelContextProtocol.AotCompatibility.TestApp/Program.cs index 04f24e51f..56e2c812f 100644 --- a/tests/ModelContextProtocol.AotCompatibility.TestApp/Program.cs +++ b/tests/ModelContextProtocol.AotCompatibility.TestApp/Program.cs @@ -1,36 +1,352 @@ using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.AI; +using ModelContextProtocol; using ModelContextProtocol.Client; using ModelContextProtocol.Extensions.Apps; using ModelContextProtocol.Extensions.Skills; using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; using ModelContextProtocol.Tests.Utils; +using System.Collections; +using System.Collections.ObjectModel; +using System.Dynamic; using System.IO.Pipelines; +using System.Text.Json; using System.Text.Json.Nodes; +using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; Pipe clientToServerPipe = new(), serverToClientPipe = new(); +const string FallbackUri = "skill://aot-fallback/SKILL.md"; -var authoredSkill = McpServerSkill.Create( - "skill://aot/SKILL.md", - [McpServerSkillFile.FromText("SKILL.md", "---\nname: aot\ndescription:\n An AOT-published skill.\nmetadata:\n note: >\n First\n \tCode\n Last\n---\n")]); var expectedMeta = JsonNode.Parse("""{"example.com/aot":{"values":["native",42,true]}}""")!.AsObject(); -var entry = authoredSkill.ProtocolSkill; -entry.Meta = (JsonObject)expectedMeta.DeepClone(); -var catalog = new InMemoryMcpSkillCatalog([entry]); -entry.Meta["example.com/aot"]!["values"]!.AsArray().Clear(); +var metadata = new List { "local-aot-metadata" }; +var createOptions = new McpServerSkillPackageCreateOptions +{ + Uri = "skill://aot/SKILL.md", + Metadata = metadata, + Meta = expectedMeta, +}; +var package = McpServerSkillPackage.Create( + [McpServerSkillFile.FromText("SKILL.md", "---\nname: aot\ndescription:\n An AOT-published skill.\nmetadata:\n note: >\n First\n \tCode\n Last\n---\n")], createOptions); +var primitive = package.Skill; var services = new ServiceCollection(); +var catalogTracker = new AotCatalogTracker(); +services.AddSingleton(catalogTracker); +services.AddScoped(); +JsonSerializerOptions structuredOptions = new(McpJsonUtilities.DefaultOptions) +{ + IgnoreReadOnlyProperties = true, + TypeInfoResolver = new AotCachingResolver(JsonTypeInfoResolver.Combine(AotJsonContext.Default, McpJsonUtilities.DefaultOptions.TypeInfoResolver) + .WithAddedModifier(typeInfo => + { + if (typeInfo.Type == typeof(AotBaseOutput)) + { + typeInfo.Properties.Single(property => property.Name == "flag").ShouldSerialize = static (_, _) => false; + } + })), +}; +McpServerTool structuredTool = McpServerTool.Create( + () => new AotStructuredOutput(), + new() + { + Name = "RequiredDefaults", + UseStructuredContent = true, + SerializerOptions = structuredOptions, + OutputSchema = JsonDocument.Parse(""" + { + "$id":"https://example.test/aot", + "type":"object", + "required":["id","note","value","items","genericItems","dynamicItems","unions","numbers","extension","contract","nullable","delegatingCount"], + "anyOf":[{"required":["count"]},{"required":["note"]}], + "$defs":{"child":{"$id":"child","type":"object","required":["count"],"properties":{"count":{"const":0}}}}, + "properties":{ + "id":{"type":"null"},"note":{"type":"null"},"value":{"const":"converted"}, + "items":{"type":"object","additionalProperties":{"$ref":"child"}}, + "genericItems":{"type":"object","additionalProperties":{"$ref":"child"}}, + "dynamicItems":{"type":"object","additionalProperties":{"$ref":"child"}}, + "numbers":{"required":["count"],"properties":{"count":{"const":"0"}}}, + "extension":{"properties":{"child/~":{"required":["note"]}}}, + "contract":{"oneOf":[{"required":["flag"]},{"required":["note"]}]}, + "nullable":{"required":["note"]}, + "unevaluated":{"allOf":[{"properties":{"evaluated\u0000path":{"required":["real"]}}}],"unevaluatedProperties":{"required":["note"]}}, + "delegatingCount":{"const":0}, + "unions":{"type":"array","allOf":[{"items":{"oneOf":[ + {"properties":{"tag":{"const":"0"},"child0":{"required":["note"]}},"required":["tag","child0"],"additionalProperties":false}, + {"properties":{"tag":{"const":"1"},"child1":{"required":["note"]}},"required":["tag","child1"],"additionalProperties":false} + ]}},{"items":{"not":{"required":["child1"]}}}]} + } + } + """).RootElement, + }); +JsonSerializerOptions preservedOptions = new(structuredOptions) { ReferenceHandler = ReferenceHandler.Preserve }; +McpServerTool preservedTool = McpServerTool.Create( + new AotPreservedFunction(preservedOptions), + new() + { + UseStructuredContent = true, + OutputSchema = JsonDocument.Parse(""" + { + "required":["entry/1~","entry/2~"], + "properties":{ + "entry/1~":{"required":["count"],"properties":{"count":{"type":"integer"}}}, + "entry/2~":{"required":["count"],"properties":{"count":{"type":"integer"}}} + } + } + """).RootElement, + }); +McpServerTool preservedNongenericTool = McpServerTool.Create( + new AotPreservedFunction(preservedOptions, nongeneric: true), + new() + { + UseStructuredContent = true, + OutputSchema = preservedTool.ProtocolTool.OutputSchema, + }); +McpServerTool ancestorTool = McpServerTool.Create( + new AotAncestorFunction(new(structuredOptions) + { + ReferenceHandler = ReferenceHandler.IgnoreCycles, + DefaultIgnoreCondition = JsonIgnoreCondition.Never, + }), + new() + { + UseStructuredContent = true, + OutputSchema = JsonDocument.Parse(""" + { + "type":"object", + "$defs":{"node":{"required":["missing"],"properties":{"missing":{ + "required":["owner"],"properties":{"owner":{"type":"null"}} + }}}}, + "properties":{ + "child":{"$ref":"#/$defs/node"}, + "items":{"items":{"$ref":"#/$defs/node"}}, + "entries":{"additionalProperties":{"$ref":"#/$defs/node"}} + } + } + """).RootElement, + }); +McpServerTool iteratorTool = McpServerTool.Create( + AotTools.Iterator, + new() + { + Name = "Iterator", + UseStructuredContent = true, + SerializerOptions = structuredOptions, + OutputSchema = JsonDocument.Parse("""{"type":"array","items":{"required":["count"]}}""").RootElement, + }); +McpServerTool genericListTool = McpServerTool.Create( + () => new AotGenericList(), + new() + { + Name = "GenericList", + UseStructuredContent = true, + SerializerOptions = structuredOptions, + OutputSchema = JsonDocument.Parse("""{"type":"array","items":{"required":["count"]}}""").RootElement, + }); +McpServerTool genericMutableListTool = McpServerTool.Create( + () => new AotGenericMutableList(), + new() + { + Name = "GenericMutableList", + UseStructuredContent = true, + SerializerOptions = structuredOptions, + OutputSchema = genericListTool.ProtocolTool.OutputSchema, + }); +JsonArray recoveryAlternatives = []; +for (int i = 0; i < 128; i++) +{ + recoveryAlternatives.Add((JsonNode)new JsonObject { ["required"] = new JsonArray($"missing{i}_" + new string('x', 512)) }); +} +recoveryAlternatives.Add((JsonNode)new JsonObject { ["required"] = new JsonArray("note") }); +McpServerTool boundedRecoveryTool = McpServerTool.Create( + () => new AotNullableChild(), + new() + { + Name = "BoundedRecovery", + UseStructuredContent = true, + SerializerOptions = structuredOptions, + OutputSchema = JsonDocument.Parse(new JsonObject { ["anyOf"] = recoveryAlternatives }.ToJsonString()).RootElement, + }); +McpServerTool annotationRecoveryTool = McpServerTool.Create( + () => new AotNullableChild { Real = "kept" }, + new() + { + Name = "AnnotationRecovery", + UseStructuredContent = true, + SerializerOptions = structuredOptions, + OutputSchema = JsonDocument.Parse("""{"anyOf":[true,{"required":["note"],"properties":{"real":true,"note":true}}],"unevaluatedProperties":false}""").RootElement, + }); +McpServerTool regexRecoveryTool = McpServerTool.Create( + () => new AotNullableChild { Real = new string('a', 26) + "!" }, + new() + { + Name = "RegexRecovery", + UseStructuredContent = true, + SerializerOptions = structuredOptions, + OutputSchema = JsonDocument.Parse("""{"anyOf":[{"properties":{"real":{"pattern":"^(a+)+$"}}},{"required":["note"]}]}""").RootElement, + }); +McpServerTool callbackRecoveryTool = McpServerTool.Create( + () => new AotCallbackOutput(), + new() + { + Name = "CallbackRecovery", + UseStructuredContent = true, + SerializerOptions = structuredOptions, + OutputSchema = JsonDocument.Parse("""{"required":["note"]}""").RootElement, + }); +List readOnlyRecoveryTools = []; +foreach (bool field in new[] { false, true }) +{ + JsonSerializerOptions readOnlyOptions = new(structuredOptions) + { + IgnoreReadOnlyProperties = field, + IgnoreReadOnlyFields = !field, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + TypeInfoResolver = JsonTypeInfoResolver.Combine(AotJsonContext.Default, McpJsonUtilities.DefaultOptions.TypeInfoResolver) + .WithAddedModifier(typeInfo => + { + foreach (JsonPropertyInfo property in typeInfo.Properties) + { + property.AttributeProvider = null; + } + }), + }; + readOnlyRecoveryTools.Add(McpServerTool.Create(() => new AotReadOnlyOutput(), new() + { + Name = field ? "ReadOnlyFieldRecovery" : "ReadOnlyPropertyRecovery", + UseStructuredContent = true, + SerializerOptions = readOnlyOptions, + OutputSchema = JsonDocument.Parse(field + ? """{"required":["fieldNote"],"not":{"required":["note"]}}""" + : """{"required":["note"],"not":{"required":["fieldNote"]}}""").RootElement, + })); +} +List forwardingRecoveryTools = []; +foreach (bool required in new[] { false, true }) +{ + foreach (bool rebindMatch in new[] { false, true }) + { + JsonElement requiredSchema = JsonDocument.Parse("""{"type":"object","required":["note"],"properties":{"note":{"type":"null"}}}""").RootElement; + JsonElement optionalSchema = JsonDocument.Parse("""{"type":"object","properties":{"note":{"type":"string"}}}""").RootElement; + McpServerTool inner = McpServerTool.Create(() => new AotNullableChild(), new() + { + Name = (required ? "ForwardingRequiredNote" : "ForwardingOptionalNote") + (rebindMatch ? "Rebound" : ""), + UseStructuredContent = true, + SerializerOptions = structuredOptions, + OutputSchema = required ? optionalSchema : requiredSchema, + }); + forwardingRecoveryTools.Add(new ForwardingSchemaAotTool(inner, required ? requiredSchema : optionalSchema, rebindMatch)); + if (!rebindMatch) + { + forwardingRecoveryTools.Add(new PayloadAotTool( + new ForwardingSchemaAotTool(inner, required ? requiredSchema : optionalSchema))); + } + } +} +var mutableNote = new AotMutableNote(); +McpServerTool mutableNoteTool = McpServerTool.Create(() => mutableNote, new() +{ + Name = "ForwardingMutableNote", + UseStructuredContent = true, + SerializerOptions = structuredOptions, + OutputSchema = JsonDocument.Parse("""{"type":"object","required":["note"],"properties":{"note":{"type":"null"}}}""").RootElement, +}); +forwardingRecoveryTools.Add(new ForwardingSchemaAotTool( + mutableNoteTool, JsonDocument.Parse(mutableNoteTool.ProtocolTool.OutputSchema!.Value.GetRawText()).RootElement, + afterInvocation: () => mutableNote.Note = "changed after serialization")); +var mutableCount = new AotMutableCount(); +McpServerTool mutableCountTool = McpServerTool.Create(() => mutableCount, new() +{ + Name = "ForwardingMutableCount", + UseStructuredContent = true, + SerializerOptions = structuredOptions, + OutputSchema = JsonDocument.Parse("""{"type":"object"}""").RootElement, +}); +forwardingRecoveryTools.Add(new ForwardingSchemaAotTool( + mutableCountTool, + JsonDocument.Parse("""{"type":"object","required":["count"],"properties":{"count":{"type":"integer"}}}""").RootElement, + afterInvocation: () => mutableCount.Count = 42)); +var firstDispatchEntered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); +var releaseFirstDispatch = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); +int dispatchCount = 0; +McpServerTool overlappingTool = McpServerTool.Create(async (CancellationToken cancellationToken) => +{ + if (Interlocked.Increment(ref dispatchCount) == 1) + { + firstDispatchEntered.TrySetResult(true); + await releaseFirstDispatch.Task.WaitAsync(cancellationToken); + } + return new AotNullableChild(); +}, new() +{ + Name = "OverlappingForwardingNote", + UseStructuredContent = true, + SerializerOptions = structuredOptions, + OutputSchema = JsonDocument.Parse("""{"type":"object"}""").RootElement, +}); +forwardingRecoveryTools.Add(new ForwardingSchemaAotTool( + overlappingTool, + JsonDocument.Parse("""{"type":"object","required":["note"],"properties":{"note":{"type":"null"}}}""").RootElement)); +services.Configure(options => +{ + options.Filters.Request.CallToolFilters.Add(next => async (request, cancellationToken) => + { + if (request.Params?.Name != "OverlappingForwardingNote") + { + return await next(request, cancellationToken); + } + var first = next(request, cancellationToken).AsTask(); + await firstDispatchEntered.Task.WaitAsync(cancellationToken); + try { await next(request, cancellationToken); } + finally { releaseFirstDispatch.TrySetResult(true); } + return await first; + }); +}); services.AddMcpServer() .WithStreamServerTransport(clientToServerPipe.Reader.AsStream(), serverToClientPipe.Writer.AsStream()) .WithTools() + .WithTools([new ForwardingAotTool(structuredTool), preservedTool, preservedNongenericTool, ancestorTool, iteratorTool, genericListTool, genericMutableListTool, boundedRecoveryTool, annotationRecoveryTool, regexRecoveryTool, callbackRecoveryTool]) + .WithTools(readOnlyRecoveryTools) + .WithTools(forwardingRecoveryTools) .WithTools( [ new ForwardingAotTool(McpServerTool.Create(AotTools.Echo, new() { Name = "ForwardingEcho", UseStructuredContent = true })), new OverridingAotTool(McpServerTool.Create(AotTools.Echo, new() { Name = "OverridingEcho", UseStructuredContent = true })), ]) .WithMcpApps() - .WithSkills(catalog) - .WithResources(authoredSkill.Resources); + .WithSkillPackages([package]).WithSkills(options => + { + options.ListSkillsHandler = (_, _) => new(new ListSkillsResult()); + options.GetSkillHandler = (context, _) => + { + if (context.MatchedPrimitive is not null || context.Params.Uri != FallbackUri) + { + throw new Exception("Unexpected custom get fallback context."); + } + + var fallback = new Skill + { + Uri = FallbackUri, + Frontmatter = new JsonObject { ["name"] = "aot-fallback", ["description"] = "An AOT fallback skill." }, + Resources = SkillResources.Dynamic, + Meta = expectedMeta, + }; + return new(new GetSkillResult { Skill = fallback, TimeToLive = TimeSpan.FromMinutes(1), CacheScope = CacheScope.Public }); + }; + + options.GetSkillFilters.Add(next => (context, cancellationToken) => + { + _ = context.Services!.GetRequiredService(); + if (context.MatchedPrimitive?.Id == primitive.Id && + (!ReferenceEquals(context.MatchedPrimitive, primitive) || primitive.Metadata is not ["local-aot-metadata"])) + { + throw new Exception("The get filter did not receive the matched primitive metadata."); + } + + return next(context, cancellationToken); + }); + }); await using var serviceProvider = services.BuildServiceProvider(); var server = serviceProvider.GetRequiredService(); @@ -60,6 +376,48 @@ throw new Exception($"Unexpected result: {result}"); } +var structuredResult = await client.CallToolAsync("RequiredDefaults"); +if (structuredResult.StructuredContent is not { } structured || + structured.GetProperty("id").ValueKind != JsonValueKind.Null || + structured.GetProperty("note").ValueKind != JsonValueKind.Null || + structured.GetProperty("value").GetString() != "converted" || + structured.GetProperty("items").GetProperty("42").GetProperty("count").GetInt32() != 0 || + structured.GetProperty("genericItems").GetProperty("42").GetProperty("count").GetInt32() != 0 || + structured.GetProperty("dynamicItems").GetProperty("entry").GetProperty("count").GetInt32() != 0 || + structured.GetProperty("numbers").GetProperty("count").GetString() != "0" || + structured.GetProperty("extension").GetProperty("child/~").GetProperty("note").ValueKind != JsonValueKind.Null || + structured.GetProperty("contract").GetProperty("note").ValueKind != JsonValueKind.Null || + structured.GetProperty("contract").TryGetProperty("flag", out _) || + structured.GetProperty("nullable").GetProperty("note").ValueKind != JsonValueKind.Null || + structured.GetProperty("unevaluated").GetProperty("evaluated\0path").GetProperty("real").ValueKind != JsonValueKind.Null || + structured.GetProperty("unevaluated").GetProperty("evaluated\0path").TryGetProperty("note", out _) || + structured.GetProperty("unevaluated").GetProperty("other").GetProperty("note").ValueKind != JsonValueKind.Null || + structured.GetProperty("delegatingCount").GetInt32() != 0 || + structured.TryGetProperty("count", out _)) +{ + throw new Exception($"Unexpected structured output: {structuredResult.StructuredContent}"); +} + +foreach (JsonElement item in structured.GetProperty("unions").EnumerateArray()) +{ + if (item.GetProperty("child0").GetProperty("note").ValueKind != JsonValueKind.Null || + item.TryGetProperty("child1", out _)) + { + throw new Exception($"Unexpected union output: {item}"); + } +} + +foreach (string name in new[] { "PreservedDictionary", "PreservedNongenericDictionary" }) +{ + var preservedResult = await client.CallToolAsync(name); + if (preservedResult.StructuredContent is not { } preserved || + preserved.GetProperty("entry/1~").GetProperty("count").GetInt32() != 0 || + preserved.GetProperty("entry/2~").GetProperty("count").GetInt32() != 5) + { + throw new Exception($"Unexpected preserved dictionary output: {preservedResult.StructuredContent}"); + } +} + foreach (string name in new[] { "ForwardingEcho", "OverridingEcho" }) { var delegatedResult = await client.CallToolAsync(name, new Dictionary { ["arg"] = "Hello World" }); @@ -69,6 +427,130 @@ } } +var iteratorResult = await client.CallToolAsync("Iterator"); +if (iteratorResult.StructuredContent is not { } iterator || + iterator.ValueKind != JsonValueKind.Array || + iterator.GetArrayLength() != 1 || + iterator[0].ValueKind != JsonValueKind.Object || + iterator[0].TryGetProperty("count", out _)) +{ + throw new Exception($"Unexpected iterator output: {iteratorResult.StructuredContent}"); +} + +foreach (string name in new[] { "GenericList", "GenericMutableList" }) +{ + var genericListResult = await client.CallToolAsync(name); + if (genericListResult.StructuredContent is not { } genericList || + genericList[0].GetProperty("count").GetInt32() != 0) + { + throw new Exception($"Unexpected generic list output: {genericListResult.StructuredContent}"); + } +} + +var boundedRecoveryResult = await client.CallToolAsync("BoundedRecovery"); +if (boundedRecoveryResult.StructuredContent is not { } boundedRecovery || + boundedRecovery.GetRawText() != "{}") +{ + throw new Exception($"Unexpected bounded recovery output: {boundedRecoveryResult.StructuredContent}"); +} + +var annotationRecoveryResult = await client.CallToolAsync("AnnotationRecovery"); +if (annotationRecoveryResult.StructuredContent is not { } annotationRecovery || + annotationRecovery.GetProperty("note").ValueKind != JsonValueKind.Null || + annotationRecovery.GetProperty("real").GetString() != "kept") +{ + throw new Exception($"Unexpected annotation recovery output: {annotationRecoveryResult.StructuredContent}"); +} + +var regexRecoveryResult = await client.CallToolAsync("RegexRecovery"); +if (regexRecoveryResult.StructuredContent is not { } regexRecovery || + regexRecovery.TryGetProperty("note", out _) || + regexRecovery.GetProperty("real").GetString() != new string('a', 26) + "!") +{ + throw new Exception($"Unexpected regex recovery output: {regexRecoveryResult.StructuredContent}"); +} + +foreach (bool field in new[] { false, true }) +{ + var readOnlyResult = await client.CallToolAsync(field ? "ReadOnlyFieldRecovery" : "ReadOnlyPropertyRecovery"); + if (readOnlyResult.StructuredContent is not { } readOnly || + !readOnly.TryGetProperty(field ? "fieldNote" : "note", out JsonElement note) || + note.ValueKind != JsonValueKind.Null || + readOnly.TryGetProperty(field ? "note" : "fieldNote", out _)) + { + throw new Exception($"Unexpected readonly recovery output: {readOnlyResult.StructuredContent}"); + } +} + +var callbackRecoveryResult = await client.CallToolAsync("CallbackRecovery"); +if (callbackRecoveryResult.IsError == true || + callbackRecoveryResult.StructuredContent?.GetRawText() != """{"kept":"kept"}""") +{ + throw new Exception($"Unexpected callback recovery output: {callbackRecoveryResult.StructuredContent}"); +} + +foreach (bool required in new[] { false, true }) +{ + foreach (bool rebindMatch in new[] { false, true }) + { + var forwardingResult = await client.CallToolAsync( + (required ? "ForwardingRequiredNote" : "ForwardingOptionalNote") + (rebindMatch ? "Rebound" : "")); + if (forwardingResult.IsError == true || + forwardingResult.StructuredContent is not { } forwarded || + forwarded.TryGetProperty("note", out JsonElement note) != required || + (required && note.ValueKind != JsonValueKind.Null)) + { + throw new Exception($"Unexpected forwarding recovery output: {forwardingResult.StructuredContent}"); + } + } + var payloadResult = await client.CallToolAsync(required ? "PayloadForwardingRequiredNote" : "PayloadForwardingOptionalNote"); + if (payloadResult.IsError == true || + payloadResult.StructuredContent is not { } payload || + payload.GetProperty("payload").TryGetProperty("note", out JsonElement payloadNote) != required || + (required && payloadNote.ValueKind != JsonValueKind.Null)) + { + throw new Exception($"Unexpected stacked forwarding recovery output: {payloadResult.StructuredContent}"); + } +} +var mutableNoteResult = await client.CallToolAsync("ForwardingMutableNote"); +if (mutableNoteResult.IsError == true || + mutableNoteResult.StructuredContent is not { } mutableNoteContent || + !mutableNoteContent.TryGetProperty("note", out JsonElement mutableNoteValue) || + mutableNoteValue.ValueKind != JsonValueKind.Null || + mutableNote.Note != "changed after serialization") +{ + throw new Exception($"Unexpected mutable forwarding recovery output: {mutableNoteResult.StructuredContent}"); +} +var mutableCountResult = await client.CallToolAsync("ForwardingMutableCount"); +if (mutableCountResult.IsError == true || + mutableCountResult.StructuredContent is not { } mutableCountContent || + !mutableCountContent.TryGetProperty("count", out JsonElement mutableCountValue) || + mutableCountValue.GetInt32() != 0 || + mutableCount.Count != 42) +{ + throw new Exception($"Unexpected captured forwarding recovery output: {mutableCountResult.StructuredContent}"); +} + +var overlappingResult = await client.CallToolAsync("OverlappingForwardingNote"); +if (dispatchCount != 4 || + overlappingResult.IsError == true || + overlappingResult.StructuredContent?.GetProperty("note").ValueKind != JsonValueKind.Null) +{ + throw new Exception($"Unexpected overlapping dispatch recovery output: {overlappingResult.StructuredContent}"); +} + +var ancestorResult = await client.CallToolAsync("AncestorCycles"); +JsonElement ancestor = ancestorResult.StructuredContent!.Value; +foreach (JsonElement node in ancestor.GetProperty("items").EnumerateArray() + .Concat(ancestor.GetProperty("entries").EnumerateObject().Select(property => property.Value)) + .Prepend(ancestor.GetProperty("child"))) +{ + if (node.GetProperty("missing").GetProperty("owner").ValueKind != JsonValueKind.Null) + { + throw new Exception($"Unexpected ancestor cycle output: {ancestor}"); + } +} + // List, get, and verify a skill. var skills = await client.ListSkillsAsync(); if (skills.Count != 1 || skills[0].Name != "aot") @@ -109,16 +591,409 @@ Console.WriteLine("Success!"); +internal sealed class AotCatalogTracker +{ + public int Created; + public int Disposed; +} + +internal sealed class AotCatalogDependency : IAsyncDisposable +{ + private readonly AotCatalogTracker _tracker; + + public AotCatalogDependency(AotCatalogTracker tracker) + { + _tracker = tracker; + Interlocked.Increment(ref tracker.Created); + } + + public ValueTask DisposeAsync() + { + Interlocked.Increment(ref _tracker.Disposed); + return ValueTask.CompletedTask; + } +} + [McpServerToolType] internal sealed class AotTools { + internal static IEnumerable Iterator() + { + yield return default; + } + [McpServerTool(Name = "Echo", UseStructuredContent = true)] [McpAppUi(ResourceUri = "ui://aot/echo")] public static string Echo(string arg) => $"Echo: {arg}"; } +internal sealed class AotStructuredOutput +{ + public int Count => throw new InvalidOperationException("Excluded getter was invoked."); + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + public RequestId Id { get; init; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Note { get; init; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + [JsonConverter(typeof(AotElementConverter))] + public JsonElement Value { get; init; } + + public ReadOnlyDictionary Items { get; init; } = + new(new Dictionary { [42] = default }); + + public AotGenericOnlyDictionary GenericItems { get; init; } = + new(new Dictionary { [42] = default }); + + public ExpandoObject DynamicItems { get; init; } = CreateDynamicItems(); + + public AotUnionOutput[] Unions { get; init; } = Enumerable.Range(0, 5).Select(_ => new AotUnionOutput()).ToArray(); + + public AotNumberOutput Numbers { get; init; } = new(); + public AotExtensionOutput Extension { get; init; } = new(); + public AotBaseOutput Contract { get; init; } = new AotDerivedOutput(); + public AotNullableChild? Nullable { get; init; } = new AotNullableChild(); + public AotUnevaluatedOutput Unevaluated { get; init; } = new(); + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + [JsonConverter(typeof(AotDelegatingIntConverter))] + public int DelegatingCount { get; init; } + + [JsonConverter(typeof(AotRejectNullConverter))] + public string? Optional { get; init; } + + private static ExpandoObject CreateDynamicItems() + { + ExpandoObject items = new(); + ((IDictionary)items)["entry"] = default(AotStructuredChild); + return items; + } +} + +internal sealed class AotGenericList : IReadOnlyList +{ + public int Count => 1; + public AotStructuredChild this[int index] => index == 0 ? default : throw new ArgumentOutOfRangeException(nameof(index)); + public IEnumerator GetEnumerator() => ((IEnumerable)new AotStructuredChild[1]).GetEnumerator(); + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); +} + +internal sealed class AotGenericMutableList : IList +{ + private readonly List _items = [default]; + public AotStructuredChild this[int index] { get => _items[index]; set => throw new NotSupportedException(); } + public int Count => _items.Count; + public bool IsReadOnly => true; + public IEnumerator GetEnumerator() => _items.GetEnumerator(); + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + public int IndexOf(AotStructuredChild item) => _items.IndexOf(item); + public bool Contains(AotStructuredChild item) => _items.Contains(item); + public void CopyTo(AotStructuredChild[] array, int arrayIndex) => _items.CopyTo(array, arrayIndex); + public void Add(AotStructuredChild item) => throw new NotSupportedException(); + public void Clear() => throw new NotSupportedException(); + public void Insert(int index, AotStructuredChild item) => throw new NotSupportedException(); + public bool Remove(AotStructuredChild item) => throw new NotSupportedException(); + public void RemoveAt(int index) => throw new NotSupportedException(); +} + +[JsonNumberHandling(JsonNumberHandling.WriteAsString)] +internal sealed class AotNumberOutput +{ + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + public int Count { get; init; } +} + +internal sealed class AotExtensionOutput +{ + [JsonExtensionData] + public Dictionary Data { get; set; } = new() { ["child/~"] = new AotNullableChild() }; +} + +internal class AotBaseOutput +{ + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + public bool Flag { get; init; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Note { get; init; } +} + +internal sealed class AotDerivedOutput : AotBaseOutput; + +internal sealed class AotRejectNullConverter : JsonConverter +{ + public override bool HandleNull => true; + public override string Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => + throw new NotSupportedException(); + public override void Write(Utf8JsonWriter writer, string value, JsonSerializerOptions options) => + throw new ArgumentNullException(nameof(value)); +} + +internal sealed class AotUnionOutput +{ + public string Tag { get; init; } = "0"; + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + public AotNullableChild Child0 { get; init; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + public AotNullableChild Child1 { get; init; } +} + +internal struct AotNullableChild +{ + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Note { get; init; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Real { get; init; } +} + +internal sealed class AotMutableNote +{ + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Note { get; set; } +} + +internal sealed class AotMutableCount +{ + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + public int Count { get; set; } +} + +internal sealed class AotUnevaluatedOutput +{ + [JsonPropertyName("evaluated\0path")] + public AotNullableChild Evaluated { get; init; } + public AotNullableChild Other { get; init; } +} + +internal sealed class AotGenericOnlyDictionary(IReadOnlyDictionary entries) : IReadOnlyDictionary +{ + public TValue this[TKey key] => entries[key]; + public IEnumerable Keys => entries.Keys; + public IEnumerable Values => entries.Values; + public int Count => entries.Count; + public bool ContainsKey(TKey key) => entries.ContainsKey(key); + public bool TryGetValue(TKey key, out TValue value) => entries.TryGetValue(key, out value!); + public IEnumerator> GetEnumerator() => entries.GetEnumerator(); + System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => GetEnumerator(); +} + +internal struct AotStructuredChild +{ + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + public int Count { get; init; } +} + +internal sealed class AotElementConverter : JsonConverter +{ + public override JsonElement Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => + throw new NotSupportedException(); + + public override void Write(Utf8JsonWriter writer, JsonElement value, JsonSerializerOptions options) => + writer.WriteStringValue("converted"); +} + +internal sealed class AotDelegatingIntConverter : JsonConverter +{ + private bool _writing; + public override int Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => + throw new NotSupportedException(); + public override void Write(Utf8JsonWriter writer, int value, JsonSerializerOptions options) + { + if (_writing) + { + throw new InvalidOperationException("Recursive member converter invocation."); + } + + _writing = true; + try + { + JsonSerializer.Serialize(writer, value, options.GetTypeInfo(typeof(int))); + } + finally + { + _writing = false; + } + } +} + +[JsonConverter(typeof(AotDictionaryKeyConverter))] +internal readonly record struct AotDictionaryKey(int Value); + +internal sealed class AotDictionaryKeyConverter : JsonConverter +{ + public override AotDictionaryKey Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => + throw new NotSupportedException(); + public override void Write(Utf8JsonWriter writer, AotDictionaryKey value, JsonSerializerOptions options) => + writer.WriteNumberValue(value.Value); + public override void WriteAsPropertyName(Utf8JsonWriter writer, AotDictionaryKey value, JsonSerializerOptions options) => + writer.WritePropertyName($"entry/{value.Value}~"); +} + +internal sealed class AotPreservedFunction(JsonSerializerOptions options, bool nongeneric = false) : AIFunction +{ + private readonly object _value = nongeneric + ? new Hashtable + { + [new AotDictionaryKey(1)] = default(AotStructuredChild), + [new AotDictionaryKey(2)] = new AotStructuredChild { Count = 5 }, + } + : new Dictionary + { + [new(1)] = default, + [new(2)] = new() { Count = 5 }, + }; + public override string Name => nongeneric ? "PreservedNongenericDictionary" : "PreservedDictionary"; + public override JsonElement JsonSchema { get; } = JsonDocument.Parse("""{"type":"object"}""").RootElement; + public override JsonSerializerOptions JsonSerializerOptions => options; + protected override ValueTask InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) => + new(_value); +} + +internal sealed class AotAncestorRoot +{ + public AotAncestorOwner Child { get; set; } = new(); + public AotAncestorOwner[] Items { get; set; } = [new(), new()]; + public Dictionary Entries { get; set; } = new() + { + ["root"] = new(), + ["container"] = new(), + }; +} + +internal sealed class AotAncestorOwner +{ + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + public AotAncestorValue Missing { get; set; } +} + +internal struct AotAncestorValue : IEquatable +{ + public object? Owner { get; set; } + public bool Equals(AotAncestorValue other) => true; + public override bool Equals(object? obj) => obj is AotAncestorValue; + public override int GetHashCode() => 0; +} + +internal sealed class AotAncestorFunction(JsonSerializerOptions options) : AIFunction +{ + private readonly AotAncestorRoot _value = CreateValue(); + public override string Name => "AncestorCycles"; + public override JsonElement JsonSchema { get; } = JsonDocument.Parse("""{"type":"object"}""").RootElement; + public override JsonSerializerOptions JsonSerializerOptions => options; + protected override ValueTask InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) => + new(_value); + + private static AotAncestorRoot CreateValue() + { + AotAncestorRoot root = new(); + root.Child.Missing = new() { Owner = root }; + root.Items[0].Missing = new() { Owner = root }; + root.Items[1].Missing = new() { Owner = root.Items }; + root.Entries["root"].Missing = new() { Owner = root }; + root.Entries["container"].Missing = new() { Owner = root.Entries }; + return root; + } +} + +internal sealed class AotCachingResolver(IJsonTypeInfoResolver inner) : IJsonTypeInfoResolver +{ + private readonly System.Collections.Concurrent.ConcurrentDictionary<(Type, JsonSerializerOptions), JsonTypeInfo> _contracts = new(); + + public JsonTypeInfo? GetTypeInfo(Type type, JsonSerializerOptions options) => + _contracts.GetOrAdd((type, options), key => inner.GetTypeInfo(key.Item1, key.Item2)!); +} + +internal sealed class AotReadOnlyOutput +{ + public string? Note => null; + [JsonInclude] + public readonly string? FieldNote = null; +} + +internal sealed class AotCallbackOutput : IJsonOnSerializing, IJsonOnSerialized +{ + private bool _serializing; + public string Kept { get; set; } = "kept"; + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Note => _serializing ? null : throw new InvalidOperationException("Getter requires callback state."); + public void OnSerializing() => _serializing = true; + public void OnSerialized() => _serializing = false; +} + +[JsonSerializable(typeof(AotStructuredOutput))] +[JsonSerializable(typeof(AotDerivedOutput))] +[JsonSerializable(typeof(Dictionary))] +[JsonSerializable(typeof(Hashtable))] +[JsonSerializable(typeof(AotAncestorRoot))] +[JsonSerializable(typeof(IEnumerable))] +[JsonSerializable(typeof(AotGenericList))] +[JsonSerializable(typeof(AotGenericMutableList))] +[JsonSerializable(typeof(AotReadOnlyOutput))] +[JsonSerializable(typeof(AotCallbackOutput))] +[JsonSerializable(typeof(AotMutableNote))] +[JsonSerializable(typeof(AotMutableCount))] +internal partial class AotJsonContext : JsonSerializerContext; + internal class ForwardingAotTool(McpServerTool inner) : DelegatingMcpServerTool(inner); +internal sealed class PayloadAotTool(McpServerTool inner) : DelegatingMcpServerTool(inner) +{ + public override Tool ProtocolTool { get; } = new() + { + Name = "Payload" + inner.ProtocolTool.Name, + InputSchema = inner.ProtocolTool.InputSchema, + OutputSchema = JsonDocument.Parse($$$$"""{"type":"object","required":["payload"],"properties":{"payload":{{{{inner.ProtocolTool.OutputSchema!.Value.GetRawText()}}}}}}""").RootElement, + }; + + public override async ValueTask InvokeAsync( + RequestContext request, CancellationToken cancellationToken = default) + { + CallToolResult result = await base.InvokeAsync(request, cancellationToken); + result.StructuredContent = JsonDocument.Parse($$"""{"payload":{{result.StructuredContent!.Value.GetRawText()}}}""").RootElement; + return result; + } +} + +internal sealed class ForwardingSchemaAotTool( + McpServerTool inner, JsonElement schema, bool rebindMatch = false, Action? afterInvocation = null) + : DelegatingMcpServerTool(inner) +{ + private readonly McpServerTool _inner = inner; + + public override Tool ProtocolTool { get; } = new() + { + Name = inner.ProtocolTool.Name, + InputSchema = inner.ProtocolTool.InputSchema, + OutputSchema = schema, + }; + + public override async ValueTask InvokeAsync( + RequestContext request, CancellationToken cancellationToken = default) + { + IMcpServerPrimitive? original = request.MatchedPrimitive; + if (rebindMatch) + { + request.MatchedPrimitive = _inner; + } + try + { + CallToolResult result = await base.InvokeAsync(request, cancellationToken); + await base.InvokeAsync(request, cancellationToken); + afterInvocation?.Invoke(); + return result; + } + finally + { + request.MatchedPrimitive = original; + } + } +} + internal sealed class OverridingAotTool(McpServerTool inner) : ForwardingAotTool(inner) { public override ValueTask InvokeAsync( diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/ModelContextProtocol.AspNetCore.Tests.csproj b/tests/ModelContextProtocol.AspNetCore.Tests/ModelContextProtocol.AspNetCore.Tests.csproj index 781aa7178..1abff02a8 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/ModelContextProtocol.AspNetCore.Tests.csproj +++ b/tests/ModelContextProtocol.AspNetCore.Tests/ModelContextProtocol.AspNetCore.Tests.csproj @@ -8,7 +8,7 @@ true ModelContextProtocol.AspNetCore.Tests - $(NoWarn);MCP9006 + $(NoWarn);MCP9006;MCPEXP005 diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/SkillsAuthorizationTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/SkillsAuthorizationTests.cs index ccf9a5c36..cdad42864 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/SkillsAuthorizationTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/SkillsAuthorizationTests.cs @@ -51,9 +51,13 @@ public async Task PaginationDoesNotDiscloseDeniedEntriesThroughCursors(bool allo var resources = await client.ListResourcesAsync(cancellationToken: TestContext.Current.CancellationToken); Assert.Equal(seen, resources.Select(resource => resource.Uri).OrderBy(uri => uri, StringComparer.Ordinal)); await Assert.ThrowsAsync( - async () => await client.GetSkillAsync(HiddenSkillUri, TestContext.Current.CancellationToken)); - await Assert.ThrowsAsync( - async () => await client.ReadResourceAsync(HiddenSkillUri, cancellationToken: TestContext.Current.CancellationToken)); + async () => await client.GetSkillAsync(HiddenSkillUri, cancellationToken: TestContext.Current.CancellationToken)); + foreach (var uri in new[] { HiddenSkillUri, "skill://private/secret%2Dpayroll/SKILL.md", HiddenSkillUri + "#x" }) + { + var error = await Assert.ThrowsAsync( + async () => await client.ReadResourceAsync(uri, cancellationToken: TestContext.Current.CancellationToken)); + Assert.Equal(McpErrorCode.InvalidRequest, error.ErrorCode); + } } [Theory] @@ -67,14 +71,14 @@ public async Task SharedPolicyGuardsCatalogAndResourcePaths(bool allowed) await using var client = await TestMcpClient.CreateAsync( transport, cancellationToken: TestContext.Current.CancellationToken); - var skills = await client.ListSkillsAsync(TestContext.Current.CancellationToken); + var skills = await client.ListSkillsAsync(cancellationToken: TestContext.Current.CancellationToken); var resources = await client.ListResourcesAsync(cancellationToken: TestContext.Current.CancellationToken); if (allowed) { Assert.Equal(SkillUri, Assert.Single(skills).Uri); Assert.Equal(SkillUri, Assert.Single(resources).Uri); - var skill = await client.GetSkillAsync(SkillUri, TestContext.Current.CancellationToken); - var content = await client.ReadSkillResourceAsync(skill, SkillUri, TestContext.Current.CancellationToken); + var skill = await client.GetSkillAsync(SkillUri, cancellationToken: TestContext.Current.CancellationToken); + var content = await client.ReadSkillResourceAsync(skill, SkillUri, cancellationToken: TestContext.Current.CancellationToken); Assert.Single(content.Contents); } else @@ -82,7 +86,7 @@ public async Task SharedPolicyGuardsCatalogAndResourcePaths(bool allowed) Assert.Empty(skills); Assert.Empty(resources); var metadataError = await Assert.ThrowsAsync( - async () => await client.GetSkillAsync(SkillUri, TestContext.Current.CancellationToken)); + async () => await client.GetSkillAsync(SkillUri, cancellationToken: TestContext.Current.CancellationToken)); Assert.Equal(McpErrorCode.InvalidParams, metadataError.ErrorCode); var resourceError = await Assert.ThrowsAsync( async () => await client.ReadResourceAsync(SkillUri, cancellationToken: TestContext.Current.CancellationToken)); @@ -100,9 +104,9 @@ public async Task ResourceListingOmissionDoesNotOverrideAnAuthorizedDirectRead() transport, cancellationToken: TestContext.Current.CancellationToken); Assert.Empty(await client.ListResourcesAsync(cancellationToken: TestContext.Current.CancellationToken)); - var skill = await client.GetSkillAsync(SkillUri, TestContext.Current.CancellationToken); + var skill = await client.GetSkillAsync(SkillUri, cancellationToken: TestContext.Current.CancellationToken); Assert.Equal(SkillUri, skill.Uri); - Assert.Single((await client.ReadSkillResourceAsync(skill, SkillUri, TestContext.Current.CancellationToken)).Contents); + Assert.Single((await client.ReadSkillResourceAsync(skill, SkillUri, cancellationToken: TestContext.Current.CancellationToken)).Contents); } [Fact] @@ -114,23 +118,151 @@ public async Task UnauthenticatedRoleClaimsDoNotAuthorizeSkillAccess() await using var client = await TestMcpClient.CreateAsync( transport, cancellationToken: TestContext.Current.CancellationToken); - Assert.Empty(await client.ListSkillsAsync(TestContext.Current.CancellationToken)); + Assert.Empty(await client.ListSkillsAsync(cancellationToken: TestContext.Current.CancellationToken)); await Assert.ThrowsAsync( - async () => await client.GetSkillAsync(SkillUri, TestContext.Current.CancellationToken)); + async () => await client.GetSkillAsync(SkillUri, cancellationToken: TestContext.Current.CancellationToken)); + } + + [Theory] + [InlineData(false, "2026-07-28")] + [InlineData(true, "2026-07-28")] + [InlineData(false, "2025-11-25")] + [InlineData(true, "2025-11-25")] + public async Task ManualSkillsFiltersProtectRegisteredAndCustomEntries_AndFilesAreProtectedSeparately(bool allowed, string version) + { + var registered = CreateSkill("report"); + var application = CreateSkill("application"); + var policy = new ReadPolicy(); + var handlers = new AuthorizedHandlers([application.Skill], policy, pageSize: 1); + Builder.Services.AddSingleton(policy); + Builder.Services.AddMcpServer().WithHttpTransport(options => options.Stateless = true) + .WithSkillPackages([registered]).WithSkills(options => + { + options.ListSkillsHandler = handlers.ListAsync; + options.GetSkillHandler = handlers.GetAsync; + options.ListSkillsFilters.Add(next => async (context, cancellationToken) => + { + var result = await next(context, cancellationToken); + var requestPolicy = context.Services!.GetRequiredService(); + result.Skills = result.Skills.Where(skill => requestPolicy.CanRead(context.User, skill.Uri)).ToList(); + result.TimeToLive = TimeSpan.Zero; + result.CacheScope = CacheScope.Private; + return result; + }); + + options.GetSkillFilters.Add(next => (context, cancellationToken) => + { + var uri = context.MatchedPrimitive is McpServerSkill matched ? matched.Id : context.Params.Uri; + if (!context.Services!.GetRequiredService().CanRead(context.User, uri)) + { + throw new McpProtocolException("Access forbidden.", McpErrorCode.InvalidParams); + } + + return next(context, cancellationToken); + }); + }) + .WithResources(application.Resources) + .WithRequestFilters(filters => + { + filters.AddListResourcesFilter(next => async (context, cancellationToken) => + { + var result = await next(context, cancellationToken); + result.Resources = result.Resources.Where(resource => policy.CanRead(context.User, resource.Uri)).ToList(); + return result; + }); + filters.AddReadResourceFilter(next => (context, cancellationToken) => + { + if (!policy.CanRead(context.User, context.MatchedPrimitive?.Id)) + { + throw new McpProtocolException("Access forbidden.", McpErrorCode.InvalidRequest); + } + + return next(context, cancellationToken); + }); + }); + await using var app = Builder.Build(); + app.Use(async (context, next) => + { + context.User = new ClaimsPrincipal(new ClaimsIdentity( + [new Claim(ClaimTypes.Role, allowed ? "reader" : "viewer")], "Test")); + await next(context); + }); + + app.MapMcp(); + await app.StartAsync(TestContext.Current.CancellationToken); + await using var transport = new HttpClientTransport( + new() { Endpoint = new Uri("http://localhost:5000/") }, HttpClient, LoggerFactory); + var clientOptions = TestMcpClient.CreateOptions(); + clientOptions.ProtocolVersion = version; + await using var client = await TestMcpClient.CreateAsync( + transport, clientOptions, LoggerFactory, TestContext.Current.CancellationToken); + + var page = await client.ListSkillsAsync(new ListSkillsRequestParams(), TestContext.Current.CancellationToken); + Assert.Equal(allowed ? [application.Skill.Id, registered.Skill.Id] : [], page.Skills.Select(skill => skill.Uri)); + var resources = await client.ListResourcesAsync(cancellationToken: TestContext.Current.CancellationToken); + Assert.Equal(allowed ? [application.Skill.Id, registered.Skill.Id] : [], + resources.Select(resource => resource.Uri).OrderBy(uri => uri, StringComparer.Ordinal)); + Assert.Null(page.NextCursor); + if (version == McpProtocolVersions.July2026ProtocolVersion) + { + Assert.Equal(TimeSpan.Zero, page.TimeToLive); + Assert.Equal(CacheScope.Private, page.CacheScope); + } + + foreach (var uri in new[] { "SKILL://PRIVATE/report/SKILL.md", application.Skill.Id }) + { + if (allowed) + { + var skill = await client.GetSkillAsync(uri, cancellationToken: TestContext.Current.CancellationToken); + Assert.Single((await client.ReadSkillResourceAsync( + skill, skill.Uri, cancellationToken: TestContext.Current.CancellationToken)).Contents); + } + else + { + var error = await Assert.ThrowsAsync( + async () => await client.GetSkillAsync(uri, cancellationToken: TestContext.Current.CancellationToken)); + Assert.Equal(McpErrorCode.InvalidParams, error.ErrorCode); + } + } + + foreach (var uri in new[] + { + registered.Skill.Id, + "SKILL://PRIVATE/report/SKILL.md", + "skill://private/%72eport/SKILL.md", + registered.Skill.Id + "#x", + application.Skill.Id, + }) + { + if (allowed) + { + Assert.Single((await client.ReadResourceAsync(uri, cancellationToken: TestContext.Current.CancellationToken)).Contents); + } + else + { + var error = await Assert.ThrowsAsync( + async () => await client.ReadResourceAsync(uri, cancellationToken: TestContext.Current.CancellationToken)); + Assert.Equal(McpErrorCode.InvalidRequest, error.ErrorCode); + } + } } private async Task StartServer( bool allowed, bool hideResourceListing = false, bool authenticated = true, bool paginated = false) { - var skills = paginated + var packages = paginated ? new[] { CreateSkill("secret-payroll"), CreateSkill("zebra"), CreateSkill("zulu") } : new[] { CreateSkill("report") }; var policy = new ReadPolicy(); - var catalog = new AuthorizedCatalog(skills.Select(skill => skill.ProtocolSkill).ToArray(), policy, pageSize: 1); + var handlers = new AuthorizedHandlers(packages.Select(package => package.Skill).ToArray(), policy, pageSize: 1); Builder.Services.AddMcpServer() .WithHttpTransport(options => options.Stateless = true) - .WithSkills(catalog) - .WithResources(skills.SelectMany(skill => skill.Resources)) + .WithSkills(options => + { + options.ListSkillsHandler = handlers.ListAsync; + options.GetSkillHandler = handlers.GetAsync; + }) + .WithResources(packages.SelectMany(package => package.Resources)) .WithRequestFilters(filters => { filters.AddListResourcesFilter(next => async (context, cancellationToken) => @@ -143,7 +275,7 @@ private async Task StartServer( }); filters.AddReadResourceFilter(next => (context, cancellationToken) => { - if (!policy.CanRead(context.User, context.Params.Uri)) + if (!policy.CanRead(context.User, context.MatchedPrimitive?.Id)) { throw new McpProtocolException("Access forbidden.", McpErrorCode.InvalidRequest); } @@ -163,30 +295,42 @@ private async Task StartServer( return app; } - private McpServerSkill CreateSkill(string name) => McpServerSkill.Create( - $"skill://private/{name}/SKILL.md", - [McpServerSkillFile.FromText("SKILL.md", $"---\nname: {name}\ndescription: d\n---\n")]); + private McpServerSkillPackage CreateSkill(string name) => McpServerSkillPackage.Create( + [McpServerSkillFile.FromText("SKILL.md", $"---\nname: {name}\ndescription: d\n---\n")], new McpServerSkillPackageCreateOptions { Uri = $"skill://private/{name}/SKILL.md" }); private sealed class ReadPolicy { - public bool CanRead(ClaimsPrincipal? user, string uri) => - uri.StartsWith("skill://private/", StringComparison.Ordinal) && - uri != HiddenSkillUri && + public bool CanRead(ClaimsPrincipal? user, string? id) => + id?.StartsWith("skill://private/", StringComparison.Ordinal) == true && + id != HiddenSkillUri && (user?.Identity?.IsAuthenticated).GetValueOrDefault() && (user?.IsInRole("reader")).GetValueOrDefault(); } - private sealed class AuthorizedCatalog(IReadOnlyList skills, ReadPolicy policy, int pageSize) : IMcpSkillCatalog + private sealed class AuthorizedHandlers(IReadOnlyList skills, ReadPolicy policy, int pageSize) { - public ValueTask ListAsync( - string? cursor, McpSkillRequestContext context, CancellationToken cancellationToken) => - ForUser(context.User).ListAsync(cursor, context, cancellationToken); - - public ValueTask GetAsync( - string uri, McpSkillRequestContext context, CancellationToken cancellationToken) => - ForUser(context.User).GetAsync(uri, context, cancellationToken); + public ValueTask ListAsync( + RequestContext context, CancellationToken cancellationToken) + { + string? after = context.Params.Cursor is { } cursor ? Encoding.UTF8.GetString(Convert.FromBase64String(cursor)) : null; + var visible = skills.Where(skill => policy.CanRead(context.User, skill.Id)) + .OrderBy(skill => skill.Id, StringComparer.Ordinal) + .Where(skill => after is null || string.CompareOrdinal(skill.Id, after) > 0).ToArray(); + var page = visible.Take(pageSize).Select(skill => skill.ProtocolSkill!).ToList(); + return new(new ListSkillsResult + { + Skills = page, + NextCursor = visible.Length > pageSize ? + Convert.ToBase64String(Encoding.UTF8.GetBytes(page[page.Count - 1].Uri)) : null, + }); + } - private InMemoryMcpSkillCatalog ForUser(ClaimsPrincipal? user) => - new(skills.Where(skill => policy.CanRead(user, skill.Uri)), pageSize); + public ValueTask GetAsync( + RequestContext context, CancellationToken cancellationToken) => + new(new GetSkillResult + { + Skill = skills.SingleOrDefault(skill => skill.Id == context.Params.Uri) is { } skill && policy.CanRead(context.User, skill.Id) ? + skill.ProtocolSkill! : throw new McpProtocolException("No skill is served.", McpErrorCode.InvalidParams), + }); } } diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/SkillsDependencyInjectionTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/SkillsDependencyInjectionTests.cs new file mode 100644 index 000000000..ffbf98972 --- /dev/null +++ b/tests/ModelContextProtocol.AspNetCore.Tests/SkillsDependencyInjectionTests.cs @@ -0,0 +1,232 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using ModelContextProtocol.AspNetCore.Tests.Utils; +using ModelContextProtocol.Client; +using ModelContextProtocol.Extensions.Skills; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using ModelContextProtocol.Tests.Utils; +using System.Collections.Concurrent; +using System.Security.Claims; +using System.Text.Json.Nodes; + +namespace ModelContextProtocol.AspNetCore.Tests; + +public class SkillsDependencyInjectionTests(ITestOutputHelper output) : KestrelInMemoryTest(output) +{ + private const string SkillUri = "skill://database/SKILL.md"; + private const string MiddlewareState = "From HTTP middleware"; + private const string McpRequestState = "Independent MCP request scope"; + + [Theory] + [InlineData(HttpServerSessionMode.Stateless, false, "2026-07-28")] + [InlineData(HttpServerSessionMode.StatefulForInitializeClients, false, "2026-07-28")] + [InlineData(HttpServerSessionMode.Stateful, false, "2025-11-25")] + [InlineData(HttpServerSessionMode.Stateful, true, "2025-11-25")] + public async Task Catalog_UsesTheTransportRequestScope( + HttpServerSessionMode mode, bool useSse, string protocolVersion) + { + var tracker = new InvocationTracker(); + await using var app = await StartServerAsync(tracker, mode, useSse); + await using var transport = new HttpClientTransport(new() + { + Endpoint = new Uri(useSse ? "http://localhost:5000/sse" : "http://localhost:5000/"), + TransportMode = useSse ? HttpTransportMode.Sse : HttpTransportMode.StreamableHttp, + }, HttpClient, LoggerFactory); + var clientOptions = TestMcpClient.CreateOptions(); + clientOptions.ProtocolVersion = protocolVersion; + await using var client = await TestMcpClient.CreateAsync( + transport, clientOptions, LoggerFactory, TestContext.Current.CancellationToken); + Assert.Empty(tracker.Invocations); + bool usesHttpScope = !useSse && (mode == HttpServerSessionMode.Stateless || + mode == HttpServerSessionMode.StatefulForInitializeClients && protocolVersion == McpProtocolVersions.July2026ProtocolVersion); + + var listed = Assert.Single(await client.ListSkillsAsync(cancellationToken: TestContext.Current.CancellationToken)); + var retrieved = await client.GetSkillAsync(SkillUri, cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal(usesHttpScope ? MiddlewareState : McpRequestState, listed.Description); + Assert.Equal(listed.Description, retrieved.Description); + var invocations = tracker.Invocations.ToArray(); + Assert.Equal(2, invocations.Length); + Assert.NotSame(invocations[0].Catalog, invocations[1].Catalog); + Assert.NotSame(invocations[0].Dependency, invocations[1].Dependency); + foreach (var invocation in invocations) + { + if (usesHttpScope) + { + Assert.Same(invocation.Dependency.HttpRequestServices, invocation.Services); + } + else + { + Assert.Null(invocation.Dependency.HttpRequestServices); + } + + await Task.WhenAll(invocation.Dependency.Disposed.Task, invocation.Catalog.Disposed.Task) + .WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken); + Assert.Equal(1, invocation.Dependency.DisposeCount); + Assert.Equal(1, invocation.Catalog.DisposeCount); + } + } + + [Fact] + public async Task CatalogFailure_DisposesHttpOwnedCatalogAndDependencies() + { + var tracker = new InvocationTracker { Fail = true }; + await using var app = await StartServerAsync(tracker, HttpServerSessionMode.Stateless, useSse: false); + await using var transport = new HttpClientTransport( + new() { Endpoint = new Uri("http://localhost:5000/") }, HttpClient, LoggerFactory); + await using var client = await TestMcpClient.CreateAsync( + transport, cancellationToken: TestContext.Current.CancellationToken); + + var exception = await Assert.ThrowsAsync( + async () => await client.ListSkillsAsync(new ListSkillsRequestParams(), cancellationToken: TestContext.Current.CancellationToken)); + + Assert.Equal(McpErrorCode.InternalError, exception.ErrorCode); + var invocation = Assert.Single(tracker.Invocations); + await Task.WhenAll(invocation.Dependency.Disposed.Task, invocation.Catalog.Disposed.Task) + .WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken); + Assert.Equal(1, invocation.Dependency.DisposeCount); + Assert.Equal(1, invocation.Catalog.DisposeCount); + } + + private async Task StartServerAsync( + InvocationTracker tracker, HttpServerSessionMode mode, bool useSse) + { + Builder.Host.UseDefaultServiceProvider(options => + { + options.ValidateScopes = true; + options.ValidateOnBuild = true; + }); + + Builder.Services.AddSingleton(tracker); + Builder.Services.AddScoped(); + var mcpBuilder = Builder.Services.AddMcpServer() + .WithHttpTransport(options => + { + options.SessionMode = mode; + options.EnableLegacySse = useSse; + }) + .WithMessageFilters(filters => filters.AddIncomingFilter(next => (context, cancellationToken) => + { + context.Items["source"] = "incoming filter"; + return next(context, cancellationToken); + })); + Builder.Services.AddScoped(); + mcpBuilder.WithSkills(options => + { + options.ListSkillsHandler = (context, cancellationToken) => + context.Services!.GetRequiredService().ListAsync(context, cancellationToken); + options.GetSkillHandler = (context, cancellationToken) => + context.Services!.GetRequiredService().GetAsync(context, cancellationToken); + options.ListSkillsFilters.Add(next => (context, cancellationToken) => + { + RecordFilter(context); + return next(context, cancellationToken); + }); + + options.GetSkillFilters.Add(next => (context, cancellationToken) => + { + RecordFilter(context); + return next(context, cancellationToken); + }); + }); + + var app = Builder.Build(); + app.Use(async (context, next) => + { + context.User = new ClaimsPrincipal(new ClaimsIdentity( + [new Claim(ClaimTypes.Name, "reader"), new Claim(ClaimTypes.NameIdentifier, "reader")], "Test")); + var dependency = context.RequestServices.GetRequiredService(); + dependency.State = MiddlewareState; + dependency.HttpRequestServices = context.RequestServices; + await next(context); + }); + + app.MapMcp(); + await app.StartAsync(TestContext.Current.CancellationToken); + return app; + } + + private static void RecordFilter(MessageContext context) + { + context.Items["skills-filter-context"] = context; + context.Items["skills-filter-dependency"] = context.Services!.GetRequiredService(); + } + + private sealed class InvocationTracker + { + public ConcurrentQueue Invocations { get; } = new(); + public bool Fail { get; init; } + } + + private sealed record Invocation(ScopedCatalog Catalog, RequestDependency Dependency, IServiceProvider Services); + + private sealed class RequestDependency : IAsyncDisposable + { + public string? State { get; set; } + public IServiceProvider? HttpRequestServices { get; set; } + public TaskCompletionSource Disposed { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public int DisposeCount { get; private set; } + + public async ValueTask DisposeAsync() + { + await Task.Yield(); + DisposeCount++; + Disposed.TrySetResult(); + } + } + + private sealed class ScopedCatalog(RequestDependency dependency, InvocationTracker tracker) : IAsyncDisposable + { + public TaskCompletionSource Disposed { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public int DisposeCount { get; private set; } + + public ValueTask ListAsync(RequestContext context, CancellationToken cancellationToken) + { + Record(context); + return new(new ListSkillsResult { Skills = [CreateEntry()] }); + } + + public ValueTask GetAsync(RequestContext context, CancellationToken cancellationToken) + { + Record(context); + return new(new GetSkillResult + { + Skill = context.Params.Uri == SkillUri ? CreateEntry() : + throw new McpProtocolException("No skill is served.", McpErrorCode.InvalidParams), + }); + } + + private void Record(MessageContext context) + { + var services = Assert.IsAssignableFrom(context.Services); + Assert.Same(dependency, services.GetRequiredService()); + var filterContext = Assert.IsAssignableFrom(context.Items["skills-filter-context"]); + Assert.Same(context, filterContext); + Assert.Same(dependency, context.Items["skills-filter-dependency"]); + Assert.Equal("reader", context.User?.Identity?.Name); + Assert.Equal("incoming filter", context.Items["source"]); + Assert.NotNull(context.Server); + tracker.Invocations.Enqueue(new(this, dependency, services)); + if (tracker.Fail) + { + throw new McpProtocolException("Catalog failed.", McpErrorCode.InternalError); + } + } + + private Skill CreateEntry() => new() + { + Uri = SkillUri, + Frontmatter = new JsonObject { ["name"] = "database", ["description"] = dependency.State ?? McpRequestState }, + Resources = SkillResources.Dynamic, + }; + + public async ValueTask DisposeAsync() + { + await Task.Yield(); + DisposeCount++; + Disposed.TrySetResult(); + } + } +} diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/SkillsProtocolBoundaryTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/SkillsProtocolBoundaryTests.cs new file mode 100644 index 000000000..1afd83007 --- /dev/null +++ b/tests/ModelContextProtocol.AspNetCore.Tests/SkillsProtocolBoundaryTests.cs @@ -0,0 +1,69 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol.AspNetCore.Tests.Utils; +using ModelContextProtocol.Client; +using ModelContextProtocol.Extensions.Skills; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Tests.Utils; + +namespace ModelContextProtocol.AspNetCore.Tests; + +public class SkillsProtocolBoundaryTests(ITestOutputHelper output) : KestrelInMemoryTest(output) +{ + [Theory] + [InlineData(false, "2025-11-25", false)] + [InlineData(false, "2025-11-25", true)] + [InlineData(true, "2025-11-25", false)] + [InlineData(true, "2025-11-25", true)] + [InlineData(true, "2026-07-28", false)] + [InlineData(true, "2026-07-28", true)] + public async Task DirectCallbacksUseLegacyStatefulHttp_AndAreRejectedOnStatelessHttp(bool stateless, string version, bool sampling) + { + Exception? localError = null; + Builder.Services.AddMcpServer().WithHttpTransport(options => options.SessionMode = + stateless ? HttpServerSessionMode.Stateless : HttpServerSessionMode.Stateful) + .WithSkills(options => options.ListSkillsHandler = async (context, cancellationToken) => + { + try + { + if (sampling) + { + await context.Server.SampleAsync(new CreateMessageRequestParams { Messages = [], MaxTokens = 10 }, cancellationToken); + } + else + { + await context.Server.ElicitAsync(new ElicitRequestParams { Message = "Confirm", RequestedSchema = new() }, cancellationToken); + } + + return new ListSkillsResult(); + } + catch (InvalidOperationException error) + { + localError = error; + throw; + } + }); + + await using var app = Builder.Build(); + app.MapMcp(); + await app.StartAsync(TestContext.Current.CancellationToken); + await using var transport = new HttpClientTransport(new() { Endpoint = new Uri("http://localhost:5000/") }, HttpClient, LoggerFactory); + var options = TestMcpClient.CreateOptions(); + options.ProtocolVersion = version; + options.Handlers.ElicitationHandler = (_, _) => new(new ElicitResult { Action = "accept" }); + options.Handlers.SamplingHandler = (_, _, _) => new(new CreateMessageResult { Model = "test", Content = [] }); + await using var client = await TestMcpClient.CreateAsync(transport, options, LoggerFactory, TestContext.Current.CancellationToken); + if (stateless) + { + var error = await Assert.ThrowsAsync( + async () => await client.ListSkillsAsync(new ListSkillsRequestParams(), cancellationToken: TestContext.Current.CancellationToken)); + Assert.Equal(McpErrorCode.InternalError, error.ErrorCode); + Assert.Contains("not supported in stateless mode", Assert.IsType(localError).Message); + } + else + { + Assert.Empty(await client.ListSkillsAsync(cancellationToken: TestContext.Current.CancellationToken)); + Assert.Null(localError); + } + } +} diff --git a/tests/ModelContextProtocol.ConformanceServer/ModelContextProtocol.ConformanceServer.csproj b/tests/ModelContextProtocol.ConformanceServer/ModelContextProtocol.ConformanceServer.csproj index 7f2ca8212..71700976c 100644 --- a/tests/ModelContextProtocol.ConformanceServer/ModelContextProtocol.ConformanceServer.csproj +++ b/tests/ModelContextProtocol.ConformanceServer/ModelContextProtocol.ConformanceServer.csproj @@ -5,7 +5,7 @@ enable enable Exe - $(NoWarn);MCP9006 + $(NoWarn);MCP9006;MCPEXP005 diff --git a/tests/ModelContextProtocol.ConformanceServer/Program.cs b/tests/ModelContextProtocol.ConformanceServer/Program.cs index 568644262..62102287c 100644 --- a/tests/ModelContextProtocol.ConformanceServer/Program.cs +++ b/tests/ModelContextProtocol.ConformanceServer/Program.cs @@ -61,11 +61,7 @@ private static void ConfigureConformanceMcpServer( .AddMcpServer() .WithHttpTransport(options => options.Stateless = stateless) .WithDistributedCacheEventStreamStore() - .WithSkills(ConformanceSkills.CreateCatalog(), options => - { - options.TimeToLive = TimeSpan.FromMinutes(5); - options.CacheScope = CacheScope.Public; - }) + .WithSkills(ConformanceSkills.CreateSkills()) .WithResources(ConformanceSkills.CreateResources()) .WithTasks( new InMemoryMcpTaskStore diff --git a/tests/ModelContextProtocol.ConformanceServer/Skills/ConformanceSkills.cs b/tests/ModelContextProtocol.ConformanceServer/Skills/ConformanceSkills.cs index 6fc96ed78..42391b84c 100644 --- a/tests/ModelContextProtocol.ConformanceServer/Skills/ConformanceSkills.cs +++ b/tests/ModelContextProtocol.ConformanceServer/Skills/ConformanceSkills.cs @@ -9,43 +9,40 @@ namespace ModelContextProtocol.ConformanceServer.Skills; /// /// /// -/// The three static skills are authored through , so their frontmatter is read from +/// The three static skills are authored through , so their frontmatter is read from /// their SKILL.md and their manifests are computed from the same bytes their resources serve. A fourth, -/// dynamically generated skill is included in the same catalog: it is listed and answerable through +/// dynamically generated skill is included in the same collection: it is listed and answerable through /// skills/get, but its dynamic manifest carries no digests and so cannot be content-bound. /// /// -/// The page size is deliberately small so the scenarios exercise cursor pagination. +/// Registered skills are returned on the initial page; application-owned sources may implement pagination. /// /// public static class ConformanceSkills { private const string GeneratedReportUri = "skill://generated-report/SKILL.md"; - private static readonly McpServerSkill[] s_skills = + private static readonly McpServerSkillPackage[] s_packages = [ - McpServerSkill.Create( - "skill://git-workflow/SKILL.md", + McpServerSkillPackage.Create( [ SkillFile("git-workflow", "Follow this team's Git conventions for branching and commits", "# Git workflow\n"), - ]), + ], new McpServerSkillPackageCreateOptions { Uri = "skill://git-workflow/SKILL.md" }), - McpServerSkill.Create( - "skill://pdf-processing/SKILL.md", + McpServerSkillPackage.Create( [ SkillFile("pdf-processing", "Extract, fill, and assemble PDF documents", "# PDF processing\n"), McpServerSkillFile.FromText("references/FORMS.md", "# Forms\n\nField reference for PDF form filling.\n"), McpServerSkillFile.FromText("scripts/extract.py", "import sys\n\nprint('extract')\n"), McpServerSkillFile.FromText("templates/invoice.md", "# Invoice\n"), McpServerSkillFile.FromText("templates/regional/eu-invoice.md", "# EU Invoice\n"), - ]), + ], new McpServerSkillPackageCreateOptions { Uri = "skill://pdf-processing/SKILL.md" }), - McpServerSkill.Create( - "skill://acme/billing/refunds/SKILL.md", + McpServerSkillPackage.Create( [ SkillFile("refunds", "Process customer refund requests per company policy", "# Refunds\n"), McpServerSkillFile.FromText("examples/email.md", "Subject: Your refund\n"), - ]), + ], new McpServerSkillPackageCreateOptions { Uri = "skill://acme/billing/refunds/SKILL.md" }), ]; private static readonly Skill s_generatedReport = new() @@ -56,10 +53,9 @@ public static class ConformanceSkills }; /// - /// Creates the catalog backing skills/list and skills/get. + /// Creates the primitives backing skills/list and skills/get. /// - public static IMcpSkillCatalog CreateCatalog() => - new InMemoryMcpSkillCatalog([.. s_skills.Select(s => s.ProtocolSkill), s_generatedReport], pageSize: 2); + public static IEnumerable CreateSkills() => [.. s_packages.Select(package => package.Skill), McpServerSkill.Create(s_generatedReport)]; /// /// Creates the resources serving every skill file, so the files are enumerable through resources/list @@ -67,9 +63,9 @@ public static IMcpSkillCatalog CreateCatalog() => /// public static IEnumerable CreateResources() { - foreach (var skill in s_skills) + foreach (var package in s_packages) { - foreach (var resource in skill.Resources) + foreach (var resource in package.Resources) { yield return resource; } diff --git a/tests/ModelContextProtocol.Tests/Client/McpSkillsClientUnreadableTextTests.cs b/tests/ModelContextProtocol.Tests/Client/McpSkillsClientUnreadableTextTests.cs deleted file mode 100644 index 608567af1..000000000 --- a/tests/ModelContextProtocol.Tests/Client/McpSkillsClientUnreadableTextTests.cs +++ /dev/null @@ -1,139 +0,0 @@ -using ModelContextProtocol.Client; -using ModelContextProtocol.Extensions.Skills; -using ModelContextProtocol.Protocol; -using ModelContextProtocol.Tests.Utils; -using System.Net; -using System.Text; -using System.Text.Json; - -namespace ModelContextProtocol.Tests.Client; - -/// -/// A JSON string can hold an escaped unpaired surrogate such as "\uD800", which is not text. A server built on -/// System.Text.Json cannot write one, so these tests answer the client with raw response bodies and check that the -/// Skills client fails such an entry like any other malformed entry, without an . -/// -public class McpSkillsClientUnreadableTextTests(ITestOutputHelper testOutputHelper) : LoggedTest(testOutputHelper) -{ - private const string ValidFrontmatter = """{"name":"good","description":"d"}"""; - private const string UnpairedHighSurrogate = """{"name":"bad","description":"\uD800"}"""; - - [Theory] - [InlineData(UnpairedHighSurrogate)] - [InlineData("""{"name":"bad","description":"\uDC00 after an unpaired low surrogate"}""")] - [InlineData("""{"name":"bad","description":"d","\uD800":"in a key"}""")] - public async Task ListSkillsWithDiagnosticsAsync_RejectsAnEntryWithUnreadableText_AndKeepsTheValidEntry(string frontmatter) - { - await using var transport = CreateTransport(listResult: Page(Entry("good", ValidFrontmatter), Entry("bad", frontmatter))); - await using var client = await ConnectAsync(transport); - - var listing = await client.ListSkillsWithDiagnosticsAsync(new ListSkillsRequestParams(), TestContext.Current.CancellationToken); - - Assert.Equal("good", Assert.Single(listing.Page.Skills).Name); - var rejection = Assert.Single(listing.RejectedEntries); - Assert.Equal(1, rejection.Index); - AssertUnreadableText(Assert.IsType(rejection.Error)); - } - - [Fact] - public async Task ListSkillsWithDiagnosticsAsync_ThrowsJsonException_WhenThePageHasUnreadableText() - { - string page = """{"skills":[],"nextCursor":"\uD800"}"""; - await using var transport = CreateTransport(listResult: page); - await using var client = await ConnectAsync(transport); - - var error = await Assert.ThrowsAsync( - async () => await client.ListSkillsWithDiagnosticsAsync(new ListSkillsRequestParams(), TestContext.Current.CancellationToken)); - - AssertUnreadableText(error); - } - - [Fact] - public async Task ListSkillsAsync_ThrowsJsonException_ForAnEntryWithUnreadableText() - { - await using var transport = CreateTransport(listResult: Page(Entry("good", ValidFrontmatter), Entry("bad", UnpairedHighSurrogate))); - await using var client = await ConnectAsync(transport); - - var error = await Assert.ThrowsAsync(async () => await client.ListSkillsAsync(TestContext.Current.CancellationToken)); - - AssertUnreadableText(error); - } - - [Fact] - public async Task GetSkillAsync_ThrowsJsonException_ForAnEntryWithUnreadableText() - { - await using var transport = CreateTransport(listResult: Page(), getResult: "{\"skill\":" + Entry("bad", UnpairedHighSurrogate) + "}"); - await using var client = await ConnectAsync(transport); - - var error = await Assert.ThrowsAsync( - async () => await client.GetSkillAsync("skill://bad/SKILL.md", TestContext.Current.CancellationToken)); - - AssertUnreadableText(error); - } - - private void AssertUnreadableText(JsonException error) - { - Assert.Contains("not valid Unicode text", error.Message); - Assert.IsType(error.InnerException); - } - - private string Page(params string[] entries) => "{\"skills\":[" + string.Join(",", entries) + "]}"; - - private string Entry(string name, string frontmatter) => - "{\"uri\":\"skill://" + name + "/SKILL.md\",\"frontmatter\":" + frontmatter + - ",\"resources\":[{\"uri\":\"skill://" + name + "/SKILL.md\",\"digest\":\"sha256:" + new string('a', 64) + "\",\"size\":1}]}"; - - /// Creates a transport to a raw Streamable HTTP server that answers skills/list and skills/get with the given result bodies. - private HttpClientTransport CreateTransport(string listResult, string? getResult = null) - { - var handler = new MockHttpHandler { RequestHandler = request => RespondAsync(request, listResult, getResult) }; - return new HttpClientTransport(new HttpClientTransportOptions - { - Endpoint = new Uri("http://localhost"), - TransportMode = HttpTransportMode.StreamableHttp, - Name = "Raw skills server test client", - }, new HttpClient(handler), LoggerFactory, ownsHttpClient: true); - } - - private Task ConnectAsync(HttpClientTransport transport) => McpClient.CreateAsync( - transport, - new McpClientOptions { ProtocolVersion = McpProtocolVersions.November2025ProtocolVersion }, - LoggerFactory, - TestContext.Current.CancellationToken); - - private async Task RespondAsync(HttpRequestMessage request, string listResult, string? getResult) - { - // The server offers no standalone SSE stream, which the spec permits. - // net472 does not populate a default Content, so every response sets one explicitly. - if (request.Method != HttpMethod.Post) - { - var status = request.Method == HttpMethod.Get ? HttpStatusCode.MethodNotAllowed : HttpStatusCode.OK; - return new HttpResponseMessage(status) { Content = new StringContent(string.Empty) }; - } - - using var body = JsonDocument.Parse(await request.Content!.ReadAsStringAsync()); - if (!body.RootElement.TryGetProperty("id", out var id)) - { - return new HttpResponseMessage(HttpStatusCode.Accepted) { Content = new StringContent(string.Empty) }; - } - - string method = body.RootElement.GetProperty("method").GetString()!; - string result = method switch - { - RequestMethods.Initialize => - "{\"protocolVersion\":\"" + McpProtocolVersions.November2025ProtocolVersion + "\"," + - "\"capabilities\":{\"extensions\":{\"" + SkillsProtocol.ExtensionId + "\":{}}}," + - "\"serverInfo\":{\"name\":\"raw\",\"version\":\"1.0\"}}", - SkillsProtocol.MethodSkillsList => listResult, - SkillsProtocol.MethodSkillsGet when getResult is not null => getResult, - _ => throw new InvalidOperationException($"The raw test server does not answer '{method}'."), - }; - - var response = new HttpResponseMessage(HttpStatusCode.OK) - { - Content = new StringContent("{\"jsonrpc\":\"2.0\",\"id\":" + id.GetRawText() + ",\"result\":" + result + "}", Encoding.UTF8, "application/json"), - }; - response.Headers.Add("mcp-session-id", "raw-session"); - return response; - } -} diff --git a/tests/ModelContextProtocol.Tests/Client/McpSkillsClientValidationTests.cs b/tests/ModelContextProtocol.Tests/Client/McpSkillsClientValidationTests.cs index b78f6a864..8a77e1ed6 100644 --- a/tests/ModelContextProtocol.Tests/Client/McpSkillsClientValidationTests.cs +++ b/tests/ModelContextProtocol.Tests/Client/McpSkillsClientValidationTests.cs @@ -44,8 +44,6 @@ protected override void ConfigureServices(ServiceCollection services, IMcpServer var json = request.Params?["cursor"]?.GetValue() switch { - "no-skills-array" => """{ "skills": null }""", - "missing-skills-array" => "{}", "empty-skills-array" => """{ "skills": [] }""", _ => """ { "skills": [ { "uri": "skill://good/SKILL.md", "frontmatter": { "name": "good", "description": "d" }, @@ -85,27 +83,45 @@ protected override void ConfigureServices(ServiceCollection services, IMcpServer }); } + [Fact] + public void SupportsSkillsChecksTheExtensionDeclaration() + { + var capabilities = new ServerCapabilities(); + Assert.False(capabilities.SupportsSkills()); + + capabilities.Extensions = new Dictionary { ["example.com/other"] = new JsonObject() }; + Assert.False(capabilities.SupportsSkills()); + + capabilities.Extensions[SkillsProtocol.ExtensionId] = new JsonObject(); + Assert.True(capabilities.SupportsSkills()); + + Assert.Throws("capabilities", () => McpSkillsClientExtensions.SupportsSkills(null!)); + } + [Fact] public async Task ListSkillsAsync_RejectsAnInvalidEntry() { await using McpClient client = await CreateMcpClientForServer(); - var exception = await Assert.ThrowsAsync( - async () => await client.ListSkillsAsync(TestContext.Current.CancellationToken)); + var exception = await Assert.ThrowsAsync( + async () => await client.ListSkillsAsync(new ListSkillsRequestParams(), cancellationToken: TestContext.Current.CancellationToken)); Assert.Contains("skills/list", exception.Message); Assert.Contains("skill://bad/SKILL.md", exception.Message); } [Theory] - [InlineData("no-skills-array")] - [InlineData("missing-skills-array")] - public async Task ListSkillsAsync_RejectsAPageWithoutASkillsArray(string cursor) + [InlineData("""{"skills":null}""")] + [InlineData("{}")] + public async Task ListSkillsAsync_RejectsAPageWithoutASkillsArray(string json) { + _listResponse = JsonNode.Parse(json)!.AsObject(); await using McpClient client = await CreateMcpClientForServer(); await Assert.ThrowsAsync( - async () => await client.ListSkillsAsync(new ListSkillsRequestParams { Cursor = cursor }, TestContext.Current.CancellationToken)); + async () => await client.ListSkillsAsync(new ListSkillsRequestParams(), cancellationToken: TestContext.Current.CancellationToken)); + await Assert.ThrowsAsync( + async () => await client.ListSkillsAsync(cancellationToken: TestContext.Current.CancellationToken)); } [Fact] @@ -113,7 +129,7 @@ public async Task ListSkillsAsync_AcceptsAnEmptySkillsArray() { await using McpClient client = await CreateMcpClientForServer(); - var result = await client.ListSkillsAsync(new ListSkillsRequestParams { Cursor = "empty-skills-array" }, TestContext.Current.CancellationToken); + var result = await client.ListSkillsAsync(new ListSkillsRequestParams { Cursor = "empty-skills-array" }, cancellationToken: TestContext.Current.CancellationToken); Assert.Empty(result.Skills); } @@ -126,7 +142,7 @@ public async Task ListSkillsAsync_AcceptsSupplementaryUnicodeNames(string name) _listResponse = CreateListResponse(name); await using McpClient client = await CreateMcpClientForServer(); - var result = await client.ListSkillsAsync(TestContext.Current.CancellationToken); + var result = await client.ListSkillsAsync(cancellationToken: TestContext.Current.CancellationToken); Assert.Equal(name, Assert.Single(result).Name); } @@ -143,16 +159,12 @@ public async Task ClientMethodsAcceptPercentEncodedUnicodeNameSegments(string na _getResponse = new JsonObject { ["skill"] = entry.DeepClone() }; await using var client = await CreateMcpClientForServer(); - var listed = await client.ListSkillsAsync(TestContext.Current.CancellationToken); - var fetched = await client.GetSkillAsync(uri, TestContext.Current.CancellationToken); - var diagnostic = await client.ListSkillsWithDiagnosticsAsync( - new ListSkillsRequestParams(), TestContext.Current.CancellationToken); + var listed = await client.ListSkillsAsync(cancellationToken: TestContext.Current.CancellationToken); + var fetched = await client.GetSkillAsync(uri, cancellationToken: TestContext.Current.CancellationToken); Assert.Equal(uri, Assert.Single(listed).Uri); Assert.Equal(name, fetched.Name); Assert.Equal(uri, fetched.Uri); - Assert.Empty(diagnostic.RejectedEntries); - Assert.Equal(uri, Assert.Single(diagnostic.Page.Skills).Uri); } [Theory] @@ -167,8 +179,8 @@ public async Task ClientMethodsAcceptNativeAbsoluteResourceUris(string uri) _getResponse = new JsonObject { ["skill"] = entry.DeepClone() }; await using var client = await CreateMcpClientForServer(); - var listed = await client.ListSkillsAsync(TestContext.Current.CancellationToken); - var fetched = await client.GetSkillAsync(uri, TestContext.Current.CancellationToken); + var listed = await client.ListSkillsAsync(cancellationToken: TestContext.Current.CancellationToken); + var fetched = await client.GetSkillAsync(uri, cancellationToken: TestContext.Current.CancellationToken); Assert.Equal(uri, Assert.Single(listed).Uri); Assert.Equal(uri, fetched.Uri); } @@ -191,33 +203,21 @@ public async Task ClientMethodsUseScalarFrontmatterLimits(string field, int limi if (overLimit) { - var listError = await Assert.ThrowsAsync( - async () => await client.ListSkillsAsync(TestContext.Current.CancellationToken)); - var getError = await Assert.ThrowsAsync( - async () => await client.GetSkillAsync(uri, TestContext.Current.CancellationToken)); + var listError = await Assert.ThrowsAsync( + async () => await client.ListSkillsAsync(new ListSkillsRequestParams(), cancellationToken: TestContext.Current.CancellationToken)); + var getError = await Assert.ThrowsAsync( + async () => await client.GetSkillAsync(uri, cancellationToken: TestContext.Current.CancellationToken)); Assert.Contains($"{length} characters", listError.Message); Assert.Contains($"{length} characters", getError.Message); } else { - var listed = await client.ListSkillsAsync(TestContext.Current.CancellationToken); - var fetched = await client.GetSkillAsync(uri, TestContext.Current.CancellationToken); + var listed = await client.ListSkillsAsync(cancellationToken: TestContext.Current.CancellationToken); + var fetched = await client.GetSkillAsync(uri, cancellationToken: TestContext.Current.CancellationToken); Assert.Equal(text, Assert.Single(listed).Frontmatter[field]!.GetValue()); Assert.Equal(text, fetched.Frontmatter[field]!.GetValue()); } - var diagnostic = await client.ListSkillsWithDiagnosticsAsync( - new ListSkillsRequestParams(), TestContext.Current.CancellationToken); - if (overLimit) - { - Assert.Empty(diagnostic.Page.Skills); - Assert.Contains($"{length} characters", Assert.Single(diagnostic.RejectedEntries).Error.Message); - } - else - { - Assert.Empty(diagnostic.RejectedEntries); - Assert.Equal(text, Assert.Single(diagnostic.Page.Skills).Frontmatter[field]!.GetValue()); - } } [Fact] @@ -232,20 +232,15 @@ public async Task ClientMethodsPreserveEntryMetaSeparatelyFromFrontmatterAndPage var uri = entry["uri"]!.GetValue(); await using var client = await CreateMcpClientForServer(); - var page = await client.ListSkillsAsync(new ListSkillsRequestParams(), TestContext.Current.CancellationToken); + var page = await client.ListSkillsAsync(new ListSkillsRequestParams(), cancellationToken: TestContext.Current.CancellationToken); var listed = Assert.Single(page.Skills); Assert.True(JsonNode.DeepEquals(meta, listed.Meta)); Assert.Equal("page", page.Meta!["scope"]!.GetValue()); Assert.Equal("2.1", listed.Frontmatter["metadata"]!["version"]!.GetValue()); listed.Meta!["example.com/extension"]!["items"]![0]!["value"] = "changed"; - var fetched = await client.GetSkillAsync(uri, TestContext.Current.CancellationToken); + var fetched = await client.GetSkillAsync(uri, cancellationToken: TestContext.Current.CancellationToken); Assert.True(JsonNode.DeepEquals(meta, fetched.Meta)); - var diagnostic = await client.ListSkillsWithDiagnosticsAsync( - new ListSkillsRequestParams(), TestContext.Current.CancellationToken); - Assert.Empty(diagnostic.RejectedEntries); - Assert.True(JsonNode.DeepEquals(meta, Assert.Single(diagnostic.Page.Skills).Meta)); - Assert.Equal("page", diagnostic.Page.Meta!["scope"]!.GetValue()); } [Fact] @@ -254,8 +249,8 @@ public async Task ListSkillsAsync_RejectsTitlecaseNames() _listResponse = CreateListResponse("\u01c5"); await using McpClient client = await CreateMcpClientForServer(); - await Assert.ThrowsAsync( - async () => await client.ListSkillsAsync(TestContext.Current.CancellationToken)); + await Assert.ThrowsAsync( + async () => await client.ListSkillsAsync(new ListSkillsRequestParams(), cancellationToken: TestContext.Current.CancellationToken)); } [Theory] @@ -269,8 +264,8 @@ public async Task ListSkillsAsync_RejectsNonStringMetadataValues(string value) _listResponse = CreateListResponse("alpha", new JsonObject { ["value"] = JsonNode.Parse(value) }); await using McpClient client = await CreateMcpClientForServer(); - var exception = await Assert.ThrowsAsync( - async () => await client.ListSkillsAsync(TestContext.Current.CancellationToken)); + var exception = await Assert.ThrowsAsync( + async () => await client.ListSkillsAsync(new ListSkillsRequestParams(), cancellationToken: TestContext.Current.CancellationToken)); Assert.Contains("metadata", exception.Message); } @@ -283,76 +278,41 @@ public async Task ListSkillsAsync_PreservesStringMetadataAndStructuredExtensionF frontmatter["custom"] = new JsonObject { ["count"] = 1, ["values"] = new JsonArray(true) }; await using McpClient client = await CreateMcpClientForServer(); - var result = await client.ListSkillsAsync(TestContext.Current.CancellationToken); + var result = await client.ListSkillsAsync(cancellationToken: TestContext.Current.CancellationToken); Assert.True(JsonNode.DeepEquals(frontmatter, Assert.Single(result).Frontmatter)); } - [Fact] - public async Task DiagnosticListingKeepsValidEntriesAndReportsTheInvalidOne() - { - await using var client = await CreateMcpClientForServer(); - - var result = await client.ListSkillsWithDiagnosticsAsync( - new ListSkillsRequestParams(), TestContext.Current.CancellationToken); - - Assert.Equal("good", Assert.Single(result.Page.Skills).Name); - var rejected = Assert.Single(result.RejectedEntries); - Assert.Equal(1, rejected.Index); - Assert.IsType(rejected.Error); - } - - [Fact] - public async Task DiagnosticListingReportsMalformedEntriesAndPreservesPageHints() + [Theory] + [InlineData("null")] + [InlineData("42")] + [InlineData("{}")] + public async Task ListingRejectsMalformedEntriesWithoutReturningPartialSuccess(string malformed) { _listResponse = CreateListResponse("alpha"); var entries = _listResponse["skills"]!.AsArray(); - entries.Add(null); - entries.Add(JsonNode.Parse("42")); - entries.Add(new JsonObject()); + entries.Add(JsonNode.Parse(malformed)); _listResponse["nextCursor"] = "next-page"; _listResponse["ttlMs"] = 3000; _listResponse["cacheScope"] = "public"; _listResponse["resultType"] = "complete"; await using var client = await CreateMcpClientForServer(); - var result = await client.ListSkillsWithDiagnosticsAsync( - new ListSkillsRequestParams(), TestContext.Current.CancellationToken); - - Assert.Single(result.Page.Skills); - Assert.Equal(new[] { 1, 2, 3 }, result.RejectedEntries.Select(rejected => rejected.Index)); - Assert.Equal("next-page", result.Page.NextCursor); - Assert.Equal(TimeSpan.FromSeconds(3), result.Page.TimeToLive); - Assert.Equal(CacheScope.Public, result.Page.CacheScope); - Assert.Equal("complete", result.Page.ResultType); - Assert.Equal(4, entries.Count); - } - - [Theory] - [InlineData("no-skills-array")] - [InlineData("missing-skills-array")] - public async Task DiagnosticListingStillRejectsAnInvalidPageEnvelope(string cursor) - { - await using var client = await CreateMcpClientForServer(); - await Assert.ThrowsAsync( - async () => await client.ListSkillsWithDiagnosticsAsync( - new ListSkillsRequestParams { Cursor = cursor }, TestContext.Current.CancellationToken)); + async () => await client.ListSkillsAsync(new ListSkillsRequestParams(), TestContext.Current.CancellationToken)); + await Assert.ThrowsAsync( + async () => await client.ListSkillsAsync(cancellationToken: TestContext.Current.CancellationToken)); + Assert.Equal(2, entries.Count); } [Fact] - public async Task DiagnosticListingReportsAnAllInvalidPageRatherThanHidingItsFailure() + public async Task GetSkillAsync_RejectsAnExplicitNullSkill() { - _listResponse = CreateListResponse("alpha", new JsonObject { ["version"] = 1 }); - _listResponse["nextCursor"] = "next-page"; + _getResponse = new JsonObject { ["skill"] = null }; await using var client = await CreateMcpClientForServer(); - var result = await client.ListSkillsWithDiagnosticsAsync( - new ListSkillsRequestParams(), TestContext.Current.CancellationToken); - - Assert.Empty(result.Page.Skills); - Assert.Contains("metadata", Assert.Single(result.RejectedEntries).Error.Message); - Assert.Equal("next-page", result.Page.NextCursor); + await Assert.ThrowsAsync( + async () => await client.GetSkillAsync("skill://alpha/SKILL.md", cancellationToken: TestContext.Current.CancellationToken)); } [Theory] @@ -375,18 +335,14 @@ public async Task ClientMethodsRejectSpecFieldsThatAreNotStrings(string field, s var uri = entry["uri"]!.GetValue(); await using var client = await CreateMcpClientForServer(); - var listError = await Assert.ThrowsAsync( - async () => await client.ListSkillsAsync(TestContext.Current.CancellationToken)); - var getError = await Assert.ThrowsAsync( - async () => await client.GetSkillAsync(uri, TestContext.Current.CancellationToken)); - var diagnostic = await client.ListSkillsWithDiagnosticsAsync( - new ListSkillsRequestParams(), TestContext.Current.CancellationToken); + var listError = await Assert.ThrowsAsync( + async () => await client.ListSkillsAsync(new ListSkillsRequestParams(), cancellationToken: TestContext.Current.CancellationToken)); + var getError = await Assert.ThrowsAsync( + async () => await client.GetSkillAsync(uri, cancellationToken: TestContext.Current.CancellationToken)); var expected = $"'{field}' frontmatter field that is not a string"; Assert.Contains(expected, listError.Message); Assert.Contains(expected, getError.Message); - Assert.Empty(diagnostic.Page.Skills); - Assert.Contains(expected, Assert.Single(diagnostic.RejectedEntries).Error.Message); } [Fact] @@ -395,14 +351,14 @@ public async Task GetSkillAsync_RejectsAValidEntryForADifferentUri() await using McpClient client = await CreateMcpClientForServer(); // The entry itself is valid; it is just not the skill that was asked for. - var exception = await Assert.ThrowsAsync( - async () => await client.GetSkillAsync("skill://other/SKILL.md", TestContext.Current.CancellationToken)); + var exception = await Assert.ThrowsAsync( + async () => await client.GetSkillAsync("skill://other/SKILL.md", cancellationToken: TestContext.Current.CancellationToken)); Assert.Contains("skill://other/SKILL.md", exception.Message); Assert.Contains("skill://good/SKILL.md", exception.Message); // Asking for the URI the server actually returns succeeds. - var good = await client.GetSkillAsync("skill://good/SKILL.md", TestContext.Current.CancellationToken); + var good = await client.GetSkillAsync("skill://good/SKILL.md", cancellationToken: TestContext.Current.CancellationToken); Assert.Equal("good", good.Name); } @@ -411,8 +367,8 @@ public async Task GetSkillAsync_RejectsAnEntryWhoseManifestEscapesTheSkill() { await using McpClient client = await CreateMcpClientForServer(); - var exception = await Assert.ThrowsAsync( - async () => await client.GetSkillAsync("skill://escape/SKILL.md", TestContext.Current.CancellationToken)); + var exception = await Assert.ThrowsAsync( + async () => await client.GetSkillAsync("skill://escape/SKILL.md", cancellationToken: TestContext.Current.CancellationToken)); Assert.Contains("skills/get", exception.Message); Assert.Contains("'..'", exception.Message); diff --git a/tests/ModelContextProtocol.Tests/Client/McpSkillsEnumerationTests.cs b/tests/ModelContextProtocol.Tests/Client/McpSkillsEnumerationTests.cs deleted file mode 100644 index 3bdfa0c33..000000000 --- a/tests/ModelContextProtocol.Tests/Client/McpSkillsEnumerationTests.cs +++ /dev/null @@ -1,190 +0,0 @@ -#pragma warning disable MCPEXP002 - -using Microsoft.Extensions.DependencyInjection; -using ModelContextProtocol.Client; -using ModelContextProtocol.Extensions.Skills; -using ModelContextProtocol.Protocol; -using ModelContextProtocol.Server; -using System.Text.Json.Nodes; - -namespace ModelContextProtocol.Tests.Client; - -public class McpSkillsEnumerationTests : ClientServerTestBase -{ - private Func? _page; - private readonly List _cursors = []; - - public McpSkillsEnumerationTests(ITestOutputHelper output) : base(output) - { - } - - protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder builder) - { - services.Configure(options => - { - options.Capabilities ??= new ServerCapabilities(); - options.Capabilities.Extensions ??= new Dictionary(); - options.Capabilities.Extensions[SkillsProtocol.ExtensionId] = new JsonObject(); - options.RequestHandlers ??= []; - options.RequestHandlers.Add(new McpServerRequestHandler - { - Method = SkillsProtocol.MethodSkillsList, - Handler = (request, _) => - { - _cursors.Add(request.Params?["cursor"]?.GetValue()); - return new ValueTask(_page!(_cursors.Count)); - }, - }); - }); - } - - [Theory] - [InlineData("repeat")] - [InlineData("")] - public async Task RepeatedCursorFailsInsteadOfLooping(string cursor) - { - _page = number => Page(cursor, $"skill-{number}"); - await using var client = await CreateMcpClientForServer(); - - var error = await Assert.ThrowsAsync( - async () => await client.ListSkillsAsync(TestContext.Current.CancellationToken)); - - Assert.Contains("repeated", error.Message); - Assert.Equal(2, _cursors.Count); - } - - [Fact] - public async Task CursorCyclesAreRejectedEvenWhenTheRepeatIsNotAdjacent() - { - _page = number => Page(number == 2 ? "second" : "first", $"skill-{number}"); - await using var client = await CreateMcpClientForServer(); - - await Assert.ThrowsAsync( - async () => await client.ListSkillsAsync(TestContext.Current.CancellationToken)); - - Assert.Equal(3, _cursors.Count); - } - - [Fact] - public async Task EmptyOpaqueCursorIsUsedBeforeNullEndsTheListing() - { - _page = number => number == 1 ? Page("", "first") : Page(null, "second"); - await using var client = await CreateMcpClientForServer(); - - var skills = await client.ListSkillsAsync(TestContext.Current.CancellationToken); - - Assert.Equal(new[] { "first", "second" }, skills.Select(skill => skill.Name)); - Assert.Equal(2, _cursors.Count); - Assert.Null(_cursors[0]); - Assert.Equal("", _cursors[1]); - } - - [Fact] - public async Task EverChangingCursorsStopAtTheRequestedPageLimit() - { - _page = number => Page($"cursor-{number}"); - await using var client = await CreateMcpClientForServer(); - - var error = await Assert.ThrowsAsync( - async () => await client.ListSkillsAsync(3, 10, TestContext.Current.CancellationToken)); - - Assert.Contains("3 pages", error.Message); - Assert.Equal(3, _cursors.Count); - } - - [Fact] - public async Task DefaultPageLimitIsOneHundred() - { - _page = number => Page($"cursor-{number}"); - await using var client = await CreateMcpClientForServer(); - - var error = await Assert.ThrowsAsync( - async () => await client.ListSkillsAsync(TestContext.Current.CancellationToken)); - - Assert.Contains("100 pages", error.Message); - Assert.Equal(100, _cursors.Count); - } - - [Fact] - public async Task EntryLimitFailsWithoutReturningATruncatedList() - { - _page = number => number == 1 ? Page("next", "a", "b") : Page(null, "c", "d"); - await using var client = await CreateMcpClientForServer(); - - var error = await Assert.ThrowsAsync( - async () => await client.ListSkillsAsync(10, 3, TestContext.Current.CancellationToken)); - - Assert.Contains("3 entries", error.Message); - Assert.Equal(2, _cursors.Count); - } - - [Fact] - public async Task LastPageMayExactlyReachBothLimits() - { - _page = number => number == 1 ? Page("next", "a", "b") : Page(null, "c"); - await using var client = await CreateMcpClientForServer(); - - var skills = await client.ListSkillsAsync(2, 3, TestContext.Current.CancellationToken); - - Assert.Equal(3, skills.Count); - Assert.Equal(2, _cursors.Count); - } - - [Fact] - public async Task DefaultEntryLimitIsOneThousandAndCanBeChangedExplicitly() - { - _page = _ => Page(null, Enumerable.Range(0, 1001).Select(index => $"skill-{index}").ToArray()); - await using var client = await CreateMcpClientForServer(); - - var error = await Assert.ThrowsAsync( - async () => await client.ListSkillsAsync(TestContext.Current.CancellationToken)); - Assert.Contains("1000 entries", error.Message); - - var skills = await client.ListSkillsAsync(1, 1001, TestContext.Current.CancellationToken); - Assert.Equal(1001, skills.Count); - Assert.Equal(2, _cursors.Count); - } - - [Theory] - [InlineData(0, 1)] - [InlineData(-1, 1)] - [InlineData(1, 0)] - [InlineData(1, -1)] - public async Task InvalidLimitsFailBeforeRequestingAPage(int maxPages, int maxSkills) - { - _page = _ => Page(null); - await using var client = await CreateMcpClientForServer(); - - await Assert.ThrowsAsync( - async () => await client.ListSkillsAsync(maxPages, maxSkills, TestContext.Current.CancellationToken)); - Assert.Empty(_cursors); - } - - [Fact] - public async Task CancellationStopsBeforeRequestingAPage() - { - _page = _ => Page(null); - await using var client = await CreateMcpClientForServer(); - using var cancellation = new CancellationTokenSource(); - cancellation.Cancel(); - - await Assert.ThrowsAnyAsync( - async () => await client.ListSkillsAsync(cancellation.Token)); - Assert.Empty(_cursors); - } - - private JsonObject Page(string? nextCursor, params string[] names) - { - var entries = new JsonArray(); - foreach (var name in names) - { - entries.Add(new JsonObject - { - ["uri"] = $"skill://{name}/SKILL.md", - ["frontmatter"] = new JsonObject { ["name"] = name, ["description"] = "d" }, - ["resources"] = "dynamic", - }); - } - return new JsonObject { ["skills"] = entries, ["nextCursor"] = nextCursor }; - } -} diff --git a/tests/ModelContextProtocol.Tests/Client/McpSkillsPaginationTests.cs b/tests/ModelContextProtocol.Tests/Client/McpSkillsPaginationTests.cs new file mode 100644 index 000000000..79b76083f --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Client/McpSkillsPaginationTests.cs @@ -0,0 +1,106 @@ +#pragma warning disable MCPEXP002 + +using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol.Extensions.Skills; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using System.Text.Json.Nodes; + +namespace ModelContextProtocol.Tests.Client; + +public class McpSkillsPaginationTests(ITestOutputHelper output) : ClientServerTestBase(output) +{ + private Func _page = () => Page("next", "alpha"); + private readonly List _cursors = []; + + protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder builder) + { + services.Configure(options => + { + options.Capabilities ??= new(); + options.Capabilities.Extensions = new Dictionary { [SkillsProtocol.ExtensionId] = new JsonObject() }; + options.RequestHandlers = + [ + new McpServerRequestHandler + { + Method = SkillsProtocol.MethodSkillsList, + Handler = (context, _) => + { + _cursors.Add(context.Params?["cursor"]?.GetValue()); + return new ValueTask(_page()); + }, + }, + ]; + }); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("opaque-cursor")] + public async Task EachCallRequestsOnePageAndPreservesOpaqueCursorsAndHints(string? cursor) + { + _page = () => Page(cursor, "alpha"); + await using var client = await CreateMcpClientForServer(); + + var page = await client.ListSkillsAsync( + new ListSkillsRequestParams { Cursor = cursor }, TestContext.Current.CancellationToken); + + Assert.Equal(cursor, Assert.Single(_cursors)); + Assert.Equal(cursor, page.NextCursor); + Assert.Equal("alpha", Assert.Single(page.Skills).Name); + Assert.Equal(TimeSpan.FromMinutes(1), page.TimeToLive); + Assert.Equal(CacheScope.Public, page.CacheScope); + } + + [Fact] + public async Task AggregationFollowsOpaqueCursorsUntilNull() + { + _page = () => _cursors.Count switch + { + 1 => Page("", "alpha"), + 2 => Page("opaque-cursor"), + 3 => Page("opaque-cursor", "beta"), + _ => Page(null, "gamma"), + }; + + await using var client = await CreateMcpClientForServer(); + + var skills = await client.ListSkillsAsync(cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal(["alpha", "beta", "gamma"], skills.Select(skill => skill.Name)); + Assert.Equal([null, "", "opaque-cursor", "opaque-cursor"], _cursors); + } + + [Fact] + public async Task AggregationRejectsAnInvalidLaterPage() + { + _page = () => _cursors.Count == 1 ? Page("next", "alpha") : Page(null, "Bad"); + await using var client = await CreateMcpClientForServer(); + + await Assert.ThrowsAsync( + async () => await client.ListSkillsAsync(cancellationToken: TestContext.Current.CancellationToken)); + + Assert.Equal(2, _cursors.Count); + } + + private static JsonObject Page(string? nextCursor, params string[] names) + { + var entries = new JsonArray(); + foreach (var name in names) + { + entries.Add(new JsonObject + { + ["uri"] = $"skill://{name}/SKILL.md", + ["frontmatter"] = new JsonObject { ["name"] = name, ["description"] = "d" }, + ["resources"] = "dynamic", + }); + } + + return new JsonObject + { + ["skills"] = entries, ["nextCursor"] = nextCursor, + ["resultType"] = "complete", ["ttlMs"] = 60_000, ["cacheScope"] = "public", + }; + } +} diff --git a/tests/ModelContextProtocol.Tests/Client/SkillVerifierTests.cs b/tests/ModelContextProtocol.Tests/Client/SkillVerifierTests.cs index 8a176c572..4a4ae5295 100644 --- a/tests/ModelContextProtocol.Tests/Client/SkillVerifierTests.cs +++ b/tests/ModelContextProtocol.Tests/Client/SkillVerifierTests.cs @@ -43,7 +43,7 @@ public void Verify_RejectsSizeMismatch() var entry = Entry(content); entry.Size += 1; - var exception = Assert.Throws(() => SkillVerifier.Verify(entry, content)); + var exception = Assert.Throws(() => SkillVerifier.Verify(entry, content)); Assert.Contains("bytes", exception.Message); } @@ -52,7 +52,7 @@ public void Verify_RejectsDigestMismatch() { var entry = Entry(Encoding.UTF8.GetBytes("# Hello\n")); - var exception = Assert.Throws(() => + var exception = Assert.Throws(() => SkillVerifier.Verify(entry, Encoding.UTF8.GetBytes("# Hellp\n"))); Assert.Contains("digest", exception.Message); } @@ -81,7 +81,7 @@ public void Verify_Contents_RejectsMissingTextAsVerificationFailure() { var entry = Entry(Encoding.UTF8.GetBytes("x")); - Assert.Throws(() => SkillVerifier.Verify(entry, new TextResourceContents { Uri = Uri, Text = null! })); + Assert.Throws(() => SkillVerifier.Verify(entry, new TextResourceContents { Uri = Uri, Text = null! })); } [Fact] @@ -93,12 +93,24 @@ public void Verify_BlobContents_HashesDecodedBytes() SkillVerifier.Verify(entry, BlobResourceContents.FromBytes(content, Uri)); } + [Fact] + public void Verify_InvalidBlobEncodingReportsInvalidDataAndPreservesItsCause() + { + var entry = Entry(Encoding.UTF8.GetBytes("x")); + var contents = new BlobResourceContents { Uri = Uri, Blob = Encoding.UTF8.GetBytes("not base64!") }; + + var error = Assert.Throws(() => SkillVerifier.Verify(entry, contents)); + + Assert.IsType(error.InnerException); + Assert.Contains(Uri, error.Message); + } + [Fact] public void Verify_Contents_RejectsUriMismatch() { var entry = Entry(Encoding.UTF8.GetBytes("x")); - Assert.Throws(() => + Assert.Throws(() => SkillVerifier.Verify(entry, new TextResourceContents { Uri = "skill://alpha/other.md", Text = "x" })); } @@ -107,11 +119,38 @@ public void Verify_Skill_ChecksEveryContentAgainstManifest() { byte[] content = Encoding.UTF8.GetBytes("x"); var skill = CreateSkill(content); + var other = new SkillResource { Uri = "skill://alpha/other.md", Digest = SkillVerifier.ComputeDigest(content), Size = content.Length }; + skill.Resources = SkillResources.FromResources([Entry(content), other]); + var otherContents = new TextResourceContents { Uri = "skill://alpha/%6Fther.md", Text = "x" }; + var result = new ReadResourceResult + { + Contents = [new TextResourceContents { Uri = "SKILL://ALPHA/SKILL.md", Text = "x" }, otherContents], + }; + + SkillVerifier.Verify(skill, result); + SkillVerifier.Verify(skill, "skill://alpha/%53KILL.md", result); - SkillVerifier.Verify(skill, new ReadResourceResult { Contents = [new TextResourceContents { Uri = Uri, Text = "x" }] }); + otherContents.Text = "y"; + Assert.Throws(() => SkillVerifier.Verify(skill, result)); + Assert.Throws(() => SkillVerifier.Verify(skill, Uri, result)); - Assert.Throws(() => SkillVerifier.Verify(skill, - new ReadResourceResult { Contents = [new TextResourceContents { Uri = Uri, Text = "y" }] })); + otherContents.Text = "xx"; + Assert.Throws(() => SkillVerifier.Verify(skill, result)); + Assert.Throws(() => SkillVerifier.Verify(skill, Uri, result)); + } + + [Fact] + public void Verify_Skill_UnreferencedAmbiguousUrisDoNotAffectVerification() + { + byte[] content = Encoding.UTF8.GetBytes("x"); + var skill = CreateSkill(content); + var other = new SkillResource { Uri = "skill://alpha/other.md", Digest = SkillVerifier.ComputeDigest(content), Size = content.Length }; + var alias = new SkillResource { Uri = "SKILL://ALPHA/other.md", Digest = other.Digest, Size = other.Size }; + skill.Resources = SkillResources.FromResources([other, alias, Entry(content)]); + var result = new ReadResourceResult { Contents = [new TextResourceContents { Uri = Uri, Text = "x" }] }; + + SkillVerifier.Verify(skill, result); + SkillVerifier.Verify(skill, Uri, result); } [Fact] @@ -119,7 +158,7 @@ public void Verify_Skill_RejectsUnlistedFile() { var skill = CreateSkill(Encoding.UTF8.GetBytes("x")); - var exception = Assert.Throws(() => SkillVerifier.Verify(skill, + var exception = Assert.Throws(() => SkillVerifier.Verify(skill, new ReadResourceResult { Contents = [new TextResourceContents { Uri = "skill://alpha/new.md", Text = "x" }] })); Assert.Contains("not listed", exception.Message); } @@ -129,7 +168,7 @@ public void Verify_Skill_RejectsEmptyResult() { var skill = CreateSkill(Encoding.UTF8.GetBytes("x")); - Assert.Throws(() => SkillVerifier.Verify(skill, new ReadResourceResult())); + Assert.Throws(() => SkillVerifier.Verify(skill, new ReadResourceResult())); } [Theory] @@ -142,9 +181,19 @@ public void Verify_Skill_RejectsNullWireContentsAsVerificationFailure(string jso var result = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); Assert.NotNull(result); - var error = Assert.Throws(() => SkillVerifier.Verify(skill, result)); + var error = Assert.Throws(() => SkillVerifier.Verify(skill, result)); Assert.Contains("null", error.Message); - Assert.Throws(() => SkillVerifier.Verify(skill, Uri, result)); + Assert.Throws(() => SkillVerifier.Verify(skill, Uri, result)); + } + + [Fact] + public void Verify_Skill_RejectsMissingContentUriAsVerificationFailure() + { + var skill = CreateSkill(Encoding.UTF8.GetBytes("x")); + var result = new ReadResourceResult { Contents = [new TextResourceContents { Uri = null!, Text = "x" }] }; + + Assert.Contains("not listed", Assert.Throws(() => SkillVerifier.Verify(skill, result)).Message); + Assert.Contains("not listed", Assert.Throws(() => SkillVerifier.Verify(skill, Uri, result)).Message); } [Fact] @@ -184,12 +233,12 @@ public void Verify_SkillAndUri_RequiresTheRequestedFileInTheResult() // A correctly digested but different file of the same skill must not satisfy a read of SKILL.md. var substituted = new ReadResourceResult { Contents = [new TextResourceContents { Uri = "skill://alpha/other.md", Text = "other" }] }; SkillVerifier.Verify(skill, substituted); - var exception = Assert.Throws(() => SkillVerifier.Verify(skill, Uri, substituted)); + var exception = Assert.Throws(() => SkillVerifier.Verify(skill, Uri, substituted)); Assert.Contains("returned no contents for that URI", exception.Message); SkillVerifier.Verify(skill, Uri, new ReadResourceResult { Contents = [new TextResourceContents { Uri = Uri, Text = "x" }] }); - Assert.Throws(() => + Assert.Throws(() => SkillVerifier.Verify(skill, "skill://alpha/unlisted.md", new ReadResourceResult { Contents = [new TextResourceContents { Uri = Uri, Text = "x" }] })); } diff --git a/tests/ModelContextProtocol.Tests/Client/SkillsRequestOptionsTests.cs b/tests/ModelContextProtocol.Tests/Client/SkillsRequestOptionsTests.cs new file mode 100644 index 000000000..d3cbf9929 --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Client/SkillsRequestOptionsTests.cs @@ -0,0 +1,88 @@ +using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol.Extensions.Skills; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using System.Text.Json.Nodes; + +namespace ModelContextProtocol.Tests.Client; + +public class SkillsRequestOptionsTests : ClientServerTestBase +{ + private const string Markdown = "---\nname: alpha\ndescription: Alpha\n---\n"; + private readonly McpServerSkill _skill = McpServerSkillPackage.Create([McpServerSkillFile.FromText("SKILL.md", Markdown)]).Skill; + private readonly List _metadata = []; + + public SkillsRequestOptionsTests(ITestOutputHelper output) : base(output, startServer: false) + { + McpServerBuilder.WithSkills(options => + { + options.ListSkillsHandler = (context, _) => + { + _metadata.Add(context.Params.Meta); + return new(new ListSkillsResult + { + Skills = context.Params.Cursor is null ? [_skill.ProtocolSkill!] : [], + NextCursor = context.Params.Cursor is null ? "next" : null, + }); + }; + + options.GetSkillHandler = (context, _) => + { + _metadata.Add(context.Params.Meta); + return new(new GetSkillResult { Skill = _skill.ProtocolSkill! }); + }; + }).WithReadResourceHandler((context, _) => + { + _metadata.Add(context.Params.Meta); + return new(new ReadResourceResult { Contents = [new TextResourceContents { Uri = _skill.Id, Text = Markdown }] }); + }); + + StartServer(); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ConvenienceRequestsForwardMetadataAndProgress_WithoutMutatingOptions(bool integer) + { + var metadata = new JsonObject { ["example.com/request"] = "value", ["progressToken"] = "old" }; + var options = new RequestOptions { Meta = metadata, ProgressToken = integer ? new ProgressToken(42) : new ProgressToken("progress") }; + await using var client = await CreateMcpClientForServer(); + var skills = await client.ListSkillsAsync(options, TestContext.Current.CancellationToken); + Assert.Single(skills); + var skill = await client.GetSkillAsync(_skill.Id, options, TestContext.Current.CancellationToken); + Assert.Single((await client.ReadSkillResourceAsync(skill, skill.Uri, options, TestContext.Current.CancellationToken)).Contents); + Assert.Equal(4, _metadata.Count); + Assert.All(_metadata, meta => + { + Assert.Equal("value", meta!["example.com/request"]!.GetValue()); + if (integer) Assert.Equal(42, meta["progressToken"]!.GetValue()); + else Assert.Equal("progress", meta["progressToken"]!.GetValue()); + }); + + Assert.Equal("old", metadata["progressToken"]!.GetValue()); + } + + [Theory] + [InlineData("list")] + [InlineData("get")] + [InlineData("read")] + public async Task ConvenienceRequestsPropagateCancellation(string operation) + { + await using var client = await CreateMcpClientForServer(); + using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + cancellation.Cancel(); + var options = new RequestOptions { Meta = new JsonObject { ["example.com/request"] = "value" } }; + await Assert.ThrowsAnyAsync(async () => + { + switch (operation) + { + case "list": await client.ListSkillsAsync(options, cancellation.Token); break; + case "get": await client.GetSkillAsync(_skill.Id, options, cancellation.Token); break; + case "read": await client.ReadSkillResourceAsync(_skill.ProtocolSkill!, _skill.Id, options, cancellation.Token); break; + } + }); + + Assert.Empty(_metadata); + } +} diff --git a/tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj b/tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj index 1ac0cbaa7..16398bed1 100644 --- a/tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj +++ b/tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj @@ -11,7 +11,7 @@ true ModelContextProtocol.Tests - $(NoWarn);NU1903;NU1902;MCP9006 + $(NoWarn);NU1903;NU1902;MCP9006;MCPEXP005 $(DefineConstants);MCP_TEST_TIME_PROVIDER @@ -42,6 +42,7 @@ + diff --git a/tests/ModelContextProtocol.Tests/Protocol/SkillSerializationTests.cs b/tests/ModelContextProtocol.Tests/Protocol/SkillSerializationTests.cs index aba2221e9..6036a110d 100644 --- a/tests/ModelContextProtocol.Tests/Protocol/SkillSerializationTests.cs +++ b/tests/ModelContextProtocol.Tests/Protocol/SkillSerializationTests.cs @@ -1,5 +1,7 @@ using ModelContextProtocol.Extensions.Skills; using ModelContextProtocol.Protocol; +using System.Reflection; +using System.Runtime.CompilerServices; using System.Text.Json; using System.Text.Json.Nodes; @@ -11,6 +13,27 @@ namespace ModelContextProtocol.Tests.Protocol; /// public class SkillSerializationTests { + [Fact] + public void PublicSkillsApisUseTheDedicatedExperimentalDiagnostic() + { + Assert.All(typeof(Skill).Assembly.GetExportedTypes(), type => + { + AssertExperimental(type); + // Extension calls do not name the containing type, so methods need their own diagnostic. + Assert.All(type.GetMethods(BindingFlags.Public | BindingFlags.Static | BindingFlags.DeclaredOnly) + .Where(method => method.IsDefined(typeof(ExtensionAttribute), inherit: false)), AssertExperimental); + }); + + static void AssertExperimental(MemberInfo member) + { + var attribute = Assert.Single(member.GetCustomAttributesData(), + attribute => attribute.AttributeType.FullName == "System.Diagnostics.CodeAnalysis.ExperimentalAttribute"); + Assert.Equal("MCPEXP005", Assert.Single(attribute.ConstructorArguments).Value); + Assert.Equal("https://github.com/modelcontextprotocol/csharp-sdk/blob/main/docs/list-of-diagnostics.md#mcpexp005", + Assert.Single(attribute.NamedArguments, argument => argument.MemberName == "UrlFormat").TypedValue.Value); + } + } + private Skill CreateEntry(SkillResources resources) => new() { Uri = "skill://git-workflow/SKILL.md", @@ -39,6 +62,7 @@ public void Skill_WithEnumeratedResources_RoundTrips() ])); string json = JsonSerializer.Serialize(original, McpSkillsJsonContext.Default.Skill); + Assert.Equal(2, JsonNode.Parse(json)!["resources"]!.AsArray().Count); var deserialized = JsonSerializer.Deserialize(json, McpSkillsJsonContext.Default.Skill); Assert.NotNull(deserialized); @@ -47,7 +71,7 @@ public void Skill_WithEnumeratedResources_RoundTrips() Assert.Equal("Follow this team's Git conventions", deserialized.Description); Assert.False(deserialized.Resources.IsDynamic); - var resources = deserialized.Resources.Resources; + var resources = deserialized.Resources.Entries; Assert.NotNull(resources); Assert.Equal(2, resources.Count); Assert.Equal("skill://git-workflow/SKILL.md", resources[0].Uri); @@ -55,6 +79,20 @@ public void Skill_WithEnumeratedResources_RoundTrips() Assert.Equal("sha256:" + new string('b', 64), resources[1].Digest); } + [Fact] + public void Skill_RejectsNullManifestDuringSerialization() + { + Assert.Throws(() => JsonSerializer.Serialize(CreateEntry(null!), McpSkillsJsonContext.Default.Skill)); + Assert.Throws(() => JsonSerializer.Serialize(null!, McpSkillsJsonContext.Default.SkillResources)); + } + + [Fact] + public void Skill_RejectsNullManifestEntriesDuringSerialization() + { + Assert.Throws(() => JsonSerializer.Serialize( + CreateEntry(SkillResources.FromResources([null!])), McpSkillsJsonContext.Default.Skill)); + } + [Fact] public void Skill_WithDynamicResources_SerializesAsTheDynamicString() { @@ -72,7 +110,7 @@ public void Skill_WithDynamicResources_RoundTrips() Assert.NotNull(deserialized); Assert.True(deserialized.Resources.IsDynamic); - Assert.Null(deserialized.Resources.Resources); + Assert.Null(deserialized.Resources.Entries); } [Fact] @@ -83,7 +121,7 @@ public void Skill_WithEmptyResourceArray_RoundTripsAsNonDynamic() Assert.NotNull(deserialized); Assert.False(deserialized.Resources.IsDynamic); - Assert.Empty(deserialized.Resources.Resources!); + Assert.Empty(deserialized.Resources.Entries); } [Fact] @@ -274,20 +312,6 @@ public void Skill_WithoutResources_Throws() Assert.ThrowsAny(() => JsonSerializer.Deserialize(Json, McpSkillsJsonContext.Default.Skill)); } - [Fact] - public void SkillResources_FromResources_CopiesTheSource() - { - var mutable = new List - { - CreateResource("skill://a/SKILL.md", "sha256:" + new string('c', 64), 1), - }; - - var resources = SkillResources.FromResources(mutable); - mutable.Add(CreateResource("skill://a/extra.md", "sha256:" + new string('d', 64), 2)); - - Assert.Single(resources.Resources!); - } - [Fact] public void SkillResources_FromResources_WithNull_Throws() => Assert.Throws(() => SkillResources.FromResources(null!)); @@ -343,6 +367,36 @@ public void ListSkillsResult_OmitsOptionalFieldsWhenUnset() Assert.Empty(node["skills"]!.AsArray()); } + [Theory] + [InlineData("null")] + [InlineData("[null]")] + public void ListSkillsResult_AllowsExplicitNullMembersAtTheDtoLevel(string skills) + { + var result = JsonSerializer.Deserialize($$"""{"skills":{{skills}}}""", McpSkillsJsonContext.Default.ListSkillsResult); + + Assert.NotNull(result); + if (skills == "null") Assert.Null(result.Skills); + else Assert.Null(Assert.Single(result.Skills)); + Assert.NotNull(JsonSerializer.SerializeToNode(result, McpSkillsJsonContext.Default.ListSkillsResult)); + } + + [Fact] + public void GetSkillResult_AllowsAnExplicitNullSkillAtTheDtoLevel() + { + var result = JsonSerializer.Deserialize("""{"skill":null}""", McpSkillsJsonContext.Default.GetSkillResult); + + Assert.NotNull(result); + Assert.Null(result.Skill); + Assert.NotNull(JsonSerializer.SerializeToNode(result, McpSkillsJsonContext.Default.GetSkillResult)); + } + + [Fact] + public void ResultsStillRequireTheirEntryMembers() + { + Assert.Throws(() => JsonSerializer.Deserialize("{}", McpSkillsJsonContext.Default.ListSkillsResult)); + Assert.Throws(() => JsonSerializer.Deserialize("{}", McpSkillsJsonContext.Default.GetSkillResult)); + } + [Fact] public void GetSkillResult_RoundTrips() { diff --git a/tests/ModelContextProtocol.Tests/Samples/DatabaseStyleSkillsHandlersTests.cs b/tests/ModelContextProtocol.Tests/Samples/DatabaseStyleSkillsHandlersTests.cs new file mode 100644 index 000000000..4abf92126 --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Samples/DatabaseStyleSkillsHandlersTests.cs @@ -0,0 +1,198 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using ModelContextProtocol.Extensions.Skills; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using ModelContextProtocol.Tests.Utils; +using SkillsServer; +using System.Security.Claims; +using System.Text; +using System.Text.Json.Nodes; + +namespace ModelContextProtocol.Tests.Samples; + +public class DatabaseStyleSkillsHandlersTests : ClientServerTestBase +{ + private ClaimsPrincipal _caller = CreateCaller("acme"); + private readonly ExampleSkillStore _store = new( + [ + ExampleSkillRow.Create("acme", "aa-denied", requiredRole: "administrator"), + ExampleSkillRow.Create("other", "ab-other"), + ExampleSkillRow.Create("acme", "alpha"), + ExampleSkillRow.Create("acme", "beta"), + ExampleSkillRow.Create("acme", "hidden", listed: false), + ]); + + public DatabaseStyleSkillsHandlersTests(ITestOutputHelper output) : base(output, startServer: false) + { + ServiceCollection.AddSingleton(_store); + McpServerBuilder.WithRepositoryHandlers() + .WithMessageFilters(filters => filters.AddIncomingFilter(next => (context, cancellationToken) => + { + context.User = _caller; + return next(context, cancellationToken); + })); + StartServer(); + } + + [Theory] + [InlineData("skill://store/acme/alpha/SKILL.md")] + [InlineData("SKILL://STORE/acme/alpha/SKILL.md")] + [InlineData("skill://store/acme/%61lpha/SKILL.md")] + public async Task GetAndReadResolveAliasesToTheSameCanonicalRow(string uri) + { + const string Canonical = "skill://store/acme/alpha/SKILL.md"; + await using var client = await CreateMcpClientForServer(); + + var entry = await client.GetSkillAsync(uri, cancellationToken: TestContext.Current.CancellationToken); + var result = await client.ReadSkillResourceAsync(entry, uri, cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal(Canonical, entry.Uri); + var content = Assert.IsType(Assert.Single(result.Contents)); + Assert.Equal(Canonical, content.Uri); + Assert.Equal(_store.Rows.Single(row => row.Skill.Id == Canonical).Markdown, content.Text); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task TenantAndPermissionFilteringHappensBeforePaging_AndCursorsDoNotNameDeniedRows(bool resources) + { + await using var client = await CreateMcpClientForServer(); + var seen = new List(); + string? cursor = null; + int pages = 0; + do + { + string[] uris; + string? nextCursor; + if (resources) + { + var result = await client.ListResourcesAsync(new ListResourcesRequestParams { Cursor = cursor }, TestContext.Current.CancellationToken); + uris = result.Resources.Select(resource => resource.Uri).ToArray(); + nextCursor = result.NextCursor; + } + else + { + var result = await client.ListSkillsAsync(new ListSkillsRequestParams { Cursor = cursor }, cancellationToken: TestContext.Current.CancellationToken); + uris = result.Skills.Select(skill => skill.Uri).ToArray(); + nextCursor = result.NextCursor; + } + + seen.Add(Assert.Single(uris)); + if (nextCursor is not null) + { + Assert.Equal(uris[0], Encoding.UTF8.GetString(Convert.FromBase64String(nextCursor))); + } + + cursor = nextCursor; + Assert.True(++pages <= 2); + } + while (cursor is not null); + Assert.Equal(2, pages); + Assert.Equal(["skill://store/acme/alpha/SKILL.md", "skill://store/acme/beta/SKILL.md"], seen); + } + + [Fact] + public async Task PermittedUnlistedSkillAndFileAreDirectlyRetrievable() + { + const string Uri = "skill://store/acme/hidden/SKILL.md"; + await using var client = await CreateMcpClientForServer(); + Assert.DoesNotContain(await client.ListSkillsAsync(cancellationToken: TestContext.Current.CancellationToken), entry => entry.Uri == Uri); + Assert.DoesNotContain(await client.ListResourcesAsync(cancellationToken: TestContext.Current.CancellationToken), entry => entry.Uri == Uri); + + var entry = await client.GetSkillAsync(Uri, cancellationToken: TestContext.Current.CancellationToken); + var read = await client.ReadSkillResourceAsync(entry, Uri, cancellationToken: TestContext.Current.CancellationToken); + var text = Assert.IsType(Assert.Single(read.Contents)); + Assert.Equal(_store.Rows.Single(row => row.Skill.Id == Uri).Markdown, text.Text); + } + + [Theory] + [InlineData("skill://store/acme/aa-denied/SKILL.md")] + [InlineData("SKILL://STORE/acme/aa-denied/SKILL.md")] + [InlineData("skill://store/other/ab-other/SKILL.md")] + [InlineData("skill://store/other/%61b-other/SKILL.md")] + [InlineData("skill://store/acme/Alpha/SKILL.md")] + [InlineData("skill://store/acme/alpha/SKILL.md?query")] + public async Task DirectMetadataAndFileAccessEnforceTheSameTenantAndPermissionChecks(string uri) + { + await using var client = await CreateMcpClientForServer(); + + var metadataError = await Assert.ThrowsAsync( + async () => await client.GetSkillAsync(uri, cancellationToken: TestContext.Current.CancellationToken)); + var fileError = await Assert.ThrowsAsync( + async () => await client.ReadResourceAsync(uri, cancellationToken: TestContext.Current.CancellationToken)); + Assert.Equal(McpErrorCode.InvalidParams, metadataError.ErrorCode); + Assert.Equal(McpErrorCode.InvalidParams, fileError.ErrorCode); + } + + [Fact] + public async Task CallerChangesAreReflectedByBothHandlersWithoutChangingRegistrations() + { + await using var client = await CreateMcpClientForServer(); + var first = await client.ListSkillsAsync(new ListSkillsRequestParams(), cancellationToken: TestContext.Current.CancellationToken); + Assert.Single(first.Skills); + Assert.NotNull(first.NextCursor); + _caller = CreateCaller("other"); + + var skill = Assert.Single(await client.ListSkillsAsync(cancellationToken: TestContext.Current.CancellationToken)); + var resource = Assert.Single(await client.ListResourcesAsync(cancellationToken: TestContext.Current.CancellationToken)); + Assert.Equal("skill://store/other/ab-other/SKILL.md", skill.Uri); + Assert.Equal(skill.Uri, resource.Uri); + Assert.Single((await client.ReadSkillResourceAsync(skill, skill.Uri, cancellationToken: TestContext.Current.CancellationToken)).Contents); + } + + [Theory] + [InlineData("skills/list", false)] + [InlineData("skills/list", true)] + [InlineData("skills/get", false)] + [InlineData("skills/get", true)] + [InlineData("resources/list", false)] + [InlineData("resources/list", true)] + [InlineData("resources/read", false)] + [InlineData("resources/read", true)] + public async Task EveryOperationRequiresAnAuthenticatedTenant(string method, bool missingTenant) + { + _caller = missingTenant ? + new(new ClaimsIdentity([new Claim(ClaimTypes.Role, "reader")], "Example")) : + new(new ClaimsIdentity([new Claim("tenant", "acme"), new Claim(ClaimTypes.Role, "reader")])); + await using var client = await CreateMcpClientForServer(); + + var error = await Assert.ThrowsAsync(() => client.SendRequestAsync(new JsonRpcRequest + { + Method = method, + Params = new JsonObject { ["uri"] = "skill://store/acme/missing/SKILL.md" }, + }, TestContext.Current.CancellationToken)); + Assert.Equal(McpErrorCode.InvalidRequest, error.ErrorCode); + } + + [Fact] + public async Task MissingProviderHasAnActionableLocalDiagnostic() + { + var skills = ServiceProvider.GetRequiredService>().Value; + var context = new RequestContext( + Server, new JsonRpcRequest { Method = SkillsProtocol.MethodSkillsList }, new()) { Services = null }; + var error = await Assert.ThrowsAsync( + async () => await skills.ListSkillsHandler!(context, TestContext.Current.CancellationToken)); + + Assert.Contains("Pass the configured service provider to McpServer.Create", error.Message); + Assert.DoesNotContain("NullReferenceException", error.ToString()); + } + + [Theory] + [InlineData("skills/list")] + [InlineData("resources/list")] + public async Task RepositoryCursorErrorsAreReportedAsInvalidParams(string method) + { + await using var client = await CreateMcpClientForServer(); + var error = await Assert.ThrowsAsync(() => client.SendRequestAsync(new JsonRpcRequest + { + Method = method, + Params = new JsonObject { ["cursor"] = "not-base64!" }, + }, TestContext.Current.CancellationToken)); + Assert.Equal(McpErrorCode.InvalidParams, error.ErrorCode); + } + + private static ClaimsPrincipal CreateCaller(string tenant) => + new(new ClaimsIdentity([new Claim("tenant", tenant), new Claim(ClaimTypes.Role, "reader")], "Example")); +} diff --git a/tests/ModelContextProtocol.Tests/Samples/SkillsClientFrontmatterReaderTests.cs b/tests/ModelContextProtocol.Tests/Samples/SkillsClientFrontmatterReaderTests.cs index 265d0d6be..57fe615d7 100644 --- a/tests/ModelContextProtocol.Tests/Samples/SkillsClientFrontmatterReaderTests.cs +++ b/tests/ModelContextProtocol.Tests/Samples/SkillsClientFrontmatterReaderTests.cs @@ -24,7 +24,7 @@ public void BlockScalarAtClosingFence_MatchesTheServer(string header, string con markdown = markdown.Replace("\n", "\r\n"); } - var served = McpServerSkill.Create([McpServerSkillFile.FromText("SKILL.md", markdown)]).ProtocolSkill.Frontmatter; + var served = McpServerSkillPackage.Create([McpServerSkillFile.FromText("SKILL.md", markdown)]).Skill.ProtocolSkill!.Frontmatter; var fetched = new YamlFrontmatterReader().Read(markdown); @@ -144,7 +144,7 @@ public void SecondByteOrderMark_IsRejectedLikeTheServer() var markdown = "\uFEFF\uFEFF---\nname: alpha\ndescription: d\n---\n"; Assert.Throws(() => new YamlFrontmatterReader().Read(markdown)); - Assert.Throws(() => McpServerSkill.Create([McpServerSkillFile.FromText("SKILL.md", markdown)])); + Assert.Throws(() => McpServerSkillPackage.Create([McpServerSkillFile.FromText("SKILL.md", markdown)]).Skill); } [Fact] @@ -152,7 +152,7 @@ public void EscapedNul_MatchesTheServer() { var markdown = "---\nname: alpha\ndescription: \"x\\0y\"\n---\n"; - var served = McpServerSkill.Create([McpServerSkillFile.FromText("SKILL.md", markdown)]).ProtocolSkill.Frontmatter; + var served = McpServerSkillPackage.Create([McpServerSkillFile.FromText("SKILL.md", markdown)]).Skill.ProtocolSkill!.Frontmatter; var fetched = new YamlFrontmatterReader().Read(markdown); Assert.Equal("x\0y", fetched["description"]!.GetValue()); @@ -168,7 +168,7 @@ public void EscapedCharacterSomeParsersReadAsALineBreak_MatchesTheServer(string { var markdown = "---\nname: alpha\ndescription: \"x" + escape + "y\"\n---\n"; - var served = McpServerSkill.Create([McpServerSkillFile.FromText("SKILL.md", markdown)]).ProtocolSkill.Frontmatter; + var served = McpServerSkillPackage.Create([McpServerSkillFile.FromText("SKILL.md", markdown)]).Skill.ProtocolSkill!.Frontmatter; var fetched = new YamlFrontmatterReader().Read(markdown); Assert.Equal("x" + char.ConvertFromUtf32(codePoint) + "y", fetched["description"]!.GetValue()); @@ -180,7 +180,7 @@ private void AssertRejectedLikeTheServer(string entry, int codePoint) var markdown = $"---\nname: alpha\n{entry}\n---\n"; var sampleException = Record.Exception(() => new YamlFrontmatterReader().Read(markdown)); - var serverException = Record.Exception(() => McpServerSkill.Create([McpServerSkillFile.FromText("SKILL.md", markdown)])); + var serverException = Record.Exception(() => McpServerSkillPackage.Create([McpServerSkillFile.FromText("SKILL.md", markdown)]).Skill); string character = $"U+{codePoint:X4}"; Assert.Contains(character, Assert.IsType(sampleException).Message); diff --git a/tests/ModelContextProtocol.Tests/Server/CustomRequestHandlerCollisionTests.cs b/tests/ModelContextProtocol.Tests/Server/CustomRequestHandlerCollisionTests.cs index 56e8fd1e6..712b438e5 100644 --- a/tests/ModelContextProtocol.Tests/Server/CustomRequestHandlerCollisionTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/CustomRequestHandlerCollisionTests.cs @@ -76,6 +76,21 @@ public async Task CustomHandler_UniqueMethod_Succeeds() Assert.NotNull(server); } + [Fact] + public async Task CustomHandler_NullHandler_ThrowsAtStartup() + { + await using var transport = new StreamServerTransport(Stream.Null, Stream.Null); + var options = new McpServerOptions + { + RequestHandlers = [new McpServerRequestHandler { Method = "custom/method", Handler = null! }], + }; + + var exception = Assert.Throws(() => McpServer.Create(transport, options, LoggerFactory)); + + Assert.Contains("custom/method", exception.Message); + Assert.Contains(nameof(McpServerRequestHandler.Handler), exception.Message); + } + [Fact] public async Task CustomHandler_EmptyRoutingNameParameter_Throws() { diff --git a/tests/ModelContextProtocol.Tests/Server/CustomRequestHandlerTests.cs b/tests/ModelContextProtocol.Tests/Server/CustomRequestHandlerTests.cs new file mode 100644 index 000000000..93c931d73 --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Server/CustomRequestHandlerTests.cs @@ -0,0 +1,160 @@ +#pragma warning disable MCPEXP002 + +using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using ModelContextProtocol.Tests.Utils; +using System.IO.Pipelines; +using System.Text.Json.Nodes; + +namespace ModelContextProtocol.Tests.Server; + +public class CustomRequestHandlerTests(ITestOutputHelper output) : LoggedTest(output) +{ + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task CustomHandler_RespectsBorrowedScopeOwnership(bool scopeRequests) + { + var services = new ServiceCollection().AddScoped(); + await using var provider = services.BuildServiceProvider(validateScopes: true); + AsyncDependency? requestDependency = null; + AsyncDependency borrowedDependency; + await using (var scope = provider.CreateAsyncScope()) + { + borrowedDependency = scope.ServiceProvider.GetRequiredService(); + var options = new McpServerOptions + { + ScopeRequests = scopeRequests, + RequestHandlers = + [ + new McpServerRequestHandler + { + Method = "custom/scope", + Handler = (context, _) => + { + requestDependency = context.Services!.GetRequiredService(); + return new ValueTask(new JsonObject()); + }, + }, + ], + }; + await InvokeAsync(options, scope.ServiceProvider, async client => + await client.SendRequestAsync(new JsonRpcRequest { Method = "custom/scope" }, TestContext.Current.CancellationToken)); + + Assert.NotNull(requestDependency); + Assert.Equal(0, borrowedDependency.DisposeCount); + if (scopeRequests) + { + Assert.NotSame(borrowedDependency, requestDependency); + Assert.Equal(1, requestDependency.DisposeCount); + } + else + { + Assert.Same(borrowedDependency, requestDependency); + Assert.Equal(0, requestDependency.DisposeCount); + } + } + + Assert.Equal(1, borrowedDependency.DisposeCount); + Assert.Equal(1, requestDependency.DisposeCount); + } + + [Fact] + public async Task CustomHandlers_IsolateConcurrentClientCapabilities() + { + var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + int count = 0; + var options = new McpServerOptions + { + RequestHandlers = + [ + new McpServerRequestHandler + { + Method = "custom/context", + Handler = async (context, cancellationToken) => + { + if (Interlocked.Increment(ref count) == 2) + { + entered.TrySetResult(true); + } + + await release.Task.WaitAsync(TestConstants.DefaultTimeout, cancellationToken); + return new JsonObject + { + ["client"] = context.Server.ClientInfo?.Name, + ["extension"] = context.Server.ClientCapabilities?.Extensions?.Keys.Single(), + }; + }, + }, + ], + }; + await InvokeAsync(options, services: null, async client => + { + var first = client.SendRequestAsync(CreateRequest("first"), TestContext.Current.CancellationToken); + var second = client.SendRequestAsync(CreateRequest("second"), TestContext.Current.CancellationToken); + try + { + await entered.Task.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken); + } + finally + { + release.TrySetResult(true); + } + + var responses = await Task.WhenAll(first, second); + Assert.All(responses, response => Assert.Equal("custom-handler-client", response.Result!["client"]!.GetValue())); + Assert.Equal("example.com/first", responses[0].Result!["extension"]!.GetValue()); + Assert.Equal("example.com/second", responses[1].Result!["extension"]!.GetValue()); + }); + + static JsonRpcRequest CreateRequest(string name) => new() + { + Method = "custom/context", + Params = new JsonObject + { + ["_meta"] = new JsonObject + { + [MetaKeys.ClientCapabilities] = new JsonObject + { + ["extensions"] = new JsonObject { [$"example.com/{name}"] = new JsonObject() }, + }, + }, + }, + }; + } + + private async Task InvokeAsync(McpServerOptions options, IServiceProvider? services, Func action) + { + Pipe input = new(), output = new(); + await using var server = McpServer.Create(new StreamServerTransport( + input.Reader.AsStream(), output.Writer.AsStream()), options, LoggerFactory, services); + var running = server.RunAsync(TestContext.Current.CancellationToken); + try + { + var clientOptions = TestMcpClient.CreateOptions(); + clientOptions.ClientInfo = new() { Name = "custom-handler-client", Version = "1" }; + await using var client = await TestMcpClient.CreateAsync(new StreamClientTransport( + input.Writer.AsStream(), output.Reader.AsStream()), clientOptions, LoggerFactory, TestContext.Current.CancellationToken); + await action(client); + } + finally + { + await server.DisposeAsync(); + await running.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken); + } + } + + private sealed class AsyncDependency : IAsyncDisposable + { + public int DisposeCount { get; private set; } + + public async ValueTask DisposeAsync() + { + await Task.Yield(); + DisposeCount++; + } + } +} diff --git a/tests/ModelContextProtocol.Tests/Server/DelegatingToolOutputSchemaTests.cs b/tests/ModelContextProtocol.Tests/Server/DelegatingToolOutputSchemaTests.cs index edd2d75df..718160065 100644 --- a/tests/ModelContextProtocol.Tests/Server/DelegatingToolOutputSchemaTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/DelegatingToolOutputSchemaTests.cs @@ -14,6 +14,804 @@ namespace ModelContextProtocol.Tests.Server; public partial class DelegatingToolOutputSchemaTests { + [Theory] + [InlineData("2025-06-18")] + [InlineData("2025-11-25")] + [InlineData("2026-07-28")] + public async Task CallTool_RepeatedInnerInvocations_VisitOnlyTheRetainedResultPlan(string protocolVersion) + { + McpServerTool inner = McpServerTool.Create(() => new MutableNote(), new() + { + Name = "indexed_note", + UseStructuredContent = true, + OutputSchema = ParseJson("""{"type":"object"}"""), + SerializerOptions = new(JsonSerializerDefaults.Web) + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + TypeInfoResolver = new DefaultJsonTypeInfoResolver(), + }, + }); + var repeated = new ObservedRecoveryTool(inner); + McpServerTool tool = new SchemaOverrideTool(repeated, ParseJson("""{"required":["note"]}""")); + await WithClientAsync(tool, protocolVersion, async client => + { + var result = await client.CallToolAsync(tool.ProtocolTool.Name, cancellationToken: TestContext.Current.CancellationToken); + + Assert.NotEqual(true, result.IsError); + Assert.Equal(JsonValueKind.Null, result.StructuredContent!.Value.GetProperty("note").ValueKind); + Assert.Equal(1, repeated.EarlierPlanVisits); + }); + } + + [Theory] + [InlineData("2025-06-18", false)] + [InlineData("2025-06-18", true)] + [InlineData("2025-11-25", false)] + [InlineData("2025-11-25", true)] + [InlineData("2026-07-28", false)] + [InlineData("2026-07-28", true)] + public async Task CallTool_OverlappingFilterDispatches_KeepIndependentRecovery( + string protocolVersion, bool suspendAfterInner) + { + foreach (bool alternateHandler in new[] { false, true }) + { + foreach (bool secondThrows in new[] { false, true }) + { + var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + McpServerTool inner = McpServerTool.Create(() => new MutableNote(), new() + { + Name = "overlapping_note", + UseStructuredContent = true, + OutputSchema = ParseJson("""{"type":"object"}"""), + SerializerOptions = new(JsonSerializerDefaults.Web) + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + TypeInfoResolver = new DefaultJsonTypeInfoResolver(), + }, + }); + McpServerTool tool = new SchemaOverrideTool( + new GatedInvocationTool(inner, entered, release, suspendAfterInner, secondThrows), + ParseJson("""{"type":"object","required":["note"],"properties":{"note":{"type":"null"}}}""")); + await WithClientAsync(tool, protocolVersion, async client => + { + var result = await client.CallToolAsync(tool.ProtocolTool.Name, cancellationToken: TestContext.Current.CancellationToken); + + Assert.NotEqual(true, result.IsError); + Assert.Equal(JsonValueKind.Null, result.StructuredContent!.Value.GetProperty("note").ValueKind); + }, alternateHandler, options => + { + if (alternateHandler) + { +#pragma warning disable MCPEXP002 // Exercise overlapping dispatch through the alternate pipeline. + options.Filters.Request.CallToolWithAlternateFilters.Add(async (request, next, cancellationToken) => + { + var first = next(request, cancellationToken).AsTask(); + await entered.Task.WaitAsync(cancellationToken); + try { await next(request, cancellationToken); } + catch (InvalidOperationException exception) when (exception.Message == "Second dispatch failed.") { } + finally { release.TrySetResult(true); } + return await first; + }); +#pragma warning restore MCPEXP002 + } + else + { + options.Filters.Request.CallToolFilters.Add(next => async (request, cancellationToken) => + { + var first = next(request, cancellationToken).AsTask(); + await entered.Task.WaitAsync(cancellationToken); + try { await next(request, cancellationToken); } + catch (InvalidOperationException exception) when (exception.Message == "Second dispatch failed.") { } + finally { release.TrySetResult(true); } + return await first; + }); + } + }); + } + } + } + + [Theory] + [InlineData("2025-06-18")] + [InlineData("2025-11-25")] + [InlineData("2026-07-28")] + public async Task ListTools_DeferredRecovery_PreservesWrapperOwnedResults(string protocolVersion) + { + foreach (int mode in new[] { 0, 1, 2 }) + { + foreach (bool alternateHandler in new[] { false, true }) + { + McpServerTool inner = McpServerTool.Create(() => new MutableCount(), new() + { + Name = "owned_count", + UseStructuredContent = true, + OutputSchema = ParseJson("""{"type":"object"}"""), + SerializerOptions = new(JsonSerializerDefaults.Web) + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingDefault, + TypeInfoResolver = new DefaultJsonTypeInfoResolver(), + }, + }); + McpServerTool tool = new SchemaOverrideTool( + new OwnedResultTool(inner, mode), ParseJson("""{"required":["count"]}""")); + await WithClientAsync(tool, protocolVersion, async client => + { + var result = await client.CallToolAsync(tool.ProtocolTool.Name, cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal(mode == 0, result.IsError == true); + Assert.Equal(mode == 1 ? """{"count":99}""" : "{}", result.StructuredContent!.Value.GetRawText()); + }, alternateHandler); + } + } + } + + [Theory] + [InlineData("2025-06-18")] + [InlineData("2025-11-25")] + [InlineData("2026-07-28")] + public async Task ListTools_DeferredRecovery_DoesNotReadSourceOrRunConvertersAfterWrapperCompletion(string protocolVersion) + { + var source = new ExpiringOutput(); + var converter = new ExpiringIntConverter(); + JsonSerializerOptions options = new(JsonSerializerDefaults.Web) + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingDefault, + TypeInfoResolver = new DefaultJsonTypeInfoResolver(), + }; + options.Converters.Add(converter); + McpServerTool inner = McpServerTool.Create(() => source, new() + { + Name = "expired_count", + UseStructuredContent = true, + OutputSchema = ParseJson("""{"type":"object"}"""), + SerializerOptions = options, + }); + McpServerTool tool = new SchemaOverrideTool(new RequestStateTool(inner, null, () => + { + source.Expired = true; + converter.Expired = true; + }), ParseJson("""{"required":["count"],"properties":{"count":{"const":"zero"}}}""")); + await WithClientAsync(tool, protocolVersion, async client => + { + var result = await client.CallToolAsync(tool.ProtocolTool.Name, cancellationToken: TestContext.Current.CancellationToken); + + Assert.True(source.Expired); + Assert.True(converter.Expired); + Assert.NotEqual(true, result.IsError); + Assert.Equal("zero", result.StructuredContent!.Value.GetProperty("count").GetString()); + }); + } + + [Theory] + [InlineData("2025-06-18", false)] + [InlineData("2025-06-18", true)] + [InlineData("2025-11-25", false)] + [InlineData("2025-11-25", true)] + [InlineData("2026-07-28", false)] + [InlineData("2026-07-28", true)] + public async Task ListTools_ForwardingSegment_RecoversBeforeTransformingWrapper( + string protocolVersion, bool requiresNull) + { + JsonElement required = ParseJson("""{"type":"object","required":["note"],"properties":{"note":{"type":"null"}}}"""); + JsonElement optional = ParseJson("""{"type":"object","properties":{"note":{"type":"string"}}}"""); + foreach (int forwardingMode in new[] { 1, 2, 5 }) + { + foreach (bool alternateHandler in new[] { false, true }) + { + McpServerTool inner = McpServerTool.Create(() => new { Note = (string?)null }, new() + { + Name = "stacked_note", + UseStructuredContent = true, + OutputSchema = requiresNull ? optional : required, + SerializerOptions = new(JsonSerializerDefaults.Web) + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + TypeInfoResolver = new DefaultJsonTypeInfoResolver(), + }, + }); + McpServerTool tool = new PayloadTool(new SchemaOverrideTool( + new ForwardingInvocationTool(inner, forwardingMode), requiresNull ? required : optional)); + tool = new PayloadTool(new ForwardingInvocationTool(tool, 2)); + await WithClientAsync(tool, protocolVersion, async client => + { + var listed = Assert.Single(await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken)); + var result = await client.CallToolAsync(listed.Name, cancellationToken: TestContext.Current.CancellationToken); + + Assert.NotEqual(true, result.IsError); + Assert.True(ParseSchema(listed.ProtocolTool.OutputSchema!.Value).Evaluate(result.StructuredContent!.Value).IsValid); + JsonElement content = result.StructuredContent.Value.GetProperty("payload").GetProperty("payload"); + Assert.Equal(requiresNull, content.TryGetProperty("note", out _)); + }, alternateHandler); + } + } + } + + [Theory] + [InlineData("2025-06-18", "object")] + [InlineData("2025-06-18", "array")] + [InlineData("2025-06-18", "nested")] + [InlineData("2025-11-25", "object")] + [InlineData("2025-11-25", "array")] + [InlineData("2025-11-25", "nested")] + [InlineData("2026-07-28", "object")] + [InlineData("2026-07-28", "array")] + [InlineData("2026-07-28", "nested")] + public async Task ListTools_ForwardingInvocation_CapturesOmittedValuesBeforeSourceMutation( + string protocolVersion, string shape) + { + const string requiredCount = """{"type":"object","required":["count"],"properties":{"count":{"type":"integer"}}}"""; + string schema = shape switch + { + "array" => $$"""{"type":"array","items":{{requiredCount}}}""", + "nested" => $$$$"""{"type":"object","required":["child"],"properties":{"child":{{{{requiredCount}}}}}}""", + _ => requiredCount, + }; + foreach (int forwardingMode in new[] { 1, 2, 5 }) + { + foreach (bool alternateHandler in new[] { false, true }) + { + var source = new MutableCount(); + object value = shape switch + { + "array" => new[] { source }, + "nested" => new { Child = source }, + _ => source, + }; + McpServerTool inner = McpServerTool.Create(() => value, new() + { + Name = "captured_count", + UseStructuredContent = true, + OutputSchema = ParseJson(shape == "array" ? """{"type":"array"}""" : """{"type":"object"}"""), + SerializerOptions = new(JsonSerializerDefaults.Web) + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingDefault, + TypeInfoResolver = new DefaultJsonTypeInfoResolver(), + }, + }); + McpServerTool tool = new SchemaOverrideTool(new RequestStateTool( + new ForwardingInvocationTool(inner, forwardingMode), null, () => source.Count = 42), ParseJson(schema)); + await WithClientAsync(tool, protocolVersion, async client => + { + var listed = Assert.Single(await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken)); + var result = await client.CallToolAsync(listed.Name, cancellationToken: TestContext.Current.CancellationToken); + JsonElement content = result.StructuredContent!.Value; + + Assert.Equal(42, source.Count); + Assert.NotEqual(true, result.IsError); + Assert.True(ParseSchema(listed.ProtocolTool.OutputSchema!.Value).Evaluate(content).IsValid); + if (shape == "array") + { + if (protocolVersion != "2026-07-28") + { + content = content.GetProperty("result"); + } + content = content[0]; + } + else if (shape == "nested") + { + content = content.GetProperty("child"); + } + Assert.Equal(0, content.GetProperty("count").GetInt32()); + }, alternateHandler); + } + } + } + + [Theory] + [InlineData("2025-06-18", false)] + [InlineData("2025-06-18", true)] + [InlineData("2025-11-25", false)] + [InlineData("2025-11-25", true)] + [InlineData("2026-07-28", false)] + [InlineData("2026-07-28", true)] + public async Task ListTools_ConcurrentForwardingInvocation_CapturesEachResultBeforeMutation( + string protocolVersion, bool returnSecond) + { + var source = new MutableCount(); + int started = 0; + var bothStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + McpServerTool inner = McpServerTool.Create(async () => + { + if (Interlocked.Increment(ref started) == 2) + { + bothStarted.TrySetResult(true); + } + await bothStarted.Task.WaitAsync(TestContext.Current.CancellationToken); + return source; + }, new() + { + Name = "concurrent_captured_count", + UseStructuredContent = true, + OutputSchema = ParseJson("""{"type":"object"}"""), + SerializerOptions = new(JsonSerializerDefaults.Web) + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingDefault, + TypeInfoResolver = new DefaultJsonTypeInfoResolver(), + }, + }); + McpServerTool tool = new SchemaOverrideTool( + new ConcurrentInvocationTool(inner, returnSecond, () => source.Count = 42), + ParseJson("""{"type":"object","required":["count"],"properties":{"count":{"type":"integer"}}}""")); + await WithClientAsync(tool, protocolVersion, async client => + { + var result = await client.CallToolAsync(tool.ProtocolTool.Name, cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal(2, started); + Assert.Equal(42, source.Count); + Assert.NotEqual(true, result.IsError); + Assert.Equal(0, result.StructuredContent!.Value.GetProperty("count").GetInt32()); + }); + } + + [Theory] + [InlineData("2025-06-18")] + [InlineData("2025-11-25")] + [InlineData("2026-07-28")] + public async Task ListTools_ForwardingInvocation_RecoversRegisteredSchemaWhenSegmentLimitIsExceeded(string protocolVersion) + { + McpServerTool tool = McpServerTool.Create(() => new MutableCount(), new() + { + Name = "bounded_forwarding", + UseStructuredContent = true, + OutputSchema = ParseJson("""{"type":"object"}"""), + SerializerOptions = new(JsonSerializerDefaults.Web) + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingDefault, + TypeInfoResolver = new DefaultJsonTypeInfoResolver(), + }, + }); + for (int i = 0; i < 129; i++) + { + tool = new ForwardingInvocationTool(tool, 1); + } + tool = new SchemaOverrideTool(tool, ParseJson("""{"required":["count"]}""")); + await WithClientAsync(tool, protocolVersion, async client => + { + var result = await client.CallToolAsync(tool.ProtocolTool.Name, cancellationToken: TestContext.Current.CancellationToken); + + Assert.NotEqual(true, result.IsError); + Assert.Equal(0, result.StructuredContent!.Value.GetProperty("count").GetInt32()); + }); + } + + [Theory] + [InlineData("2025-06-18")] + [InlineData("2025-11-25")] + [InlineData("2026-07-28")] + public async Task ListTools_ForwardingInvocation_CapturesSchemaBeforeUserCodeRuns(string protocolVersion) + { + JsonElement required = ParseJson("""{"type":"object","required":["note"],"properties":{"note":{"type":"null"}}}"""); + JsonElement optional = ParseJson("""{"type":"object","properties":{"note":{"type":"string"}}}"""); + McpServerTool? tool = null; + McpServerTool inner = McpServerTool.Create(() => + { + tool!.ProtocolTool.OutputSchema = optional; + return new { Note = (string?)null }; + }, new() + { + Name = "changed_schema", + UseStructuredContent = true, + OutputSchema = optional, + SerializerOptions = new(JsonSerializerDefaults.Web) + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + TypeInfoResolver = new DefaultJsonTypeInfoResolver(), + }, + }); + tool = new SchemaOverrideTool(new ForwardingInvocationTool(inner, 1), required); + await WithClientAsync(tool, protocolVersion, async client => + { + var listed = Assert.Single(await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken)); + var result = await client.CallToolAsync(listed.Name, cancellationToken: TestContext.Current.CancellationToken); + + Assert.NotEqual(true, result.IsError); + Assert.Equal(optional, tool.ProtocolTool.OutputSchema); + Assert.True(ParseSchema(listed.ProtocolTool.OutputSchema!.Value).Evaluate(result.StructuredContent!.Value).IsValid); + Assert.Equal(JsonValueKind.Null, result.StructuredContent.Value.GetProperty("note").ValueKind); + }); + } + + [Theory] + [InlineData("2025-06-18", false)] + [InlineData("2025-06-18", true)] + [InlineData("2025-11-25", false)] + [InlineData("2025-11-25", true)] + [InlineData("2026-07-28", false)] + [InlineData("2026-07-28", true)] + public async Task ListTools_TransformingDelegator_RecoversAgainstInnerSchema(string protocolVersion, bool overrideInnerSchema) + { + JsonElement required = ParseJson("""{"type":"object","required":["note"],"properties":{"note":{"type":"null"}}}"""); + McpServerTool inner = McpServerTool.Create(() => new { Note = (string?)null }, new() + { + Name = "transformed_note", + UseStructuredContent = true, + OutputSchema = overrideInnerSchema ? ParseJson("""{"type":"object"}""") : required, + SerializerOptions = new(JsonSerializerDefaults.Web) + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + TypeInfoResolver = new DefaultJsonTypeInfoResolver(), + }, + }); + if (overrideInnerSchema) + { + inner = new SchemaOverrideTool(inner, required); + } + McpServerTool tool = new DelegatingTool(new PayloadTool(inner)); + await WithClientAsync(tool, protocolVersion, async client => + { + var listed = Assert.Single(await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken)); + var result = await client.CallToolAsync(listed.Name, cancellationToken: TestContext.Current.CancellationToken); + + Assert.True(ParseSchema(listed.ProtocolTool.OutputSchema!.Value).Evaluate(result.StructuredContent!.Value).IsValid); + Assert.Equal(JsonValueKind.Null, result.StructuredContent.Value.GetProperty("payload").GetProperty("note").ValueKind); + }); + } + + [Theory] + [InlineData("2025-06-18")] + [InlineData("2025-11-25")] + [InlineData("2026-07-28")] + public async Task ListTools_StackedTransformingDelegator_RecoversBeforeOuterTransformation(string protocolVersion) + { + JsonElement required = ParseJson("""{"type":"object","required":["note"],"properties":{"note":{"type":"null"}}}"""); + JsonElement optional = ParseJson("""{"type":"object","properties":{"note":{"type":"string"}}}"""); + McpServerTool inner = McpServerTool.Create(() => new { Note = (string?)null }, new() + { + Name = "stacked_transformed_note", + UseStructuredContent = true, + OutputSchema = optional, + SerializerOptions = new(JsonSerializerDefaults.Web) + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + TypeInfoResolver = new DefaultJsonTypeInfoResolver(), + }, + }); + + McpServerTool tool = new PayloadTool( + new SchemaOverrideTool(new ForwardingInvocationTool(inner, 1), required)); + + await WithClientAsync(tool, protocolVersion, async client => + { + var result = await client.CallToolAsync(tool.ProtocolTool.Name, cancellationToken: TestContext.Current.CancellationToken); + + Assert.NotEqual(true, result.IsError); + JsonElement payload = result.StructuredContent!.Value.GetProperty("payload"); + Assert.Equal(JsonValueKind.Null, payload.GetProperty("note").ValueKind); + Assert.True(ParseSchema(tool.ProtocolTool.OutputSchema!.Value).Evaluate(result.StructuredContent.Value).IsValid); + }); + } + + [Theory] + [InlineData("2025-06-18", false, 0)] + [InlineData("2025-06-18", true, 0)] + [InlineData("2025-11-25", false, 0)] + [InlineData("2025-11-25", true, 0)] + [InlineData("2026-07-28", false, 0)] + [InlineData("2026-07-28", true, 0)] + [InlineData("2025-06-18", false, 1)] + [InlineData("2025-06-18", true, 1)] + [InlineData("2025-11-25", false, 1)] + [InlineData("2025-11-25", true, 1)] + [InlineData("2026-07-28", false, 1)] + [InlineData("2026-07-28", true, 1)] + [InlineData("2025-06-18", false, 2)] + [InlineData("2025-06-18", true, 2)] + [InlineData("2025-11-25", false, 2)] + [InlineData("2025-11-25", true, 2)] + [InlineData("2026-07-28", false, 2)] + [InlineData("2026-07-28", true, 2)] + [InlineData("2025-06-18", false, 3)] + [InlineData("2025-06-18", true, 3)] + [InlineData("2025-11-25", false, 3)] + [InlineData("2025-11-25", true, 3)] + [InlineData("2026-07-28", false, 3)] + [InlineData("2026-07-28", true, 3)] + [InlineData("2025-06-18", false, 4)] + [InlineData("2025-06-18", true, 4)] + [InlineData("2025-11-25", false, 4)] + [InlineData("2025-11-25", true, 4)] + [InlineData("2026-07-28", false, 4)] + [InlineData("2026-07-28", true, 4)] + [InlineData("2025-06-18", false, 5)] + [InlineData("2025-06-18", true, 5)] + [InlineData("2025-11-25", false, 5)] + [InlineData("2025-11-25", true, 5)] + [InlineData("2026-07-28", false, 5)] + [InlineData("2026-07-28", true, 5)] + public async Task ListTools_DelegatingTool_RecoversAgainstEffectiveSchema( + string protocolVersion, bool outerRequiresNull, int forwardingMode) + { + JsonElement requiredSchema = ParseJson("""{"type":"object","required":["note"],"properties":{"note":{"type":"null"}}}"""); + JsonElement optionalSchema = ParseJson("""{"type":"object","properties":{"note":{"type":"string"}}}"""); + McpServerTool inner = McpServerTool.Create(() => new { Note = (string?)null }, new() + { + Name = "effective_note", + UseStructuredContent = true, + OutputSchema = outerRequiresNull ? optionalSchema : requiredSchema, + SerializerOptions = new(JsonSerializerDefaults.Web) + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + TypeInfoResolver = new DefaultJsonTypeInfoResolver(), + }, + }); + McpServerTool forwarded = new DelegatingTool(inner); + if (forwardingMode != 0) + { + forwarded = new ForwardingInvocationTool(forwarded, forwardingMode); + } + McpServerTool tool = new SchemaOverrideTool(forwarded, outerRequiresNull ? requiredSchema : optionalSchema); + await WithClientAsync(tool, protocolVersion, async client => + { + var listed = Assert.Single(await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken)); + var result = await client.CallToolAsync(listed.Name, cancellationToken: TestContext.Current.CancellationToken); + JsonElement content = result.StructuredContent!.Value; + + Assert.True(ParseSchema(listed.ProtocolTool.OutputSchema!.Value).Evaluate(content).IsValid); + Assert.Equal(outerRequiresNull, content.TryGetProperty("note", out _)); + }, alternateHandler: forwardingMode == 4); + } + + [Theory] + [InlineData("2025-06-18", false)] + [InlineData("2025-06-18", true)] + [InlineData("2025-11-25", false)] + [InlineData("2025-11-25", true)] + [InlineData("2026-07-28", false)] + [InlineData("2026-07-28", true)] + public async Task ListTools_ForwardingArrayInvocation_PreservesWireShape(string protocolVersion, bool outerRequiresNull) + { + JsonElement requiredSchema = ParseJson("""{"type":"array","items":{"required":["note"],"properties":{"note":{"type":"null"}}}}"""); + JsonElement optionalSchema = ParseJson("""{"type":"array","items":{"properties":{"note":{"type":"string"}}}}"""); + McpServerTool inner = McpServerTool.Create(() => new[] { new { Note = (string?)null } }, new() + { + Name = "forwarded_array", + UseStructuredContent = true, + OutputSchema = outerRequiresNull ? optionalSchema : requiredSchema, + SerializerOptions = new(JsonSerializerDefaults.Web) + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + TypeInfoResolver = new DefaultJsonTypeInfoResolver(), + }, + }); + McpServerTool tool = new SchemaOverrideTool( + new ForwardingInvocationTool(inner, 2), outerRequiresNull ? requiredSchema : optionalSchema); + await WithClientAsync(tool, protocolVersion, async client => + { + var listed = Assert.Single(await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken)); + var result = await client.CallToolAsync(listed.Name, cancellationToken: TestContext.Current.CancellationToken); + JsonElement content = result.StructuredContent!.Value; + + Assert.NotEqual(true, result.IsError); + Assert.True(ParseSchema(listed.ProtocolTool.OutputSchema!.Value).Evaluate(content).IsValid); + if (protocolVersion != "2026-07-28") + { + content = content.GetProperty("result"); + } + Assert.Equal(outerRequiresNull, content[0].TryGetProperty("note", out _)); + }); + } + + [Fact] + public async Task ListTools_ForwardingInvocation_RecoversAfterAsynchronousCompletion() + { + var started = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + JsonElement requiredSchema = ParseJson("""{"type":"object","required":["note"],"properties":{"note":{"type":"null"}}}"""); + McpServerTool inner = McpServerTool.Create(async () => + { + started.TrySetResult(true); + await release.Task; + return new { Note = (string?)null }; + }, new() + { + Name = "async_forwarded_note", + UseStructuredContent = true, + OutputSchema = ParseJson("""{"type":"object","properties":{"note":{"type":"string"}}}"""), + SerializerOptions = new(JsonSerializerDefaults.Web) + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + TypeInfoResolver = new DefaultJsonTypeInfoResolver(), + }, + }); + McpServerTool tool = new SchemaOverrideTool(new ForwardingInvocationTool(inner, 2), requiredSchema); + await WithClientAsync(tool, "2026-07-28", async client => + { + Task pending = client.CallToolAsync(tool.ProtocolTool.Name, + cancellationToken: TestContext.Current.CancellationToken).AsTask(); + try + { + Assert.Same(started.Task, await Task.WhenAny(started.Task, pending)); + } + finally + { + release.TrySetResult(true); + } + + CallToolResult result = await pending; + Assert.NotEqual(true, result.IsError); + Assert.True(ParseSchema(requiredSchema).Evaluate(result.StructuredContent!.Value).IsValid); + Assert.Equal(JsonValueKind.Null, result.StructuredContent.Value.GetProperty("note").ValueKind); + }); + } + + [Theory] + [InlineData("2025-06-18", false)] + [InlineData("2025-06-18", true)] + [InlineData("2025-11-25", false)] + [InlineData("2025-11-25", true)] + [InlineData("2026-07-28", false)] + [InlineData("2026-07-28", true)] + public async Task ListTools_ForwardingInvocation_PreservesRegisteredSchemaWhenMatchChanges( + string protocolVersion, bool outerRequiresNull) + { + JsonElement required = ParseJson("""{"type":"object","required":["note"],"properties":{"note":{"type":"null"}}}"""); + JsonElement optional = ParseJson("""{"type":"object","properties":{"note":{"type":"string"}}}"""); + McpServerTool inner = McpServerTool.Create(() => new { Note = (string?)null }, new() + { + Name = "rebound_note", + UseStructuredContent = true, + OutputSchema = outerRequiresNull ? optional : required, + SerializerOptions = new(JsonSerializerDefaults.Web) + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + TypeInfoResolver = new DefaultJsonTypeInfoResolver(), + }, + }); + McpServerTool tool = new SchemaOverrideTool( + new RequestStateTool(inner, inner, null), outerRequiresNull ? required : optional); + foreach (bool alternateHandler in new[] { false, true }) + { + await WithClientAsync(tool, protocolVersion, async client => + { + var listed = Assert.Single(await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken)); + var result = await client.CallToolAsync(listed.Name, cancellationToken: TestContext.Current.CancellationToken); + + Assert.NotEqual(true, result.IsError); + Assert.True(ParseSchema(listed.ProtocolTool.OutputSchema!.Value).Evaluate(result.StructuredContent!.Value).IsValid); + Assert.Equal(outerRequiresNull, result.StructuredContent.Value.TryGetProperty("note", out _)); + }, alternateHandler); + } + } + + [Theory] + [InlineData("2025-06-18", false)] + [InlineData("2025-06-18", true)] + [InlineData("2025-11-25", false)] + [InlineData("2025-11-25", true)] + [InlineData("2026-07-28", false)] + [InlineData("2026-07-28", true)] + public async Task ListTools_ForwardingInvocation_PreservesValidJsonAfterSourceMutation( + string protocolVersion, bool arrayResult) + { + const string itemSchema = """{"type":"object","required":["note"],"properties":{"note":{"type":"null"}}}"""; + string schema = arrayResult ? $$"""{"type":"array","items":{{itemSchema}}}""" : itemSchema; + foreach (bool alternateHandler in new[] { false, true }) + { + var source = new MutableNote(); + McpServerTool inner = McpServerTool.Create(() => arrayResult ? (object)new[] { source } : source, new() + { + Name = "mutated_note", + UseStructuredContent = true, + OutputSchema = ParseJson(schema), + SerializerOptions = new(JsonSerializerDefaults.Web) + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + TypeInfoResolver = new DefaultJsonTypeInfoResolver(), + }, + }); + McpServerTool tool = new SchemaOverrideTool( + new RequestStateTool(inner, null, () => source.Note = "changed after serialization"), ParseJson(schema)); + await WithClientAsync(tool, protocolVersion, async client => + { + var listed = Assert.Single(await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken)); + var result = await client.CallToolAsync(listed.Name, cancellationToken: TestContext.Current.CancellationToken); + JsonElement content = result.StructuredContent!.Value; + + Assert.Equal("changed after serialization", source.Note); + Assert.NotEqual(true, result.IsError); + Assert.True(ParseSchema(listed.ProtocolTool.OutputSchema!.Value).Evaluate(content).IsValid); + if (arrayResult) + { + if (protocolVersion != "2026-07-28") + { + content = content.GetProperty("result"); + } + content = content[0]; + } + Assert.Equal(JsonValueKind.Null, content.GetProperty("note").ValueKind); + }, alternateHandler); + } + } + + [Theory] + [InlineData("2025-06-18", false)] + [InlineData("2025-06-18", true)] + [InlineData("2025-11-25", false)] + [InlineData("2025-11-25", true)] + [InlineData("2026-07-28", false)] + [InlineData("2026-07-28", true)] + public async Task ListTools_ForwardingInvocation_PreservesInnerProjectionWhenOuterRecoveryFails( + string protocolVersion, bool invalidSchema) + { + McpServerTool inner = McpServerTool.Create(() => new { Note = (string?)null }, new() + { + Name = "unrecoverable_outer_note", + UseStructuredContent = true, + OutputSchema = ParseJson("""{"type":"object","required":["note"]}"""), + SerializerOptions = new(JsonSerializerDefaults.Web) + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + TypeInfoResolver = new DefaultJsonTypeInfoResolver(), + }, + }); + McpServerTool tool = new SchemaOverrideTool(new ForwardingInvocationTool(inner, 1), + ParseJson(invalidSchema ? """{"type":42}""" : """{"required":["note","missing"]}""")); + await WithClientAsync(tool, protocolVersion, async client => + { + var result = await client.CallToolAsync(tool.ProtocolTool.Name, cancellationToken: TestContext.Current.CancellationToken); + + Assert.NotEqual(true, result.IsError); + Assert.Equal("""{"note":null}""", result.StructuredContent!.Value.GetRawText()); + }); + } + + [Fact] + public async Task ListTools_ForwardingInvocation_DoesNotRestartRecoveryAfterRegexTimeout() + { + string real = new string('a', 26) + "!"; + McpServerTool inner = McpServerTool.Create(() => new { Real = real, Note = (string?)null }, new() + { + Name = "timed_out_forwarded_note", + UseStructuredContent = true, + OutputSchema = ParseJson("""{"type":"object","properties":{"real":{"pattern":"^(a+)+$"}}}"""), + SerializerOptions = new(JsonSerializerDefaults.Web) + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + TypeInfoResolver = new DefaultJsonTypeInfoResolver(), + }, + }); + McpServerTool tool = new SchemaOverrideTool(new ForwardingInvocationTool(inner, 1), + ParseJson("""{"type":"object","required":["note"]}""")); + await WithClientAsync(tool, "2026-07-28", async client => + { + var result = await client.CallToolAsync(tool.ProtocolTool.Name, cancellationToken: TestContext.Current.CancellationToken); + + Assert.NotEqual(true, result.IsError); + Assert.Equal(real, result.StructuredContent!.Value.GetProperty("real").GetString()); + Assert.False(result.StructuredContent.Value.TryGetProperty("note", out _)); + }); + } + + [Theory] + [InlineData("2025-06-18")] + [InlineData("2025-11-25")] + [InlineData("2026-07-28")] + public async Task ListTools_TransformingInvocation_ReceivesInnerRecoveryAndRetainsItsOutput(string protocolVersion) + { + McpServerTool inner = McpServerTool.Create(() => new { Note = (string?)null }, new() + { + Name = "transformed_note", + UseStructuredContent = true, + OutputSchema = ParseJson("""{"type":"object","required":["note"],"properties":{"note":{"type":"null"}}}"""), + SerializerOptions = new(JsonSerializerDefaults.Web) + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + TypeInfoResolver = new DefaultJsonTypeInfoResolver(), + }, + }); + McpServerTool tool = new NoteTransformingTool(inner); + await WithClientAsync(tool, protocolVersion, async client => + { + var listed = Assert.Single(await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken)); + var result = await client.CallToolAsync(listed.Name, cancellationToken: TestContext.Current.CancellationToken); + + Assert.NotEqual(true, result.IsError); + Assert.True(ParseSchema(listed.ProtocolTool.OutputSchema!.Value).Evaluate(result.StructuredContent!.Value).IsValid); + Assert.Equal("transformed", result.StructuredContent.Value.GetProperty("note").GetString()); + }); + } + [Theory] [InlineData("2025-06-18", 1, false)] [InlineData("2025-06-18", 2, false)] @@ -89,6 +887,50 @@ await WithClientAsync(tool, protocolVersion, async client => }); } + [Theory] + [InlineData("2025-06-18", false)] + [InlineData("2025-06-18", true)] + [InlineData("2025-11-25", false)] + [InlineData("2025-11-25", true)] + [InlineData("2026-07-28", false)] + [InlineData("2026-07-28", true)] + public async Task ListTools_DelegatingTool_RecoversRequiredNullableMembers(string protocolVersion, bool innerUsesComposition) + { + JsonElement objectSchema = ParseJson( + """{"type":"object","properties":{"note":{"type":"null"}},"required":["note"]}"""); + JsonElement composedSchema = ParseJson($$"""{"allOf":[{{objectSchema.GetRawText()}}]}"""); + JsonElement innerSchema = innerUsesComposition ? composedSchema : objectSchema; + JsonElement outerSchema = innerUsesComposition ? objectSchema : composedSchema; + McpServerTool inner = McpServerTool.Create(() => new { Note = (string?)null }, new() + { + Name = "required_note", + UseStructuredContent = true, + OutputSchema = innerSchema, + SerializerOptions = new(JsonSerializerDefaults.Web) + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + TypeInfoResolver = new DefaultJsonTypeInfoResolver(), + }, + }); + McpServerTool tool = new SchemaOverrideTool(new DelegatingTool(inner), outerSchema); + await WithClientAsync(tool, protocolVersion, async client => + { + var listed = Assert.Single(await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken)); + var result = await client.CallToolAsync(listed.Name, cancellationToken: TestContext.Current.CancellationToken); + JsonElement content = result.StructuredContent!.Value; + + Assert.True(ParseSchema(listed.ProtocolTool.OutputSchema!.Value).Evaluate(content).IsValid); + if (innerUsesComposition && protocolVersion != "2026-07-28") + { + content = content.GetProperty("result"); + } + Assert.Equal(JsonValueKind.Null, content.GetProperty("note").ValueKind); + }); + + Assert.Equal(innerSchema.GetRawText(), inner.ProtocolTool.OutputSchema!.Value.GetRawText()); + Assert.Equal(outerSchema.GetRawText(), tool.ProtocolTool.OutputSchema!.Value.GetRawText()); + } + [Theory] [InlineData("2025-06-18", false)] [InlineData("2025-06-18", true)] @@ -845,15 +1687,26 @@ private static JsonElement ParseJson(string json) return document.RootElement.Clone(); } - private static async Task WithClientAsync(McpServerTool tool, string protocolVersion, Func action) + private static async Task WithClientAsync( + McpServerTool tool, string protocolVersion, Func action, bool alternateHandler = false, + Action? configure = null) { using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); cts.CancelAfter(TestConstants.DefaultTimeout); var clientToServer = new Pipe(); var serverToClient = new Pipe(); + McpServerOptions options = new() { ToolCollection = new() { tool } }; + if (alternateHandler) + { +#pragma warning disable MCPEXP002 // Exercise registered tools through the explicit alternate handler pipeline. + options.Handlers.CallToolWithAlternateHandler = + static (_, _) => throw new InvalidOperationException("Registered tool was not matched."); +#pragma warning restore MCPEXP002 + } + configure?.Invoke(options); await using var server = McpServer.Create( new StreamServerTransport(clientToServer.Reader.AsStream(), serverToClient.Writer.AsStream()), - new McpServerOptions { ToolCollection = new() { tool } }); + options); Task serverTask = server.RunAsync(cts.Token); try { @@ -873,6 +1726,217 @@ private static async Task WithClientAsync(McpServerTool tool, string protocolVer private sealed class DelegatingTool(McpServerTool inner) : DelegatingMcpServerTool(inner); + private sealed class MutableNote + { + public string? Note { get; set; } + } + + private sealed class MutableCount + { + public int Count { get; set; } + } + + private sealed class ExpiringOutput + { + [JsonIgnore] + public bool Expired { get; set; } + + public int Count => Expired ? throw new InvalidOperationException("Source is no longer owned by the inner invocation.") : 0; + } + + private sealed class ExpiringIntConverter : JsonConverter + { + public bool Expired { get; set; } + + public override int Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => + throw new NotSupportedException(); + + public override void Write(Utf8JsonWriter writer, int value, JsonSerializerOptions options) + { + if (Expired) + { + throw new InvalidOperationException("Converter is no longer owned by the inner invocation."); + } + writer.WriteStringValue(value == 0 ? "zero" : "changed"); + } + } + + private sealed class OwnedResultTool(McpServerTool inner, int mode) : DelegatingMcpServerTool(inner) + { + public override async ValueTask InvokeAsync( + RequestContext request, CancellationToken cancellationToken = default) + { + CallToolResult result = await base.InvokeAsync(request, cancellationToken); + if (mode == 0) + { + result.IsError = true; + } + else + { + result.StructuredContent = ParseJson(mode == 1 ? """{"count":99}""" : result.StructuredContent!.Value.GetRawText()); + } + return result; + } + } + + private sealed class RequestStateTool(McpServerTool inner, McpServerTool? temporaryMatch, Action? afterInvocation) + : DelegatingMcpServerTool(inner) + { + public override async ValueTask InvokeAsync( + RequestContext request, CancellationToken cancellationToken = default) + { + IMcpServerPrimitive? original = request.MatchedPrimitive; + if (temporaryMatch is not null) + { + request.MatchedPrimitive = temporaryMatch; + } + try + { + CallToolResult result = await base.InvokeAsync(request, cancellationToken); + afterInvocation?.Invoke(); + return result; + } + finally + { + request.MatchedPrimitive = original; + } + } + } + + private sealed class ForwardingInvocationTool(McpServerTool inner, int mode) : DelegatingMcpServerTool(inner) + { + public override ValueTask InvokeAsync( + RequestContext request, CancellationToken cancellationToken = default) => + mode == 1 ? base.InvokeAsync(request, cancellationToken) : ForwardAsync(request, cancellationToken); + + private async ValueTask ForwardAsync( + RequestContext request, CancellationToken cancellationToken) + { + CallToolResult result = await base.InvokeAsync(request, cancellationToken); + if (mode == 5) + { + await base.InvokeAsync(request, cancellationToken); + return result; + } + return mode == 2 ? result : new() + { + Content = result.Content, + StructuredContent = result.StructuredContent, + IsError = result.IsError, + Meta = result.Meta, + }; + } + } + + private sealed class ConcurrentInvocationTool(McpServerTool inner, bool returnSecond, Action afterInvocation) + : DelegatingMcpServerTool(inner) + { + public override async ValueTask InvokeAsync( + RequestContext request, CancellationToken cancellationToken = default) + { + Task first = base.InvokeAsync(request, cancellationToken).AsTask(); + Task second = base.InvokeAsync(request, cancellationToken).AsTask(); + CallToolResult[] results = await Task.WhenAll(first, second); + afterInvocation(); + return results[returnSecond ? 1 : 0]; + } + } + + private sealed class GatedInvocationTool( + McpServerTool inner, TaskCompletionSource entered, TaskCompletionSource release, + bool suspendAfterInner, bool secondThrows) : DelegatingMcpServerTool(inner) + { + private int _calls; + + public override async ValueTask InvokeAsync( + RequestContext request, CancellationToken cancellationToken = default) + { + bool first = Interlocked.Increment(ref _calls) == 1; + if (!first && secondThrows) + { + throw new InvalidOperationException("Second dispatch failed."); + } + if (first && !suspendAfterInner) + { + entered.TrySetResult(true); + await release.Task.WaitAsync(cancellationToken); + } + CallToolResult result = await base.InvokeAsync(request, cancellationToken); + if (first && suspendAfterInner) + { + entered.TrySetResult(true); + await release.Task.WaitAsync(cancellationToken); + } + return result; + } + } + + private sealed class ObservedRecoveryTool(McpServerTool inner) : DelegatingMcpServerTool(inner) + { + public int EarlierPlanVisits { get; private set; } + + public override async ValueTask InvokeAsync( + RequestContext request, CancellationToken cancellationToken = default) + { + CallToolResult first = await base.InvokeAsync(request, cancellationToken); + object target = typeof(RequestContext) + .GetProperty("StructuredContentRecoveryTarget", BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(request)!; + var plans = (Dictionary>)target.GetType() + .GetField("_plans", BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(target)!; + var original = plans[first.StructuredContent!.Value]; + JsonElement[] identities = plans.Where(entry => ReferenceEquals(entry.Value, original)).Select(entry => entry.Key).ToArray(); + Action observed = (tool, result) => + { + EarlierPlanVisits++; + original(tool, result); + }; + target.GetType().GetMethod("Register", BindingFlags.Instance | BindingFlags.NonPublic)! + .Invoke(target, [identities, observed]); + for (int i = 1; i < 32; i++) + { + await base.InvokeAsync(request, cancellationToken); + } + return first; + } + } + + private sealed class NoteTransformingTool(McpServerTool inner) : DelegatingMcpServerTool(inner) + { + public override Tool ProtocolTool { get; } = new() + { + Name = inner.ProtocolTool.Name, + InputSchema = inner.ProtocolTool.InputSchema, + OutputSchema = ParseJson("""{"type":"object","properties":{"note":{"type":"string"}}}"""), + }; + + public override async ValueTask InvokeAsync( + RequestContext request, CancellationToken cancellationToken = default) + { + CallToolResult result = await base.InvokeAsync(request, cancellationToken); + Assert.Equal(JsonValueKind.Null, result.StructuredContent!.Value.GetProperty("note").ValueKind); + result.StructuredContent = ParseJson("""{"note":"transformed"}"""); + return result; + } + } + + private sealed class PayloadTool(McpServerTool inner) : DelegatingMcpServerTool(inner) + { + public override Tool ProtocolTool { get; } = new() + { + Name = inner.ProtocolTool.Name, + InputSchema = inner.ProtocolTool.InputSchema, + OutputSchema = ParseJson($$$$"""{"type":"object","required":["payload"],"properties":{"payload":{{{{inner.ProtocolTool.OutputSchema!.Value.GetRawText()}}}}}}"""), + }; + + public override async ValueTask InvokeAsync( + RequestContext request, CancellationToken cancellationToken = default) + { + CallToolResult result = await base.InvokeAsync(request, cancellationToken); + result.StructuredContent = ParseJson($$"""{"payload":{{result.StructuredContent!.Value.GetRawText()}}}"""); + return result; + } + } + private sealed class AlternateInvocationTool(McpServerTool inner, CallToolResult response) : DelegatingMcpServerTool(inner) { public override ValueTask InvokeAsync( diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerNativeUriSkillTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerNativeUriSkillTests.cs index 6c567f395..ce34c06b6 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpServerNativeUriSkillTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpServerNativeUriSkillTests.cs @@ -9,7 +9,7 @@ public class McpServerNativeUriSkillTests(ITestOutputHelper output) : ClientServ { protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder) { - mcpServerBuilder.WithSkills( + mcpServerBuilder.WithSkillPackages( new[] { (Uri: "repo:/skills/alpha/SKILL.md", Name: "alpha"), @@ -18,8 +18,8 @@ protected override void ConfigureServices(ServiceCollection services, IMcpServer (Uri: "urn:alpha/SKILL.md", Name: "alpha"), (Uri: "repo:/skills/caf%C3%A9/SKILL.md", Name: "café"), (Uri: "repo:skills/caf%C3%A9/SKILL.md", Name: "café"), - }.Select(entry => McpServerSkill.Create( - entry.Uri, [McpServerSkillFile.FromText("SKILL.md", $"---\nname: {entry.Name}\ndescription: Native resource\n---\n")]))); + }.Select(entry => McpServerSkillPackage.Create( + [McpServerSkillFile.FromText("SKILL.md", $"---\nname: {entry.Name}\ndescription: Native resource\n---\n")], new McpServerSkillPackageCreateOptions { Uri = entry.Uri }))); } [Theory] @@ -32,14 +32,14 @@ protected override void ConfigureServices(ServiceCollection services, IMcpServer public async Task NativeResourceUrisListGetAndVerifyWithoutRewritingTheirIdentity(string uri) { await using var client = await CreateMcpClientForServer(); - var skills = await client.ListSkillsAsync(TestContext.Current.CancellationToken); - var fetched = await client.GetSkillAsync(uri, TestContext.Current.CancellationToken); + var skills = await client.ListSkillsAsync(cancellationToken: TestContext.Current.CancellationToken); + var fetched = await client.GetSkillAsync(uri, cancellationToken: TestContext.Current.CancellationToken); var resources = await client.ListResourcesAsync(cancellationToken: TestContext.Current.CancellationToken); Assert.Equal(6, skills.Count); Assert.Equal(uri, fetched.Uri); Assert.Contains(resources, resource => resource.Uri == uri); - var read = await client.ReadSkillResourceAsync(fetched, uri, TestContext.Current.CancellationToken); + var read = await client.ReadSkillResourceAsync(fetched, uri, cancellationToken: TestContext.Current.CancellationToken); Assert.Equal(uri, Assert.Single(read.Contents).Uri); Assert.IsType(read.Contents[0]); } diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerNestedUnicodeSkillTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerNestedUnicodeSkillTests.cs index 40db64be3..7cf7316c7 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpServerNestedUnicodeSkillTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpServerNestedUnicodeSkillTests.cs @@ -13,17 +13,16 @@ protected override void ConfigureServices(ServiceCollection services, IMcpServer { McpServerSkillFile.FromText("SKILL.md", "---\nname: outer\ndescription: Parent skill\n---\n"), }; - var skills = new List(); + var skills = new List(); foreach (var name in new[] { "café", "数据分析", "\U00010428" }) { var markdown = $"---\nname: {name}\ndescription: Nested skill\n---\n"; files.Add(McpServerSkillFile.FromText($"{name}/SKILL.md", markdown)); - skills.Add(McpServerSkill.Create( - $"skill://outer/{Uri.EscapeDataString(name)}/SKILL.md", - [McpServerSkillFile.FromText("SKILL.md", markdown)])); + skills.Add(McpServerSkillPackage.Create( + [McpServerSkillFile.FromText("SKILL.md", markdown)], new McpServerSkillPackageCreateOptions { Uri = $"skill://outer/{Uri.EscapeDataString(name)}/SKILL.md" })); } - skills.Add(McpServerSkill.Create("skill://outer/SKILL.md", files)); - mcpServerBuilder.WithSkills(skills); + skills.Add(McpServerSkillPackage.Create(files, new McpServerSkillPackageCreateOptions { Uri = "skill://outer/SKILL.md" })); + mcpServerBuilder.WithSkillPackages(skills); } [Theory] @@ -33,7 +32,7 @@ protected override void ConfigureServices(ServiceCollection services, IMcpServer public async Task NestedUnicodeSkillsListGetAndShareVerifiedResources(string name) { await using var client = await CreateMcpClientForServer(); - var skills = await client.ListSkillsAsync(TestContext.Current.CancellationToken); + var skills = await client.ListSkillsAsync(cancellationToken: TestContext.Current.CancellationToken); var resources = await client.ListResourcesAsync(cancellationToken: TestContext.Current.CancellationToken); var uri = $"skill://outer/{Uri.EscapeDataString(name)}/SKILL.md"; @@ -42,13 +41,13 @@ public async Task NestedUnicodeSkillsListGetAndShareVerifiedResources(string nam Assert.Contains(skills, skill => skill.Uri == uri); Assert.Contains(resources, resource => resource.Uri == uri); - var inner = await client.GetSkillAsync(uri, TestContext.Current.CancellationToken); - var outer = await client.GetSkillAsync("skill://outer/SKILL.md", TestContext.Current.CancellationToken); + var inner = await client.GetSkillAsync(uri, cancellationToken: TestContext.Current.CancellationToken); + var outer = await client.GetSkillAsync("skill://outer/SKILL.md", cancellationToken: TestContext.Current.CancellationToken); Assert.Equal(name, inner.Name); - Assert.Contains(outer.Resources.Resources!, resource => resource.Uri == uri); + Assert.Contains(outer.Resources.Entries!, resource => resource.Uri == uri); - var innerRead = await client.ReadSkillResourceAsync(inner, uri, TestContext.Current.CancellationToken); - var outerRead = await client.ReadSkillResourceAsync(outer, uri, TestContext.Current.CancellationToken); + var innerRead = await client.ReadSkillResourceAsync(inner, uri, cancellationToken: TestContext.Current.CancellationToken); + var outerRead = await client.ReadSkillResourceAsync(outer, uri, cancellationToken: TestContext.Current.CancellationToken); var innerText = Assert.IsType(Assert.Single(innerRead.Contents)); var outerText = Assert.IsType(Assert.Single(outerRead.Contents)); Assert.Equal(uri, innerText.Uri); diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerSkillAuthoringBoundaryTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerSkillAuthoringBoundaryTests.cs deleted file mode 100644 index 50f0970e7..000000000 --- a/tests/ModelContextProtocol.Tests/Server/McpServerSkillAuthoringBoundaryTests.cs +++ /dev/null @@ -1,116 +0,0 @@ -using ModelContextProtocol.Extensions.Skills; -using System.Text.Json.Nodes; - -namespace ModelContextProtocol.Tests.Server; - -public class McpServerSkillAuthoringBoundaryTests -{ - private const string SkillUri = "skill://owned/SKILL.md"; - - [Theory] - [InlineData("description", 1024, false)] - [InlineData("description", 1024, true)] - [InlineData("compatibility", 500, false)] - [InlineData("compatibility", 500, true)] - public void AuthoringCountsSupplementaryFrontmatterByScalar(string field, int limit, bool overLimit) - { - int length = limit + (overLimit ? 1 : 0); - var text = string.Concat(Enumerable.Repeat("\U0001f600", length)); - var description = field == "description" ? text : "d"; - var compatibility = field == "compatibility" ? $"compatibility: {text}\n" : ""; - var markdown = $"---\nname: owned\ndescription: {description}\n{compatibility}---\n"; - var files = new[] { McpServerSkillFile.FromText("SKILL.md", markdown) }; - - if (overLimit) - { - var error = Assert.Throws(() => McpServerSkill.Create(files)); - Assert.Contains($"{length} characters", error.Message); - Assert.Throws(() => McpServerSkill.Create(SkillUri, files)); - } - else - { - Assert.Equal(text, McpServerSkill.Create(files).ProtocolSkill.Frontmatter[field]!.GetValue()); - Assert.Equal(text, McpServerSkill.Create(SkillUri, files).ProtocolSkill.Frontmatter[field]!.GetValue()); - } - - WithDirectory(markdown, directory => - { - if (overLimit) - { - var error = Assert.Throws(() => McpServerSkill.CreateFromDirectory(directory)); - Assert.Contains($"{length} characters", error.Message); - Assert.Throws(() => McpServerSkill.CreateFromDirectory(SkillUri, directory)); - } - else - { - Assert.Equal(text, McpServerSkill.CreateFromDirectory(directory).ProtocolSkill.Frontmatter[field]!.GetValue()); - Assert.Equal(text, McpServerSkill.CreateFromDirectory(SkillUri, directory).ProtocolSkill.Frontmatter[field]!.GetValue()); - } - }); - } - - [Theory] - [InlineData("---\nname: &name owned\ndescription: d\n---\n")] - [InlineData("---\nname: owned\ndescription: d\nextra: [never\n---\n")] - [InlineData("# No frontmatter\n")] - [InlineData("---\nname: owned\ndescription: d\n")] - public void AutomaticAuthoring_RejectsUnsupportedMalformedOrMissingFrontmatter(string markdown) - { - var files = new[] { McpServerSkillFile.FromText("SKILL.md", markdown) }; - Assert.Throws(() => McpServerSkill.Create(files)); - Assert.Throws(() => McpServerSkill.Create(SkillUri, files)); - - WithDirectory(markdown, directory => - { - Assert.Throws(() => McpServerSkill.CreateFromDirectory(directory)); - Assert.Throws(() => McpServerSkill.CreateFromDirectory(SkillUri, directory)); - }); - } - - [Fact] - public void ExplicitMetadata_DoesNotOverrideSupportedFrontmatterEquality() - { - const string Markdown = "---\nname: owned\ndescription: From the file\n---\n"; - var supplied = new JsonObject { ["name"] = "owned", ["description"] = "Not from the file" }; - - var filesException = Assert.Throws(() => McpServerSkill.Create( - SkillUri, supplied, [McpServerSkillFile.FromText("SKILL.md", Markdown)])); - Assert.Contains("does not match", filesException.Message); - - WithDirectory(Markdown, directory => - { - var directoryException = Assert.Throws(() => - McpServerSkill.CreateFromDirectory(SkillUri, supplied, directory)); - Assert.Contains("does not match", directoryException.Message); - }); - } - - [Theory] - [InlineData("# No frontmatter\n")] - [InlineData("---\nname: owned\ndescription: d\nextra: [never\n---\n")] - public void ExplicitMetadata_DoesNotBypassDetectedBoundaryOrFormatErrors(string markdown) - { - var supplied = new JsonObject { ["name"] = "owned", ["description"] = "d" }; - Assert.Throws(() => McpServerSkill.Create( - SkillUri, supplied, [McpServerSkillFile.FromText("SKILL.md", markdown)])); - WithDirectory(markdown, directory => - { - Assert.Throws(() => McpServerSkill.CreateFromDirectory(SkillUri, supplied, directory)); - }); - } - - private void WithDirectory(string markdown, Action action) - { - var directory = Path.Combine(Path.GetTempPath(), "mcp-skill-ownership-" + Guid.NewGuid().ToString("N")); - Directory.CreateDirectory(directory); - try - { - File.WriteAllText(Path.Combine(directory, "SKILL.md"), markdown); - action(directory); - } - finally - { - Directory.Delete(directory, recursive: true); - } - } -} diff --git a/tests/ModelContextProtocol.Tests/Server/InMemoryMcpSkillCatalogTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerSkillCollectionTests.cs similarity index 50% rename from tests/ModelContextProtocol.Tests/Server/InMemoryMcpSkillCatalogTests.cs rename to tests/ModelContextProtocol.Tests/Server/McpServerSkillCollectionTests.cs index d5b6f9fa3..7bab186f3 100644 --- a/tests/ModelContextProtocol.Tests/Server/InMemoryMcpSkillCatalogTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpServerSkillCollectionTests.cs @@ -1,22 +1,17 @@ using ModelContextProtocol.Extensions.Skills; -using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; using System.Text.Json; using System.Text.Json.Nodes; -using System.Text.Json.Serialization; -using System.Text.Json.Serialization.Metadata; namespace ModelContextProtocol.Tests.Server; /// -/// Tests for : ordering, keyset pagination, lookup, cursor validation, and -/// the structural validation of entries against the specification. +/// Tests skill creation, validation, and collection identity. /// -public class InMemoryMcpSkillCatalogTests +public class McpServerSkillCollectionTests { private static readonly string s_validDigest = "sha256:" + new string('a', 64); - private McpSkillRequestContext Context => new(new JsonRpcRequest { Method = SkillsProtocol.MethodSkillsList }); - // Static because the InvalidSkills member data, which xUnit requires to be static, builds its cases with it. private static Skill CreateSkill(string name, string? description = "A skill", SkillResources? resources = null) { @@ -33,179 +28,54 @@ private static Skill CreateSkill(string name, string? description = "A skill", S }; } - private InMemoryMcpSkillCatalog CreateCatalog(int pageSize, params string[] names) => - new([.. names.Select(name => CreateSkill(name))], pageSize); - - [Fact] - public async Task ListAsync_ReturnsEntriesOrderedByUri() - { - var catalog = CreateCatalog(10, "zebra", "alpha", "middle"); - - var page = await catalog.ListAsync(null, Context, TestContext.Current.CancellationToken); - - Assert.Collection( - page.Skills, - skill => Assert.Equal("skill://alpha/SKILL.md", skill.Uri), - skill => Assert.Equal("skill://middle/SKILL.md", skill.Uri), - skill => Assert.Equal("skill://zebra/SKILL.md", skill.Uri)); - Assert.Null(page.NextCursor); - Assert.Equal(3, catalog.Count); - } - - [Fact] - public async Task ListAsync_PaginatesWithoutRepeatingOrSkippingEntries() - { - var catalog = CreateCatalog(2, "a", "b", "c", "d", "e"); - - var seen = new List(); - string? cursor = null; - int pages = 0; - do - { - var page = await catalog.ListAsync(cursor, Context, TestContext.Current.CancellationToken); - seen.AddRange(page.Skills.Select(skill => skill.Uri)); - cursor = page.NextCursor; - Assert.True(++pages < 20, "Pagination did not terminate."); - } - while (cursor is not null); - - Assert.Equal(3, pages); - Assert.Equal(5, seen.Count); - Assert.Equal(seen.Count, seen.Distinct().Count()); - Assert.Equal(seen.OrderBy(uri => uri, StringComparer.Ordinal), seen); - } - - [Fact] - public async Task ListAsync_LastPageHasNoNextCursor() - { - var catalog = CreateCatalog(2, "a", "b", "c", "d"); - - var first = await catalog.ListAsync(null, Context, TestContext.Current.CancellationToken); - var second = await catalog.ListAsync(first.NextCursor, Context, TestContext.Current.CancellationToken); - - Assert.NotNull(first.NextCursor); - Assert.Equal(2, second.Skills.Count); - Assert.Null(second.NextCursor); - } - - [Fact] - public async Task ListAsync_WithCursorForUnknownUri_ResumesAfterItsPosition() - { - var catalog = CreateCatalog(10, "a", "c"); - string cursor = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes("skill://b/SKILL.md")); - - var page = await catalog.ListAsync(cursor, Context, TestContext.Current.CancellationToken); - - Assert.Single(page.Skills); - Assert.Equal("skill://c/SKILL.md", page.Skills[0].Uri); - } - - [Fact] - public async Task ListAsync_WithEmptyCatalog_ReturnsEmptyPage() - { - var catalog = CreateCatalog(10); - - var page = await catalog.ListAsync(null, Context, TestContext.Current.CancellationToken); - - Assert.Empty(page.Skills); - Assert.Null(page.NextCursor); - } - - [Theory] - [InlineData("not-base64!!")] - [InlineData("/w==")] - [InlineData("wK8=")] - [InlineData("7aCA")] - public async Task ListAsync_WithMalformedCursor_ThrowsInvalidParams(string cursor) - { - var catalog = CreateCatalog(10, "a"); - - var exception = await Assert.ThrowsAsync( - async () => await catalog.ListAsync(cursor, Context, TestContext.Current.CancellationToken)); - - Assert.Equal(McpErrorCode.InvalidParams, exception.ErrorCode); - } - - [Fact] - public async Task GetAsync_ReturnsSkillByUri() - { - var catalog = CreateCatalog(10, "alpha"); - - var skill = await catalog.GetAsync("skill://alpha/SKILL.md", Context, TestContext.Current.CancellationToken); - - Assert.NotNull(skill); - Assert.Equal("alpha", skill.Name); - } - [Theory] - [InlineData("skill://missing/SKILL.md")] - [InlineData("SKILL://ALPHA/SKILL.md")] - [InlineData("skill://alpha")] - public async Task GetAsync_WithUnknownUri_ReturnsNull(string uri) - { - var catalog = CreateCatalog(10, "alpha"); - - Assert.Null(await catalog.GetAsync(uri, Context, TestContext.Current.CancellationToken)); - } - - [Fact] - public async Task Constructor_SnapshotsEntries_SoLaterMutationsAreNotServed() - { - var source = CreateSkill("alpha"); - var catalog = new InMemoryMcpSkillCatalog([source]); - - source.Frontmatter["description"] = "changed"; - source.Resources.Resources![0].Digest = "sha256:" + new string('f', 64); - source.Uri = "skill://renamed/SKILL.md"; - - var served = await catalog.GetAsync("skill://alpha/SKILL.md", Context, TestContext.Current.CancellationToken); - Assert.NotNull(served); - Assert.Equal("A skill", served.Description); - Assert.Equal(s_validDigest, served.Resources.Resources![0].Digest); - } - - [Fact] - public async Task ReturnedEntries_AreCopies_SoMutatingThemDoesNotAffectLaterResponses() - { - var catalog = new InMemoryMcpSkillCatalog([CreateSkill("alpha")]); - - var first = await catalog.GetAsync("skill://alpha/SKILL.md", Context, TestContext.Current.CancellationToken); - first!.Frontmatter["description"] = "tampered"; - first.Resources.Resources![0].Digest = "sha256:" + new string('f', 64); - - var listed = await catalog.ListAsync(null, Context, TestContext.Current.CancellationToken); - listed.Skills[0].Frontmatter["description"] = "tampered again"; + [InlineData("skill://catalog/alpha/SKILL.md", "SKILL://CATALOG/alpha/SKILL.md")] + [InlineData("skill://catalog/alpha/SKILL.md", "skill://catalog/%61lpha/SKILL.md")] + [InlineData("repo:/skills/alpha/SKILL.md", "REPO:/skills/alpha/SKILL.md")] + public void CollectionUsesResourceEquivalentIdentitiesForLookupAndDuplicateDetection(string uri, string alias) + { + var entry = CreateSkill("alpha", resources: SkillResources.Dynamic); + entry.Uri = uri; + var skill = McpServerSkill.Create(entry); + entry.Uri = alias; + var equivalent = McpServerSkill.Create(entry); + var collection = new McpServerSkillCollection { skill }; + var resources = new McpServerResourceCollection + { + McpServerResource.Create(() => "content", new() { UriTemplate = uri }), + }; - var again = await catalog.GetAsync("skill://alpha/SKILL.md", Context, TestContext.Current.CancellationToken); - Assert.Equal("A skill", again!.Description); - Assert.Equal(s_validDigest, again.Resources.Resources![0].Digest); + Assert.True(resources.TryGetPrimitive(alias, out _)); + Assert.True(collection.TryGetPrimitive(alias, out var found)); + Assert.Same(skill, found); + Assert.Same(skill, collection[alias]); + Assert.False(collection.TryAdd(equivalent)); + Assert.Throws(() => collection.Add(equivalent)); + Assert.Same(skill, Assert.Single(collection)); + Assert.Equal(alias, skill.ProtocolSkill!.Uri); + Assert.True(collection.Remove(skill)); + Assert.False(collection.TryGetPrimitive(alias, out _)); } [Theory] - [InlineData(false)] - [InlineData(true)] - public async Task EntryMetaIsOwnedAcrossConstructionListingAndLookup(bool isDynamic) - { - var source = CreateSkill("alpha", resources: isDynamic ? SkillResources.Dynamic : null); - source.Meta = JsonNode.Parse("""{"example.com/extension":{"items":[{"value":"original"}]},"empty":null}""")!.AsObject(); - var expected = source.Meta.DeepClone(); - var catalog = new InMemoryMcpSkillCatalog([source]); - source.Meta["example.com/extension"]!["items"]![0]!["value"] = "source changed"; - - var page = await catalog.ListAsync(null, Context, TestContext.Current.CancellationToken); - var listed = Assert.Single(page.Skills); - Assert.True(JsonNode.DeepEquals(expected, listed.Meta)); - listed.Meta!["example.com/extension"]!["items"]![0]!["value"] = "listed entry changed"; - - var fetched = await catalog.GetAsync(source.Uri, Context, TestContext.Current.CancellationToken); - Assert.NotNull(fetched); - Assert.True(JsonNode.DeepEquals(expected, fetched.Meta)); - fetched.Meta!["example.com/extension"]!["items"]!.AsArray().Clear(); - - var listedAgain = await catalog.ListAsync(null, Context, TestContext.Current.CancellationToken); - var fetchedAgain = await catalog.GetAsync(source.Uri, Context, TestContext.Current.CancellationToken); - Assert.True(JsonNode.DeepEquals(expected, Assert.Single(listedAgain.Skills).Meta)); - Assert.True(JsonNode.DeepEquals(expected, fetchedAgain!.Meta)); + [InlineData("skill://catalog/Alpha/SKILL.md")] + [InlineData("skill://other/alpha/SKILL.md")] + [InlineData("skill://catalog/alpha/SKILL.md?query")] + [InlineData("skill://catalog/alpha/SKILL.md#fragment")] + [InlineData("skill://catalog/other/../alpha/SKILL.md")] + [InlineData("skill://catalog/other/%2e%2e/alpha/SKILL.md")] + [InlineData("skill://catalog//alpha/SKILL.md")] + [InlineData("skill://catalog/other%2f../alpha/SKILL.md")] + [InlineData("skill://[invalid]/alpha/SKILL.md")] + [InlineData("alpha/SKILL.md")] + public void CollectionDoesNotMatchDifferentOrUnsupportedUriShapes(string uri) + { + var entry = CreateSkill("alpha", resources: SkillResources.Dynamic); + entry.Uri = "skill://catalog/alpha/SKILL.md"; + var collection = new McpServerSkillCollection { McpServerSkill.Create(entry) }; + + Assert.False(collection.TryGetPrimitive(uri, out var found)); + Assert.Null(found); } [Theory] @@ -214,22 +84,22 @@ public async Task EntryMetaIsOwnedAcrossConstructionListingAndLookup(bool isDyna [InlineData("données-2024")] [InlineData("\U00010428")] [InlineData("\U00020000")] - public void Constructor_AcceptsUnicodeLowercaseNames(string name) + public void Create_AcceptsUnicodeLowercaseNames(string name) { - Assert.Equal(1, new InMemoryMcpSkillCatalog([CreateSkill(name)]).Count); + Assert.Equal(name, McpServerSkill.Create(CreateSkill(name)).ProtocolSkill!.Name); } [Theory] [InlineData("café")] [InlineData("数据分析")] [InlineData("\U00010428")] - public void Constructor_AcceptsPercentEncodedUnicodeNameSegments(string name) + public void Create_AcceptsPercentEncodedUnicodeNameSegments(string name) { var skill = CreateSkill(name); skill.Uri = $"skill://catalog/{Uri.EscapeDataString(name)}/SKILL.md"; skill.Resources = SkillResources.FromResources([new SkillResource { Uri = skill.Uri, Digest = s_validDigest, Size = 1 }]); - Assert.Equal(1, new InMemoryMcpSkillCatalog([skill]).Count); + Assert.Equal(skill.Uri, McpServerSkill.Create(skill).Id); } [Theory] @@ -237,37 +107,37 @@ public void Constructor_AcceptsPercentEncodedUnicodeNameSegments(string name) [InlineData("%2e%2e")] [InlineData("ca%2ffé")] [InlineData("ca%5cfé")] - public void Constructor_DoesNotDecodeNamesRepeatedlyOrAllowEncodedTraversal(string segment) + public void Create_DoesNotDecodeNamesRepeatedlyOrAllowEncodedTraversal(string segment) { var skill = CreateSkill("café"); skill.Uri = $"skill://catalog/{segment}/SKILL.md"; skill.Resources = SkillResources.FromResources([new SkillResource { Uri = skill.Uri, Digest = s_validDigest, Size = 1 }]); - Assert.Throws(() => new InMemoryMcpSkillCatalog([skill])); + Assert.Throws(() => McpServerSkill.Create(skill)); } [Theory] [InlineData("\u01c5")] [InlineData("\U00010400")] - public void Constructor_RejectsUppercaseOrTitlecaseNames(string name) + public void Create_RejectsUppercaseOrTitlecaseNames(string name) { - Assert.Throws(() => new InMemoryMcpSkillCatalog([CreateSkill(name)])); + Assert.Throws(() => McpServerSkill.Create(CreateSkill(name))); } [Theory] [InlineData(0xd801)] [InlineData(0xdc28)] - public void Constructor_RejectsUnpairedSurrogates(int codeUnit) + public void Create_RejectsUnpairedSurrogates(int codeUnit) { var name = new string((char)codeUnit, 1); - Assert.Throws(() => new InMemoryMcpSkillCatalog([CreateSkill(name)])); + Assert.Throws(() => McpServerSkill.Create(CreateSkill(name))); } [Theory] [InlineData(64, true)] [InlineData(65, false)] - public void Constructor_CountsNameLengthByUnicodeScalar(int length, bool valid) + public void Create_CountsNameLengthByUnicodeScalar(int length, bool valid) { var name = string.Concat(Enumerable.Repeat("\U00010428", length)); var skill = CreateSkill(name); @@ -276,11 +146,11 @@ public void Constructor_CountsNameLengthByUnicodeScalar(int length, bool valid) if (valid) { - Assert.Equal(1, new InMemoryMcpSkillCatalog([skill]).Count); + Assert.Equal(skill.Uri, McpServerSkill.Create(skill).Id); } else { - Assert.Throws(() => new InMemoryMcpSkillCatalog([skill])); + Assert.Throws(() => McpServerSkill.Create(skill)); } } @@ -290,36 +160,18 @@ public void Constructor_CountsNameLengthByUnicodeScalar(int length, bool valid) [InlineData("[]")] [InlineData("{}")] [InlineData("null")] - public void Constructor_RejectsNonStringMetadataValues(string value) + public void Create_RejectsNonStringMetadataValues(string value) { var skill = CreateSkill("alpha"); skill.Frontmatter["metadata"] = new JsonObject { ["value"] = JsonNode.Parse(value) }; - var exception = Assert.Throws(() => new InMemoryMcpSkillCatalog([skill])); + var exception = Assert.Throws(() => McpServerSkill.Create(skill)); Assert.Contains("metadata", exception.Message); } [Fact] - public void Constructor_WithDuplicateUris_Throws() - { - var duplicate = new[] { CreateSkill("alpha"), CreateSkill("alpha") }; - - Assert.Throws(() => new InMemoryMcpSkillCatalog(duplicate)); - } - - [Fact] - public void Constructor_WithNullSkills_Throws() => - Assert.Throws(() => new InMemoryMcpSkillCatalog(null!)); - - [Theory] - [InlineData(0)] - [InlineData(-1)] - public void Constructor_WithNonPositivePageSize_Throws(int pageSize) => - Assert.Throws(() => new InMemoryMcpSkillCatalog([], pageSize)); - - [Fact] - public void Constructor_AcceptsFullAgentSkillsFrontmatter() + public void Create_AcceptsFullAgentSkillsFrontmatter() { var skill = CreateSkill("alpha"); skill.Frontmatter["license"] = "MIT"; @@ -328,18 +180,7 @@ public void Constructor_AcceptsFullAgentSkillsFrontmatter() skill.Frontmatter["metadata"] = new JsonObject { ["author"] = "acme", ["version"] = "2.1.0" }; skill.Frontmatter["description"] = new string('d', 1024); - Assert.Equal(1, new InMemoryMcpSkillCatalog([skill]).Count); - } - - [Theory] - [InlineData("description", 513)] - [InlineData("compatibility", 300)] - public void Constructor_AcceptsSupplementaryFrontmatterWithinTheLimit(string field, int scalarCount) - { - var skill = CreateSkill("alpha"); - skill.Frontmatter[field] = string.Concat(Enumerable.Repeat("\U0001f600", scalarCount)); - - Assert.Equal(1, new InMemoryMcpSkillCatalog([skill]).Count); + Assert.True(JsonNode.DeepEquals(skill.Frontmatter, McpServerSkill.Create(skill).ProtocolSkill!.Frontmatter)); } [Theory] @@ -347,72 +188,37 @@ public void Constructor_AcceptsSupplementaryFrontmatterWithinTheLimit(string fie [InlineData("description", 1024, true)] [InlineData("compatibility", 500, false)] [InlineData("compatibility", 500, true)] - public void Constructor_UsesScalarCountsAtFrontmatterBoundaries(string field, int limit, bool mixed) + public void Create_UsesScalarCountsAtFrontmatterBoundaries(string field, int limit, bool mixed) { var text = (mixed ? "a" : "\U0001f600") + string.Concat(Enumerable.Repeat("\U0001f600", limit - 1)); var skill = CreateSkill("alpha"); skill.Frontmatter[field] = text; - Assert.Equal(1, new InMemoryMcpSkillCatalog([skill]).Count); + Assert.Equal(skill.Uri, McpServerSkill.Create(skill).Id); skill.Frontmatter[field] = text + "a"; - var error = Assert.Throws(() => new InMemoryMcpSkillCatalog([skill])); + var error = Assert.Throws(() => McpServerSkill.Create(skill)); Assert.Contains($"{limit + 1} characters", error.Message); } [Theory] [InlineData("description", 1024)] [InlineData("compatibility", 500)] - public void Constructor_CountsCombiningMarksSeparately(string field, int limit) + public void Create_CountsCombiningMarksSeparately(string field, int limit) { var skill = CreateSkill("alpha"); var text = string.Concat(Enumerable.Repeat("e\u0301", limit / 2)); skill.Frontmatter[field] = text; - Assert.Equal(1, new InMemoryMcpSkillCatalog([skill]).Count); + Assert.Equal(skill.Uri, McpServerSkill.Create(skill).Id); skill.Frontmatter[field] = text + "a"; - var error = Assert.Throws(() => new InMemoryMcpSkillCatalog([skill])); + var error = Assert.Throws(() => McpServerSkill.Create(skill)); Assert.Contains($"{limit + 1} characters", error.Message); } [Fact] - public void Constructor_ValidatesTheOwnedSnapshotAfterCustomSerialization() - { - var options = new JsonSerializerOptions { TypeInfoResolver = JsonTypeInfoResolver.Combine() }; - var typeInfo = JsonMetadataServices.CreateValueInfo(options, new ChangingMetadataConverter()); - var value = JsonValue.Create(1, typeInfo)!; - Assert.Equal(JsonValueKind.String, value.GetValueKind()); - var skill = CreateSkill("alpha"); - skill.Frontmatter["metadata"] = new JsonObject { ["value"] = value }; - - var error = Assert.Throws(() => new InMemoryMcpSkillCatalog([skill])); - - Assert.Contains("metadata", error.Message); - } - - private sealed class ChangingMetadataConverter : JsonConverter - { - private int _writes; - - public override int Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => - throw new NotSupportedException(); - - public override void Write(Utf8JsonWriter writer, int value, JsonSerializerOptions options) - { - if (++_writes == 1) - { - writer.WriteStringValue("initial-string"); - } - else - { - writer.WriteNumberValue(value); - } - } - } - - [Fact] - public void Constructor_PassesThroughFieldsHostsDoNotActOn() + public void Create_PassesThroughFieldsHostsDoNotActOn() { // Unknown fields keep structured values. Fields the Agent Skills specification defines are type-checked, and the // known metadata map has its own string-value requirement. @@ -421,41 +227,38 @@ public void Constructor_PassesThroughFieldsHostsDoNotActOn() skill.Frontmatter["license"] = "Apache-2.0"; skill.Frontmatter["custom"] = new JsonObject { ["version"] = 2.1, ["tags"] = new JsonArray("a") }; - Assert.Equal(1, new InMemoryMcpSkillCatalog([skill]).Count); + Assert.True(JsonNode.DeepEquals(skill.Frontmatter, McpServerSkill.Create(skill).ProtocolSkill!.Frontmatter)); skill.Frontmatter["allowed-tools"] = new JsonArray("Read", "Write"); - var exception = Assert.Throws(() => new InMemoryMcpSkillCatalog([skill])); + var exception = Assert.Throws(() => McpServerSkill.Create(skill)); Assert.Contains("'allowed-tools' frontmatter field that is not a string", exception.Message); } [Fact] - public void Constructor_AcceptsEmptyAuthority() + public void Create_AcceptsEmptyAuthority() { var skill = CreateSkill("alpha"); skill.Uri = "file:///alpha/SKILL.md"; skill.Resources = SkillResources.FromResources([new SkillResource { Uri = skill.Uri, Digest = s_validDigest, Size = 1 }]); - Assert.Equal(1, new InMemoryMcpSkillCatalog([skill]).Count); + Assert.Equal(skill.Uri, McpServerSkill.Create(skill).Id); } [Fact] - public void Constructor_AcceptsDynamicSkill() + public void Create_AcceptsDynamicSkill() { - var catalog = new InMemoryMcpSkillCatalog([CreateSkill("generated", resources: SkillResources.Dynamic)]); - - Assert.Equal(1, catalog.Count); + var primitive = McpServerSkill.Create(CreateSkill("generated", resources: SkillResources.Dynamic)); + Assert.True(primitive.ProtocolSkill!.Resources.IsDynamic); } [Fact] - public void Constructor_AcceptsNestedSkillPath() + public void Create_AcceptsNestedSkillPath() { var skill = CreateSkill("refunds"); skill.Uri = "skill://acme/billing/refunds/SKILL.md"; skill.Resources = SkillResources.FromResources([new SkillResource { Uri = skill.Uri, Digest = s_validDigest, Size = 1 }]); - var catalog = new InMemoryMcpSkillCatalog([skill]); - - Assert.Equal(1, catalog.Count); + Assert.Equal(skill.Uri, McpServerSkill.Create(skill).Id); } [Theory] @@ -463,13 +266,13 @@ public void Constructor_AcceptsNestedSkillPath() [InlineData("repo:skills/alpha/SKILL.md")] [InlineData("repo:alpha/SKILL.md")] [InlineData("urn:alpha/SKILL.md")] - public void Constructor_AcceptsNativeAbsoluteResourceUris(string uri) + public void Create_AcceptsNativeAbsoluteResourceUris(string uri) { var skill = CreateSkill("alpha"); skill.Uri = uri; skill.Resources = SkillResources.FromResources([new SkillResource { Uri = uri, Digest = s_validDigest, Size = 1 }]); - Assert.Equal(1, new InMemoryMcpSkillCatalog([skill]).Count); + Assert.Equal(uri, McpServerSkill.Create(skill).Id); } [Theory] @@ -479,19 +282,19 @@ public void Constructor_AcceptsNativeAbsoluteResourceUris(string uri) [InlineData("repo:skills/ca%2ffé/alpha/SKILL.md")] [InlineData("repo:/skills/alpha/SKILL.md?query")] [InlineData("repo:/skills/alpha/SKILL.md#fragment")] - public void Constructor_RejectsUnsafeNativeUriPaths(string uri) + public void Create_RejectsUnsafeNativeUriPaths(string uri) { var skill = CreateSkill("alpha"); skill.Uri = uri; skill.Resources = SkillResources.FromResources([new SkillResource { Uri = uri, Digest = s_validDigest, Size = 1 }]); - Assert.Throws(() => new InMemoryMcpSkillCatalog([skill])); + Assert.Throws(() => McpServerSkill.Create(skill)); } [Theory] [InlineData("repo:/skills/alpha/SKILL.md")] [InlineData("repo:alpha/SKILL.md")] - public void Constructor_RejectsTraversalInNativeManifestUris(string uri) + public void Create_RejectsTraversalInNativeManifestUris(string uri) { var skill = CreateSkill("alpha"); skill.Uri = uri; @@ -502,7 +305,7 @@ public void Constructor_RejectsTraversalInNativeManifestUris(string uri) new SkillResource { Uri = root + "/%2e%2e/secret.md", Digest = s_validDigest, Size = 1 }, ]); - var error = Assert.Throws(() => new InMemoryMcpSkillCatalog([skill])); + var error = Assert.Throws(() => McpServerSkill.Create(skill)); Assert.Contains("encoded separator", error.Message); } @@ -548,11 +351,6 @@ static object[] Case(string reason, Action mutate) new SkillResource { Uri = s.Uri, Digest = s_validDigest, Size = 1 }, new SkillResource { Uri = "skill://alpha/x.md#frag", Digest = s_validDigest, Size = 1 }, ])); - yield return Case("sizes that overflow when summed", s => s.Resources = SkillResources.FromResources( - [ - new SkillResource { Uri = s.Uri, Digest = s_validDigest, Size = long.MaxValue }, - new SkillResource { Uri = "skill://alpha/x.md", Digest = s_validDigest, Size = long.MaxValue }, - ])); yield return Case("description over 1024 characters", s => s.Frontmatter["description"] = new string('d', 1025)); yield return Case("compatibility over 500 characters", s => s.Frontmatter["compatibility"] = new string('c', 501)); yield return Case("compatibility empty", s => s.Frontmatter["compatibility"] = ""); @@ -615,27 +413,16 @@ static object[] Case(string reason, Action mutate) [new SkillResource { Uri = s.Uri, Digest = "sha512:" + new string('a', 64), Size = 1 }])); yield return Case("negative size", s => s.Resources = SkillResources.FromResources( [new SkillResource { Uri = s.Uri, Digest = s_validDigest, Size = -1 }])); - yield return Case("too many resources", s => s.Resources = SkillResources.FromResources( - Enumerable.Range(0, SkillsProtocol.MaxResourcesPerSkill + 1).Select(i => new SkillResource - { - Uri = i == 0 ? s.Uri : $"skill://alpha/f{i}.md", - Digest = s_validDigest, - Size = 1, - }))); - yield return Case("total size over limit", s => s.Resources = SkillResources.FromResources( - [ - new SkillResource { Uri = s.Uri, Digest = s_validDigest, Size = 1 }, - new SkillResource { Uri = "skill://alpha/big.bin", Digest = s_validDigest, Size = SkillsProtocol.MaxTotalSizeBytes }, - ])); } [Theory] [MemberData(nameof(InvalidSkills))] - public void Constructor_RejectsInvalidSkill(string reason, Skill skill) + public void Create_RejectsInvalidSkill(string reason, Skill skill) { - var exception = Assert.Throws(() => new InMemoryMcpSkillCatalog([skill])); + var exception = Assert.Throws("protocolSkill", () => McpServerSkill.Create(skill)); Assert.Contains("skill://", exception.Message); Assert.NotEmpty(reason); } + } diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerSkillPackageTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerSkillPackageTests.cs new file mode 100644 index 000000000..02f6d6946 --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Server/McpServerSkillPackageTests.cs @@ -0,0 +1,626 @@ +using ModelContextProtocol.Extensions.Skills; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using System.Text; +using System.Text.Json.Nodes; + +namespace ModelContextProtocol.Tests.Server; + +/// +/// Tests for : manifest computation, resource creation, path handling, validation, and +/// loading from a directory. +/// +public class McpServerSkillPackageTests +{ + private const string SkillUri = "skill://git-workflow/SKILL.md"; + private const string SkillMarkdown = "---\nname: git-workflow\ndescription: Git conventions\n---\n\n# Git workflow\n"; + + private JsonObject Frontmatter(string name = "git-workflow", string? description = "Git conventions") => new() + { + ["name"] = name, + ["description"] = description, + }; + + [Fact] + public void Create_ComputesManifestFromFileBytes() + { + byte[] guide = Encoding.UTF8.GetBytes("# Guide\n"); + + var frontmatter = Frontmatter(); + var skill = McpServerSkillPackage.Create( + [ + new McpServerSkillFile { Path = "references/GUIDE.md", Content = guide }, + McpServerSkillFile.FromText("SKILL.md", SkillMarkdown), + ], new McpServerSkillPackageCreateOptions { Uri = SkillUri, Frontmatter = frontmatter }); + + var entry = skill.Skill.ProtocolSkill!; + Assert.Equal(SkillUri, entry.Uri); + Assert.True(JsonNode.DeepEquals(frontmatter, entry.Frontmatter)); + Assert.False(entry.Resources.IsDynamic); + + var manifest = entry.Resources.Entries; + Assert.Equal(2, manifest.Count); + + // SKILL.md is always first, regardless of input order. + Assert.Equal(SkillUri, manifest[0].Uri); + Assert.Equal(Encoding.UTF8.GetByteCount(SkillMarkdown), manifest[0].Size); + Assert.Equal(SkillVerifier.ComputeDigest(Encoding.UTF8.GetBytes(SkillMarkdown)), manifest[0].Digest); + + Assert.Equal("skill://git-workflow/references/GUIDE.md", manifest[1].Uri); + Assert.Equal(guide.Length, manifest[1].Size); + Assert.Equal(SkillVerifier.ComputeDigest(guide), manifest[1].Digest); + } + + [Fact] + public async Task StandaloneAuthoringProducesMatchingResourcesWithSpecMetadata() + { + byte[] binary = new byte[1024]; + var skill = McpServerSkillPackage.Create( + [ + McpServerSkillFile.FromText("SKILL.md", SkillMarkdown), + McpServerSkillFile.FromText("scripts/run.py", "print('hi')\n"), + McpServerSkillFile.FromText("data/table.csv", "a,b\n", mimeType: "text/x-custom"), + McpServerSkillFile.FromText("config.json", "{}"), + new McpServerSkillFile { Path = "data.bin", Content = binary, MimeType = "application/octet-stream" }, + ], new McpServerSkillPackageCreateOptions { Uri = SkillUri, Frontmatter = Frontmatter() }); + + Assert.Equal(5, skill.Resources.Count); + + var skillFile = skill.Resources[0].ProtocolResource; + Assert.NotNull(skillFile); + Assert.Equal(SkillUri, skillFile.Uri); + Assert.Equal("git-workflow", skillFile.Name); + Assert.Equal("Git conventions", skillFile.Description); + Assert.Equal("text/markdown", skillFile.MimeType); + + var script = Assert.Single(skill.Resources, resource => resource.ProtocolResource!.Name == "scripts/run.py").ProtocolResource; + Assert.NotNull(script); + Assert.Equal("skill://git-workflow/scripts/run.py", script.Uri); + Assert.Equal("scripts/run.py", script.Name); + Assert.Equal("text/x-python", script.MimeType); + + var table = Assert.Single(skill.Resources, resource => resource.ProtocolResource!.Name == "data/table.csv").ProtocolResource; + Assert.NotNull(table); + Assert.Equal("text/x-custom", table.MimeType); + + Assert.True(Assert.IsAssignableFrom>(skill.Resources).IsReadOnly); + await using var transport = new StreamServerTransport(Stream.Null, Stream.Null); + await using var server = McpServer.Create(transport, new McpServerOptions()); + var entry = skill.Skill.ProtocolSkill!; + foreach (var resource in skill.Resources) + { + var protocol = resource.ProtocolResource!; + var context = new RequestContext( + server, new JsonRpcRequest { Method = RequestMethods.ResourcesRead }, + new ReadResourceRequestParams { Uri = protocol.Uri }); + var result = await resource.ReadAsync(context, TestContext.Current.CancellationToken); + if (protocol.MimeType == "application/octet-stream") + { + Assert.Equal(binary, Assert.IsType(Assert.Single(result.Contents)).DecodedData.ToArray()); + } + else + { + Assert.IsType(Assert.Single(result.Contents)); + } + + SkillVerifier.Verify(entry, context.Params.Uri, result); + } + } + + [Theory] + [InlineData("./SKILL.md", "SKILL.md")] + [InlineData("references\\GUIDE.md", "references/GUIDE.md")] + public void Create_NormalizesFilePaths(string input, string expectedRelative) + { + var files = new List { McpServerSkillFile.FromText(input, expectedRelative == "SKILL.md" ? SkillMarkdown : "x") }; + if (expectedRelative != "SKILL.md") + { + files.Add(McpServerSkillFile.FromText("SKILL.md", SkillMarkdown)); + } + + var skill = McpServerSkillPackage.Create(files, new McpServerSkillPackageCreateOptions { Uri = SkillUri, Frontmatter = Frontmatter() }); + + Assert.Contains(skill.Skill.ProtocolSkill!.Resources.Entries!, r => r.Uri == "skill://git-workflow/" + expectedRelative); + } + + [Theory] + [InlineData("")] + [InlineData("/SKILL.md")] + [InlineData("references/")] + [InlineData("../SKILL.md")] + [InlineData("a/./b.md")] + [InlineData("a//b.md")] + public void Create_RejectsUnsafePaths(string path) + { + Assert.Throws(() => McpServerSkillPackage.Create( + [ + McpServerSkillFile.FromText("SKILL.md", SkillMarkdown), + McpServerSkillFile.FromText(path, "x"), + ], new McpServerSkillPackageCreateOptions { Uri = SkillUri, Frontmatter = Frontmatter() })); + } + + [Fact] + public void Create_RejectsMissingSkillFile() + { + var exception = Assert.Throws(() => + McpServerSkillPackage.Create([McpServerSkillFile.FromText("README.md", "x")], new McpServerSkillPackageCreateOptions { Uri = SkillUri, Frontmatter = Frontmatter() })); + + Assert.Contains("SKILL.md", exception.Message); + } + + [Fact] + public void Create_RejectsDuplicatePaths() + { + Assert.Throws(() => McpServerSkillPackage.Create( + [ + McpServerSkillFile.FromText("SKILL.md", SkillMarkdown), + McpServerSkillFile.FromText("./SKILL.md", SkillMarkdown), + ], new McpServerSkillPackageCreateOptions { Uri = SkillUri, Frontmatter = Frontmatter() })); + } + + [Fact] + public void Create_RejectsUriNotEndingInSkillFile() + { + Assert.Throws(() => + McpServerSkillPackage.Create([McpServerSkillFile.FromText("SKILL.md", SkillMarkdown)], new McpServerSkillPackageCreateOptions { Uri = "skill://git-workflow", Frontmatter = Frontmatter() })); + } + + [Fact] + public void Create_RejectsFrontmatterNameMismatch() + { + var exception = Assert.Throws(() => + McpServerSkillPackage.Create([McpServerSkillFile.FromText("SKILL.md", SkillMarkdown)], new McpServerSkillPackageCreateOptions { Uri = SkillUri, Frontmatter = Frontmatter(name: "git-flow") })); + + Assert.Equal("options", exception.ParamName); + Assert.Contains("git-flow", exception.Message); + } + + [Fact] + public void Create_RejectsMissingDescription() + { + Assert.Throws(() => + McpServerSkillPackage.Create([McpServerSkillFile.FromText("SKILL.md", SkillMarkdown)], new McpServerSkillPackageCreateOptions { Uri = SkillUri, Frontmatter = Frontmatter(description: null) })); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Create_ReadsFrontmatterAndUsesImplicitOrExplicitUri(bool explicitUri) + { + var uri = explicitUri ? "skill://tenant/git-workflow/SKILL.md" : SkillUri; + var skill = McpServerSkillPackage.Create( + [McpServerSkillFile.FromText("SKILL.md", SkillMarkdown), McpServerSkillFile.FromText("a.md", "x")], + explicitUri ? new McpServerSkillPackageCreateOptions { Uri = uri } : null); + + Assert.True(JsonNode.DeepEquals(Frontmatter(), skill.Skill.ProtocolSkill!.Frontmatter)); + Assert.Equal(uri, skill.Skill.ProtocolSkill.Uri); + Assert.Equal(uri.Replace("SKILL.md", "a.md"), skill.Skill.ProtocolSkill.Resources.Entries![1].Uri); + } + + [Fact] + public void Create_RejectsUriWhoseNameDiffersFromTheFile() + { + var exception = Assert.Throws(() => + McpServerSkillPackage.Create([McpServerSkillFile.FromText("SKILL.md", SkillMarkdown)], new McpServerSkillPackageCreateOptions { Uri = "skill://other-name/SKILL.md" })); + + Assert.Contains("git-workflow", exception.Message); + } + + [Fact] + public void Create_WithUnreadableFrontmatter_ExplainsAndPointsAtTheExplicitOverload() + { + var exception = Assert.Throws(() => + McpServerSkillPackage.Create([McpServerSkillFile.FromText("SKILL.md", "---\nname: &a git-workflow\ndescription: d\n---\n")])); + + Assert.Contains("anchors", exception.Message); + Assert.Contains("JsonObject", exception.Message); + + var noFrontmatter = Assert.Throws(() => + McpServerSkillPackage.Create([McpServerSkillFile.FromText("SKILL.md", "# No frontmatter\n")])); + Assert.Contains("must begin", noFrontmatter.Message); + } + + [Fact] + public void Create_WithExplicitFrontmatter_RejectsMismatchWithTheFile() + { + var mismatched = Frontmatter(); + mismatched["license"] = "MIT"; + + var exception = Assert.Throws(() => + McpServerSkillPackage.Create([McpServerSkillFile.FromText("SKILL.md", SkillMarkdown)], new McpServerSkillPackageCreateOptions { Uri = SkillUri, Frontmatter = mismatched })); + + Assert.Equal("options", exception.ParamName); + Assert.Contains("does not match", exception.Message); + Assert.Contains("\"license\"", exception.Message); + } + + [Fact] + public void Create_WithExplicitFrontmatterMissingName_AttributesFailureToOptions() + { + var exception = Assert.Throws("options", () => McpServerSkillPackage.Create( + [McpServerSkillFile.FromText("SKILL.md", "---\nname: &name alpha\ndescription: Alpha\n---\n")], + new McpServerSkillPackageCreateOptions + { + Frontmatter = new JsonObject { ["description"] = "Alpha" }, + })); + + Assert.Contains("'name'", exception.Message); + } + + [Fact] + public void Create_WithExplicitFrontmatter_AcceptsMatchAndUnreadableFile() + { + // Matches the file: fine. + McpServerSkillPackage.Create([McpServerSkillFile.FromText("SKILL.md", SkillMarkdown)], new McpServerSkillPackageCreateOptions { Uri = SkillUri, Frontmatter = Frontmatter() }); + + // Values are compared as typed JSON: a quoted "2" in the file is the string "2" in the object. + McpServerSkillPackage.Create( + [McpServerSkillFile.FromText("SKILL.md", "---\nname: git-workflow\ndescription: d\nmetadata:\n major: \"2\"\n---\n")], new McpServerSkillPackageCreateOptions { Uri = SkillUri, Frontmatter = new JsonObject { ["name"] = "git-workflow", ["description"] = "d", ["metadata"] = new JsonObject { ["major"] = "2" } } }); + + // The caller supplies the complete, prevalidated frontmatter for this unsupported YAML form. + var escapeHatch = McpServerSkillPackage.Create( + [McpServerSkillFile.FromText("SKILL.md", "---\nname: &n git-workflow\ndescription: Git conventions\n---\n")], new McpServerSkillPackageCreateOptions { Uri = SkillUri, Frontmatter = Frontmatter() }); + Assert.Equal("git-workflow", escapeHatch.Skill.ProtocolSkill!.Name); + + // The caller has also validated this complete multi-line quoted value. + var multiLine = McpServerSkillPackage.Create( + [McpServerSkillFile.FromText("SKILL.md", "---\nname: git-workflow\ndescription: \"Git\n conventions\"\n---\n")], new McpServerSkillPackageCreateOptions { Uri = SkillUri, Frontmatter = Frontmatter() }); + Assert.Equal("Git conventions", multiLine.Skill.ProtocolSkill!.Description); + + // So is a flow collection that closes on a later line. + var explicitTags = Frontmatter(); + explicitTags["tags"] = new JsonArray("Read", "Write"); + var multiLineFlow = McpServerSkillPackage.Create( + [McpServerSkillFile.FromText("SKILL.md", "---\nname: git-workflow\ndescription: Git conventions\ntags: [Read,\n Write]\n---\n")], new McpServerSkillPackageCreateOptions { Uri = SkillUri, Frontmatter = explicitTags }); + Assert.Equal(2, multiLineFlow.Skill.ProtocolSkill!.Frontmatter["tags"]!.AsArray().Count); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Create_AttributesInvalidFrontmatterToFilesOrOptions(bool explicitUri) + { + var files = new[] { McpServerSkillFile.FromText("SKILL.md", "---\nname: git-workflow\ndescription: 123\n---\n") }; + var options = new McpServerSkillPackageCreateOptions { Uri = explicitUri ? SkillUri : null }; + + Assert.Throws("files", () => McpServerSkillPackage.Create(files, options)); + + options.Frontmatter = new JsonObject { ["name"] = "git-workflow", ["description"] = 123 }; + Assert.Throws("options", () => McpServerSkillPackage.Create(files, options)); + } + + [Theory] + [InlineData("# no frontmatter block\n", "must begin")] + [InlineData("---\nname: git-workflow\n", "not closed")] + [InlineData("---\nname: a: b\n---\n", "cannot contain")] + [InlineData("---\n\tname: x\n---\n", "tabs")] + [InlineData("---\nname: git-workflow\ndescription: \"never closed\n---\n", "unterminated")] + [InlineData("---\nname: git-workflow\ndescription: d\nallowed-tools: [Read, Write\n---\n", "unterminated flow")] + [InlineData("---\nname: git-workflow\ndescription: d\n extra # comment\n more\n---\n", "a comment ended that value")] + public void Create_WithExplicitFrontmatter_StillRejectsMalformedSkillFile(string markdown, string messageFragment) + { + // These errors are detected by the bounded reader; explicit metadata does not bypass them. + var exception = Assert.Throws(() => + McpServerSkillPackage.Create([McpServerSkillFile.FromText("SKILL.md", markdown)], new McpServerSkillPackageCreateOptions { Uri = SkillUri, Frontmatter = Frontmatter() })); + + Assert.Contains(messageFragment, exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void Create_WithExplicitFrontmatter_RejectsNonUtf8SkillFile() + { + var exception = Assert.Throws(() => McpServerSkillPackage.Create( + [new McpServerSkillFile { Path = "SKILL.md", Content = new byte[] { 0x2D, 0x2D, 0x2D, 0x0A, 0xFF, 0xFE } }], new McpServerSkillPackageCreateOptions { Uri = SkillUri, Frontmatter = Frontmatter() })); + + Assert.Contains("UTF-8", exception.Message); + } + + [Fact] + public void Create_RejectsNullArguments() + { + Assert.Throws("protocolSkill", () => McpServerSkill.Create((Skill)null!)); + Assert.Throws("files", () => McpServerSkillPackage.Create((IEnumerable)null!)); + Assert.Throws("directoryPath", () => McpServerSkillPackage.CreateFromDirectory(null!)); + Assert.Throws("directoryPath", () => McpServerSkillPackage.CreateFromSubdirectories(null!)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void CreateFromDirectory_LoadsFilesRecursivelyWithImplicitOrExplicitUri(bool explicitUri) + { + string directory = Path.Combine(Path.GetTempPath(), "mcp-skill-" + Guid.NewGuid().ToString("N")); + string uri = explicitUri ? "skill://tenant/git-workflow/SKILL.md" : SkillUri; + try + { + Directory.CreateDirectory(Path.Combine(directory, "templates", "regional")); + File.WriteAllText(Path.Combine(directory, "SKILL.md"), SkillMarkdown); + File.WriteAllText(Path.Combine(directory, "templates", "invoice.md"), "# Invoice\n"); + File.WriteAllBytes(Path.Combine(directory, "templates", "regional", "logo.png"), [0x89, 0x50, 0x4E, 0x47, 0xFF, 0xFE]); + + var skill = McpServerSkillPackage.CreateFromDirectory(directory, new McpServerSkillPackageCreateOptions + { + Uri = explicitUri ? uri : null, + Frontmatter = Frontmatter(), + Metadata = ["directory-metadata"], + Meta = new JsonObject { ["example.com/category"] = "directory" }, + }); + Assert.Equal(uri, skill.Skill.ProtocolSkill!.Uri); + Assert.Equal("directory-metadata", Assert.Single(skill.Skill.Metadata)); + Assert.Equal("directory", skill.Skill.ProtocolSkill!.Meta!["example.com/category"]!.GetValue()); + + var uris = skill.Skill.ProtocolSkill!.Resources.Entries!.Select(r => r.Uri).ToList(); + Assert.Equal( + [ + uri, + uri.Replace("SKILL.md", "templates/invoice.md"), + uri.Replace("SKILL.md", "templates/regional/logo.png"), + ], uris); + + Assert.Equal("image/png", skill.Resources[2].ProtocolResource!.MimeType); + Assert.Equal(6, skill.Skill.ProtocolSkill!.Resources.Entries![2].Size); + } + finally + { + Directory.Delete(directory, recursive: true); + } + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void CreateFromDirectory_AttributesInvalidFrontmatterToDirectoryOrOptions(bool explicitUri) + { + string directory = Path.Combine(Path.GetTempPath(), "mcp-skill-invalid-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(directory); + try + { + File.WriteAllText(Path.Combine(directory, "SKILL.md"), "---\nname: git-workflow\ndescription: 123\n---\n"); + var options = new McpServerSkillPackageCreateOptions { Uri = explicitUri ? SkillUri : null }; + + Assert.Throws("directoryPath", () => McpServerSkillPackage.CreateFromDirectory(directory, options)); + + options.Frontmatter = new JsonObject { ["name"] = "git-workflow", ["description"] = 123 }; + Assert.Throws("options", () => McpServerSkillPackage.CreateFromDirectory(directory, options)); + } + finally + { + Directory.Delete(directory, recursive: true); + } + } + + [Fact] + public async Task SubdirectoryPackagesAreStandaloneOrderedAndIncludeCommonMetadata() + { + string directory = Path.Combine(Path.GetTempPath(), "mcp-skill-packages-" + Guid.NewGuid().ToString("N")); + try + { + foreach (var (child, name) in new[] { ("z-child", "beta"), ("a-child", "alpha") }) + { + Directory.CreateDirectory(Path.Combine(directory, child)); + File.WriteAllText(Path.Combine(directory, child, "SKILL.md"), $"---\nname: {name}\ndescription: {name}\n---\n"); + } + + var metadata = new List { "package-tag" }; + var meta = new JsonObject { ["example.com/category"] = new JsonObject { ["value"] = "original" } }; + var options = new McpServerSkillPackageDiscoveryOptions + { + UriPrefix = "skill://tenant", + Metadata = metadata, + Meta = meta, + }; + var packages = McpServerSkillPackage.CreateFromSubdirectories(directory, options); + Assert.Equal("skill://tenant", options.UriPrefix); + Assert.True(Assert.IsAssignableFrom>(packages).IsReadOnly); + Assert.Equal(["skill://tenant/alpha/SKILL.md", "skill://tenant/beta/SKILL.md"], + packages.Select(package => package.Skill.Id)); + + var skills = new McpServerSkillCollection(); + var resources = new McpServerResourceCollection(); + await using var transport = new StreamServerTransport(Stream.Null, Stream.Null); + await using var server = McpServer.Create(transport, new McpServerOptions()); + foreach (var package in packages) + { + skills.TryAdd(package.Skill); + Assert.Equal("package-tag", Assert.Single(package.Skill.Metadata)); + var entry = package.Skill.ProtocolSkill!; + Assert.True(JsonNode.DeepEquals(meta, entry.Meta)); + foreach (var resource in package.Resources) + { + resources.TryAdd(resource); + Assert.Null(resource.ProtocolResource!.Meta); + var context = new RequestContext( + server, new JsonRpcRequest { Method = RequestMethods.ResourcesRead }, + new ReadResourceRequestParams { Uri = resource.ProtocolResource.Uri }); + SkillVerifier.Verify(entry, context.Params.Uri, await resource.ReadAsync(context, TestContext.Current.CancellationToken)); + } + + } + + Assert.Equal(2, skills.Count); + Assert.Equal(2, resources.Count); + } + finally + { + Directory.Delete(directory, recursive: true); + } + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void SubdirectoryBatchValidatesFilesEvenForDuplicateSkillIdentities(bool conflicting) + { + string directory = Path.Combine(Path.GetTempPath(), "mcp-skill-package-conflict-" + Guid.NewGuid().ToString("N")); + try + { + foreach (string child in new[] { "first", "second" }) + { + Directory.CreateDirectory(Path.Combine(directory, child)); + File.WriteAllText(Path.Combine(directory, child, "SKILL.md"), + SkillMarkdown + (conflicting && child == "second" ? "changed" : "")); + } + + if (conflicting) + { + Assert.Throws(() => McpServerSkillPackage.CreateFromSubdirectories(directory)); + } + else + { + var packages = McpServerSkillPackage.CreateFromSubdirectories(directory); + Assert.Equal(2, packages.Count); + Assert.All(packages, package => Assert.Equal(SkillUri, package.Skill.Id)); + Assert.Equal(packages[0].Skill.ProtocolSkill!.Resources.Entries![0].Digest, + packages[1].Skill.ProtocolSkill!.Resources.Entries![0].Digest); + } + } + finally + { + Directory.Delete(directory, recursive: true); + } + } + + [Fact] + public void Create_EscapesUriSyntaxCharactersInFilePaths() + { + var skill = McpServerSkillPackage.Create( + [ + McpServerSkillFile.FromText("SKILL.md", SkillMarkdown), + McpServerSkillFile.FromText("templates/{name} v2.md", "template"), + McpServerSkillFile.FromText("notes/a#b?c.md", "note"), + ], new McpServerSkillPackageCreateOptions { Uri = SkillUri, Frontmatter = Frontmatter() }); + + var manifest = skill.Skill.ProtocolSkill!.Resources.Entries!; + Assert.Equal("skill://git-workflow/templates/%7Bname%7D%20v2.md", manifest[2].Uri); + Assert.Equal("skill://git-workflow/notes/a%23b%3Fc.md", manifest[1].Uri); + + // Every file is a concrete resource, never a template, and its resource URI equals its manifest URI. + for (int i = 0; i < manifest.Count; i++) + { + Assert.False(skill.Resources[i].IsTemplated); + Assert.Equal(manifest[i].Uri, skill.Resources[i].ProtocolResource!.Uri); + } + + Assert.Equal("templates/{name} v2.md", skill.Resources[2].ProtocolResource!.Name); + } + + [Fact] + public void Create_RequiresAStringNameToDeriveTheUri() + { + var exception = Assert.Throws(() => + McpServerSkillPackage.Create([McpServerSkillFile.FromText("SKILL.md", "---\nname: 123\ndescription: d\n---\n")])); + + Assert.Contains("must declare a string 'name'", exception.Message); + } + + [Fact] + public void Create_RejectsNullFileEntries() + { + var exception = Assert.Throws(() => + McpServerSkillPackage.Create([McpServerSkillFile.FromText("SKILL.md", SkillMarkdown), null!], new McpServerSkillPackageCreateOptions { Uri = SkillUri })); + + Assert.Contains("null entries", exception.Message); + Assert.Equal("files", exception.ParamName); + } + +#if NET + [Fact] + public void CreateFromDirectory_RejectsSymbolicLinks() + { + string root = Path.Combine(Path.GetTempPath(), "mcp-skill-link-" + Guid.NewGuid().ToString("N")); + try + { + string skillDirectory = Path.Combine(root, "skill"); + Directory.CreateDirectory(skillDirectory); + File.WriteAllText(Path.Combine(skillDirectory, "SKILL.md"), SkillMarkdown); + File.WriteAllText(Path.Combine(root, "outside.txt"), "private data"); + + try + { + File.CreateSymbolicLink(Path.Combine(skillDirectory, "linked.txt"), Path.Combine(root, "outside.txt")); + } + catch (Exception e) when (e is UnauthorizedAccessException or IOException) + { + Assert.Skip($"Cannot create symbolic links here: {e.Message}"); + } + + var exception = Assert.Throws(() => McpServerSkillPackage.CreateFromDirectory(skillDirectory, new McpServerSkillPackageCreateOptions { Uri = SkillUri, Frontmatter = Frontmatter() })); + Assert.Contains("linked.txt", exception.Message); + Assert.Equal("directoryPath", exception.ParamName); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + [Fact] + public void CreateFromDirectory_RejectsASymbolicLinkAsTheRoot() + { + string root = Path.Combine(Path.GetTempPath(), "mcp-skill-rootlink-" + Guid.NewGuid().ToString("N")); + try + { + string target = Path.Combine(root, "target"); + Directory.CreateDirectory(target); + File.WriteAllText(Path.Combine(target, "SKILL.md"), SkillMarkdown); + + string link = Path.Combine(root, "link"); + try + { + Directory.CreateSymbolicLink(link, target); + } + catch (Exception e) when (e is UnauthorizedAccessException or IOException) + { + Assert.Skip($"Cannot create symbolic links here: {e.Message}"); + } + + var exception = Assert.Throws(() => McpServerSkillPackage.CreateFromDirectory(link)); + Assert.Equal("directoryPath", exception.ParamName); + + // The target itself is fine. + Assert.Equal(SkillUri, McpServerSkillPackage.CreateFromDirectory(target).Skill.ProtocolSkill!.Uri); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + [Fact] + public void CreateFromDirectory_RejectsDirectorySymbolicLinks() + { + string root = Path.Combine(Path.GetTempPath(), "mcp-skill-dirlink-" + Guid.NewGuid().ToString("N")); + try + { + string skillDirectory = Path.Combine(root, "skill"); + Directory.CreateDirectory(skillDirectory); + Directory.CreateDirectory(Path.Combine(root, "outside")); + File.WriteAllText(Path.Combine(skillDirectory, "SKILL.md"), SkillMarkdown); + File.WriteAllText(Path.Combine(root, "outside", "secret.txt"), "private data"); + + try + { + Directory.CreateSymbolicLink(Path.Combine(skillDirectory, "linked"), Path.Combine(root, "outside")); + } + catch (Exception e) when (e is UnauthorizedAccessException or IOException) + { + Assert.Skip($"Cannot create symbolic links here: {e.Message}"); + } + + Assert.Throws(() => McpServerSkillPackage.CreateFromDirectory(skillDirectory, new McpServerSkillPackageCreateOptions { Uri = SkillUri, Frontmatter = Frontmatter() })); + } + finally + { + Directory.Delete(root, recursive: true); + } + } +#endif + + [Fact] + public void CreateFromDirectory_WithMissingDirectory_Throws() + { + string directory = Path.Combine(Path.GetTempPath(), "mcp-skill-missing-" + Guid.NewGuid().ToString("N")); + + Assert.Throws(() => McpServerSkillPackage.CreateFromDirectory(directory, new McpServerSkillPackageCreateOptions { Uri = SkillUri, Frontmatter = Frontmatter() })); + } +} diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerSkillTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerSkillTests.cs deleted file mode 100644 index f7cb0a248..000000000 --- a/tests/ModelContextProtocol.Tests/Server/McpServerSkillTests.cs +++ /dev/null @@ -1,543 +0,0 @@ -using ModelContextProtocol.Extensions.Skills; -using System.Text; -using System.Text.Json.Nodes; - -namespace ModelContextProtocol.Tests.Server; - -/// -/// Tests for : manifest computation, resource creation, path handling, validation, and -/// loading from a directory. -/// -public class McpServerSkillTests -{ - private const string SkillUri = "skill://git-workflow/SKILL.md"; - private const string SkillMarkdown = "---\nname: git-workflow\ndescription: Git conventions\n---\n\n# Git workflow\n"; - - private JsonObject Frontmatter(string name = "git-workflow", string? description = "Git conventions") => new() - { - ["name"] = name, - ["description"] = description, - }; - - [Fact] - public void Create_ComputesManifestFromFileBytes() - { - byte[] guide = Encoding.UTF8.GetBytes("# Guide\n"); - - var frontmatter = Frontmatter(); - var skill = McpServerSkill.Create(SkillUri, frontmatter, - [ - new McpServerSkillFile { Path = "references/GUIDE.md", Content = guide }, - McpServerSkillFile.FromText("SKILL.md", SkillMarkdown), - ]); - - var entry = skill.ProtocolSkill; - Assert.Equal(SkillUri, entry.Uri); - Assert.True(JsonNode.DeepEquals(frontmatter, entry.Frontmatter)); - Assert.NotSame(frontmatter, entry.Frontmatter); - Assert.False(entry.Resources.IsDynamic); - - var manifest = entry.Resources.Resources!; - Assert.Equal(2, manifest.Count); - - // SKILL.md is always first, regardless of input order. - Assert.Equal(SkillUri, manifest[0].Uri); - Assert.Equal(Encoding.UTF8.GetByteCount(SkillMarkdown), manifest[0].Size); - Assert.Equal(SkillVerifier.ComputeDigest(Encoding.UTF8.GetBytes(SkillMarkdown)), manifest[0].Digest); - - Assert.Equal("skill://git-workflow/references/GUIDE.md", manifest[1].Uri); - Assert.Equal(guide.Length, manifest[1].Size); - Assert.Equal(SkillVerifier.ComputeDigest(guide), manifest[1].Digest); - } - - [Fact] - public void Create_ProducesOneResourcePerFileWithSpecMetadata() - { - var skill = McpServerSkill.Create(SkillUri, Frontmatter(), - [ - McpServerSkillFile.FromText("SKILL.md", SkillMarkdown), - McpServerSkillFile.FromText("scripts/run.py", "print('hi')\n"), - McpServerSkillFile.FromText("data/table.csv", "a,b\n", mimeType: "text/x-custom"), - ]); - - Assert.Equal(3, skill.Resources.Count); - - var skillFile = skill.Resources[0].ProtocolResource; - Assert.NotNull(skillFile); - Assert.Equal(SkillUri, skillFile.Uri); - Assert.Equal("git-workflow", skillFile.Name); - Assert.Equal("Git conventions", skillFile.Description); - Assert.Equal("text/markdown", skillFile.MimeType); - - var script = skill.Resources[2].ProtocolResource; - Assert.NotNull(script); - Assert.Equal("skill://git-workflow/scripts/run.py", script.Uri); - Assert.Equal("scripts/run.py", script.Name); - Assert.Equal("text/x-python", script.MimeType); - - var table = skill.Resources[1].ProtocolResource; - Assert.NotNull(table); - Assert.Equal("text/x-custom", table.MimeType); - } - - [Theory] - [InlineData("./SKILL.md", "SKILL.md")] - [InlineData("references\\GUIDE.md", "references/GUIDE.md")] - public void Create_NormalizesFilePaths(string input, string expectedRelative) - { - var files = new List { McpServerSkillFile.FromText(input, expectedRelative == "SKILL.md" ? SkillMarkdown : "x") }; - if (expectedRelative != "SKILL.md") - { - files.Add(McpServerSkillFile.FromText("SKILL.md", SkillMarkdown)); - } - - var skill = McpServerSkill.Create(SkillUri, Frontmatter(), files); - - Assert.Contains(skill.ProtocolSkill.Resources.Resources!, r => r.Uri == "skill://git-workflow/" + expectedRelative); - } - - [Theory] - [InlineData("")] - [InlineData("/SKILL.md")] - [InlineData("references/")] - [InlineData("../SKILL.md")] - [InlineData("a/./b.md")] - [InlineData("a//b.md")] - public void Create_RejectsUnsafePaths(string path) - { - Assert.Throws(() => McpServerSkill.Create(SkillUri, Frontmatter(), - [ - McpServerSkillFile.FromText("SKILL.md", SkillMarkdown), - McpServerSkillFile.FromText(path, "x"), - ])); - } - - [Fact] - public void Create_RejectsMissingSkillFile() - { - var exception = Assert.Throws(() => - McpServerSkill.Create(SkillUri, Frontmatter(), [McpServerSkillFile.FromText("README.md", "x")])); - - Assert.Contains("SKILL.md", exception.Message); - } - - [Fact] - public void Create_RejectsDuplicatePaths() - { - Assert.Throws(() => McpServerSkill.Create(SkillUri, Frontmatter(), - [ - McpServerSkillFile.FromText("SKILL.md", SkillMarkdown), - McpServerSkillFile.FromText("./SKILL.md", SkillMarkdown), - ])); - } - - [Fact] - public void Create_RejectsUriNotEndingInSkillFile() - { - Assert.Throws(() => - McpServerSkill.Create("skill://git-workflow", Frontmatter(), [McpServerSkillFile.FromText("SKILL.md", SkillMarkdown)])); - } - - [Fact] - public void Create_RejectsFrontmatterNameMismatch() - { - var exception = Assert.Throws(() => - McpServerSkill.Create(SkillUri, Frontmatter(name: "git-flow"), [McpServerSkillFile.FromText("SKILL.md", SkillMarkdown)])); - - Assert.Equal("frontmatter", exception.ParamName); - Assert.Contains("git-flow", exception.Message); - } - - [Fact] - public void Create_RejectsMissingDescription() - { - Assert.Throws(() => - McpServerSkill.Create(SkillUri, Frontmatter(description: null), [McpServerSkillFile.FromText("SKILL.md", SkillMarkdown)])); - } - - [Fact] - public void Create_ReadsFrontmatterFromSkillFile() - { - var skill = McpServerSkill.Create(SkillUri, [McpServerSkillFile.FromText("SKILL.md", SkillMarkdown)]); - - Assert.True(JsonNode.DeepEquals(Frontmatter(), skill.ProtocolSkill.Frontmatter)); - } - - [Fact] - public void Create_DerivesUriFromFrontmatterName() - { - var skill = McpServerSkill.Create([McpServerSkillFile.FromText("SKILL.md", SkillMarkdown), McpServerSkillFile.FromText("a.md", "x")]); - - Assert.Equal(SkillUri, skill.ProtocolSkill.Uri); - Assert.Equal("skill://git-workflow/a.md", skill.ProtocolSkill.Resources.Resources![1].Uri); - } - - [Fact] - public void Create_RejectsUriWhoseNameDiffersFromTheFile() - { - var exception = Assert.Throws(() => - McpServerSkill.Create("skill://other-name/SKILL.md", [McpServerSkillFile.FromText("SKILL.md", SkillMarkdown)])); - - Assert.Contains("git-workflow", exception.Message); - } - - [Fact] - public void Create_WithUnreadableFrontmatter_ExplainsAndPointsAtTheExplicitOverload() - { - var exception = Assert.Throws(() => - McpServerSkill.Create([McpServerSkillFile.FromText("SKILL.md", "---\nname: &a git-workflow\ndescription: d\n---\n")])); - - Assert.Contains("anchors", exception.Message); - Assert.Contains("JsonObject", exception.Message); - - var noFrontmatter = Assert.Throws(() => - McpServerSkill.Create([McpServerSkillFile.FromText("SKILL.md", "# No frontmatter\n")])); - Assert.Contains("must begin", noFrontmatter.Message); - } - - [Fact] - public void Create_WithExplicitFrontmatter_RejectsMismatchWithTheFile() - { - var mismatched = Frontmatter(); - mismatched["license"] = "MIT"; - - var exception = Assert.Throws(() => - McpServerSkill.Create(SkillUri, mismatched, [McpServerSkillFile.FromText("SKILL.md", SkillMarkdown)])); - - Assert.Equal("frontmatter", exception.ParamName); - Assert.Contains("does not match", exception.Message); - Assert.Contains("\"license\"", exception.Message); - } - - [Fact] - public void Create_WithExplicitFrontmatter_AcceptsMatchAndUnreadableFile() - { - // Matches the file: fine. - McpServerSkill.Create(SkillUri, Frontmatter(), [McpServerSkillFile.FromText("SKILL.md", SkillMarkdown)]); - - // Values are compared as typed JSON: a quoted "2" in the file is the string "2" in the object. - McpServerSkill.Create(SkillUri, new JsonObject { ["name"] = "git-workflow", ["description"] = "d", ["metadata"] = new JsonObject { ["major"] = "2" } }, - [McpServerSkillFile.FromText("SKILL.md", "---\nname: git-workflow\ndescription: d\nmetadata:\n major: \"2\"\n---\n")]); - - // The caller supplies the complete, prevalidated frontmatter for this unsupported YAML form. - var escapeHatch = McpServerSkill.Create(SkillUri, Frontmatter(), - [McpServerSkillFile.FromText("SKILL.md", "---\nname: &n git-workflow\ndescription: Git conventions\n---\n")]); - Assert.Equal("git-workflow", escapeHatch.ProtocolSkill.Name); - - // The caller has also validated this complete multi-line quoted value. - var multiLine = McpServerSkill.Create(SkillUri, Frontmatter(), - [McpServerSkillFile.FromText("SKILL.md", "---\nname: git-workflow\ndescription: \"Git\n conventions\"\n---\n")]); - Assert.Equal("Git conventions", multiLine.ProtocolSkill.Description); - - // So is a flow collection that closes on a later line. - var explicitTags = Frontmatter(); - explicitTags["tags"] = new JsonArray("Read", "Write"); - var multiLineFlow = McpServerSkill.Create(SkillUri, explicitTags, - [McpServerSkillFile.FromText("SKILL.md", "---\nname: git-workflow\ndescription: Git conventions\ntags: [Read,\n Write]\n---\n")]); - Assert.Equal(2, multiLineFlow.ProtocolSkill.Frontmatter["tags"]!.AsArray().Count); - } - - [Theory] - [InlineData("allowed-tools", "\n - Read\n - Write")] - [InlineData("allowed-tools", " [Read, Write]")] - [InlineData("allowed-tools", " 1")] - [InlineData("allowed-tools", "\n Read: true")] - [InlineData("allowed-tools", " true")] - [InlineData("allowed-tools", "")] - [InlineData("license", " [MIT]")] - [InlineData("license", " 2")] - [InlineData("license", "\n name: MIT")] - [InlineData("license", " false")] - [InlineData("license", "")] - public void Create_RejectsSpecFieldsThatAreNotStrings(string field, string yamlValue) - { - var exception = Assert.Throws(() => McpServerSkill.Create( - [McpServerSkillFile.FromText("SKILL.md", "---\nname: git-workflow\ndescription: Git conventions\n" + field + ":" + yamlValue + "\n---\n")])); - - Assert.Contains($"'{field}' frontmatter field that is not a string", exception.Message); - } - - [Theory] - [InlineData("# no frontmatter block\n", "must begin")] - [InlineData("---\nname: git-workflow\n", "not closed")] - [InlineData("---\nname: a: b\n---\n", "cannot contain")] - [InlineData("---\n\tname: x\n---\n", "tabs")] - [InlineData("---\nname: git-workflow\ndescription: \"never closed\n---\n", "unterminated")] - [InlineData("---\nname: git-workflow\ndescription: d\nallowed-tools: [Read, Write\n---\n", "unterminated flow")] - [InlineData("---\nname: git-workflow\ndescription: d\n extra # comment\n more\n---\n", "a comment ended that value")] - public void Create_WithExplicitFrontmatter_StillRejectsMalformedSkillFile(string markdown, string messageFragment) - { - // These errors are detected by the bounded reader; explicit metadata does not bypass them. - var exception = Assert.Throws(() => - McpServerSkill.Create(SkillUri, Frontmatter(), [McpServerSkillFile.FromText("SKILL.md", markdown)])); - - Assert.Contains(messageFragment, exception.Message, StringComparison.OrdinalIgnoreCase); - } - - [Fact] - public void Create_WithExplicitFrontmatter_RejectsNonUtf8SkillFile() - { - var exception = Assert.Throws(() => McpServerSkill.Create(SkillUri, Frontmatter(), - [new McpServerSkillFile { Path = "SKILL.md", Content = new byte[] { 0x2D, 0x2D, 0x2D, 0x0A, 0xFF, 0xFE } }])); - - Assert.Contains("UTF-8", exception.Message); - } - - [Fact] - public void Create_RejectsNullArguments() - { - Assert.Throws(() => McpServerSkill.Create(null!, Frontmatter(), [])); - Assert.Throws(() => McpServerSkill.Create(SkillUri, null!, [])); - Assert.Throws(() => McpServerSkill.Create(SkillUri, Frontmatter(), null!)); - Assert.Throws(() => McpServerSkill.Create((IEnumerable)null!)); - Assert.Throws(() => McpServerSkill.Create((string)null!, [])); - Assert.Throws(() => McpServerSkill.CreateFromDirectory((string)null!)); - Assert.Throws(() => McpServerSkill.CreateFromDirectory(SkillUri, (string)null!)); - } - - [Fact] - public void CreateFromDirectory_LoadsFilesRecursively() - { - string directory = Path.Combine(Path.GetTempPath(), "mcp-skill-" + Guid.NewGuid().ToString("N")); - try - { - Directory.CreateDirectory(Path.Combine(directory, "templates", "regional")); - File.WriteAllText(Path.Combine(directory, "SKILL.md"), SkillMarkdown); - File.WriteAllText(Path.Combine(directory, "templates", "invoice.md"), "# Invoice\n"); - File.WriteAllBytes(Path.Combine(directory, "templates", "regional", "logo.png"), [0x89, 0x50, 0x4E, 0x47, 0xFF, 0xFE]); - - // All three overloads agree. - var skill = McpServerSkill.CreateFromDirectory(directory); - Assert.Equal(SkillUri, skill.ProtocolSkill.Uri); - Assert.Equal(SkillUri, McpServerSkill.CreateFromDirectory(SkillUri, directory).ProtocolSkill.Uri); - Assert.Equal(SkillUri, McpServerSkill.CreateFromDirectory(SkillUri, Frontmatter(), directory).ProtocolSkill.Uri); - - var uris = skill.ProtocolSkill.Resources.Resources!.Select(r => r.Uri).ToList(); - Assert.Equal( - [ - "skill://git-workflow/SKILL.md", - "skill://git-workflow/templates/invoice.md", - "skill://git-workflow/templates/regional/logo.png", - ], uris); - - Assert.Equal("image/png", skill.Resources[2].ProtocolResource!.MimeType); - Assert.Equal(6, skill.ProtocolSkill.Resources.Resources![2].Size); - } - finally - { - Directory.Delete(directory, recursive: true); - } - } - - [Fact] - public void Create_EscapesUriSyntaxCharactersInFilePaths() - { - var skill = McpServerSkill.Create(SkillUri, Frontmatter(), - [ - McpServerSkillFile.FromText("SKILL.md", SkillMarkdown), - McpServerSkillFile.FromText("templates/{name} v2.md", "template"), - McpServerSkillFile.FromText("notes/a#b?c.md", "note"), - ]); - - var manifest = skill.ProtocolSkill.Resources.Resources!; - Assert.Equal("skill://git-workflow/templates/%7Bname%7D%20v2.md", manifest[2].Uri); - Assert.Equal("skill://git-workflow/notes/a%23b%3Fc.md", manifest[1].Uri); - - // Every file is a concrete resource, never a template, and its resource URI equals its manifest URI. - for (int i = 0; i < manifest.Count; i++) - { - Assert.False(skill.Resources[i].IsTemplated); - Assert.Equal(manifest[i].Uri, skill.Resources[i].ProtocolResource!.Uri); - } - - Assert.Equal("templates/{name} v2.md", skill.Resources[2].ProtocolResource!.Name); - } - - [Fact] - public void Create_KeepsItsOwnCopyOfTheEntry() - { - var frontmatter = Frontmatter(); - var skill = McpServerSkill.Create(SkillUri, frontmatter, [McpServerSkillFile.FromText("SKILL.md", SkillMarkdown)]); - var manifestEntry = skill.ProtocolSkill.Resources.Resources![0]; - - // The caller goes on to edit the frontmatter object it passed in, and an entry it was handed back. - frontmatter["description"] = "changed"; - var returned = skill.ProtocolSkill; - returned.Frontmatter["name"] = "changed"; - returned.Resources.Resources![0].Digest = "sha256:" + new string('0', 64); - returned.Resources = SkillResources.Dynamic; - - // None of that reaches the skill, so what WithSkills registers is what Create computed. (That the served - // bytes are likewise a copy of the caller's buffer is checked end to end in McpServerSkillsIntegrityTests.) - var fresh = skill.ProtocolSkill; - Assert.Equal("git-workflow", fresh.Name); - Assert.Equal("Git conventions", fresh.Description); - Assert.Equal(manifestEntry.Digest, fresh.Resources.Resources![0].Digest); - Assert.NotSame(returned, fresh); - } - - [Fact] - public void Create_RequiresAStringNameToDeriveTheUri() - { - var exception = Assert.Throws(() => - McpServerSkill.Create([McpServerSkillFile.FromText("SKILL.md", "---\nname: 123\ndescription: d\n---\n")])); - - Assert.Contains("must declare a string 'name'", exception.Message); - } - - [Fact] - public void Create_RejectsNullFileEntries() - { - var exception = Assert.Throws(() => - McpServerSkill.Create(SkillUri, [McpServerSkillFile.FromText("SKILL.md", SkillMarkdown), null!])); - - Assert.Contains("null entries", exception.Message); - Assert.Equal("files", exception.ParamName); - } - -#if NET - [Fact] - public void CreateFromDirectory_RejectsSymbolicLinks() - { - string root = Path.Combine(Path.GetTempPath(), "mcp-skill-link-" + Guid.NewGuid().ToString("N")); - try - { - string skillDirectory = Path.Combine(root, "skill"); - Directory.CreateDirectory(skillDirectory); - File.WriteAllText(Path.Combine(skillDirectory, "SKILL.md"), SkillMarkdown); - File.WriteAllText(Path.Combine(root, "outside.txt"), "private data"); - - try - { - File.CreateSymbolicLink(Path.Combine(skillDirectory, "linked.txt"), Path.Combine(root, "outside.txt")); - } - catch (Exception e) when (e is UnauthorizedAccessException or IOException) - { - Assert.Skip($"Cannot create symbolic links here: {e.Message}"); - } - - var exception = Assert.Throws(() => McpServerSkill.CreateFromDirectory(SkillUri, Frontmatter(), skillDirectory)); - Assert.Contains("linked.txt", exception.Message); - Assert.Equal("directoryPath", exception.ParamName); - } - finally - { - Directory.Delete(root, recursive: true); - } - } - - [Fact] - public void CreateFromDirectory_RejectsASymbolicLinkAsTheRoot() - { - string root = Path.Combine(Path.GetTempPath(), "mcp-skill-rootlink-" + Guid.NewGuid().ToString("N")); - try - { - string target = Path.Combine(root, "target"); - Directory.CreateDirectory(target); - File.WriteAllText(Path.Combine(target, "SKILL.md"), SkillMarkdown); - - string link = Path.Combine(root, "link"); - try - { - Directory.CreateSymbolicLink(link, target); - } - catch (Exception e) when (e is UnauthorizedAccessException or IOException) - { - Assert.Skip($"Cannot create symbolic links here: {e.Message}"); - } - - var exception = Assert.Throws(() => McpServerSkill.CreateFromDirectory(link)); - Assert.Equal("directoryPath", exception.ParamName); - - // The target itself is fine. - Assert.Equal(SkillUri, McpServerSkill.CreateFromDirectory(target).ProtocolSkill.Uri); - } - finally - { - Directory.Delete(root, recursive: true); - } - } - - [Fact] - public void CreateFromDirectory_RejectsDirectorySymbolicLinks() - { - string root = Path.Combine(Path.GetTempPath(), "mcp-skill-dirlink-" + Guid.NewGuid().ToString("N")); - try - { - string skillDirectory = Path.Combine(root, "skill"); - Directory.CreateDirectory(skillDirectory); - Directory.CreateDirectory(Path.Combine(root, "outside")); - File.WriteAllText(Path.Combine(skillDirectory, "SKILL.md"), SkillMarkdown); - File.WriteAllText(Path.Combine(root, "outside", "secret.txt"), "private data"); - - try - { - Directory.CreateSymbolicLink(Path.Combine(skillDirectory, "linked"), Path.Combine(root, "outside")); - } - catch (Exception e) when (e is UnauthorizedAccessException or IOException) - { - Assert.Skip($"Cannot create symbolic links here: {e.Message}"); - } - - Assert.Throws(() => McpServerSkill.CreateFromDirectory(SkillUri, Frontmatter(), skillDirectory)); - } - finally - { - Directory.Delete(root, recursive: true); - } - } -#endif - - [Fact] - public void CreateFromDirectory_RejectsTooManyFilesBeforeReadingThem() - { - string directory = Path.Combine(Path.GetTempPath(), "mcp-skill-many-" + Guid.NewGuid().ToString("N")); - try - { - Directory.CreateDirectory(directory); - File.WriteAllText(Path.Combine(directory, "SKILL.md"), SkillMarkdown); - for (int i = 0; i < SkillsProtocol.MaxResourcesPerSkill; i++) - { - File.WriteAllText(Path.Combine(directory, $"f{i:D4}.txt"), "x"); - } - - var exception = Assert.Throws(() => McpServerSkill.CreateFromDirectory(SkillUri, directory)); - Assert.Contains(SkillsProtocol.MaxResourcesPerSkill.ToString(), exception.Message); - } - finally - { - Directory.Delete(directory, recursive: true); - } - } - - [Fact] - public void CreateFromDirectory_RejectsOversizedContentBeforeReadingIt() - { - string directory = Path.Combine(Path.GetTempPath(), "mcp-skill-big-" + Guid.NewGuid().ToString("N")); - try - { - Directory.CreateDirectory(directory); - File.WriteAllText(Path.Combine(directory, "SKILL.md"), SkillMarkdown); - using (var big = new FileStream(Path.Combine(directory, "big.bin"), FileMode.CreateNew)) - { - // A sparse file: the size check must trip without the bytes ever being read. - big.SetLength(SkillsProtocol.MaxTotalSizeBytes + 1); - } - - var exception = Assert.Throws(() => McpServerSkill.CreateFromDirectory(SkillUri, directory)); - Assert.Contains(SkillsProtocol.MaxTotalSizeBytes.ToString(), exception.Message); - } - finally - { - Directory.Delete(directory, recursive: true); - } - } - - [Fact] - public void CreateFromDirectory_WithMissingDirectory_Throws() - { - string directory = Path.Combine(Path.GetTempPath(), "mcp-skill-missing-" + Guid.NewGuid().ToString("N")); - - Assert.Throws(() => McpServerSkill.CreateFromDirectory(SkillUri, Frontmatter(), directory)); - } -} diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerSkillsCatalogTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerSkillsCatalogTests.cs deleted file mode 100644 index 92dea3a77..000000000 --- a/tests/ModelContextProtocol.Tests/Server/McpServerSkillsCatalogTests.cs +++ /dev/null @@ -1,321 +0,0 @@ -using Microsoft.Extensions.DependencyInjection; -using ModelContextProtocol.Client; -using ModelContextProtocol.Extensions.Skills; -using ModelContextProtocol.Protocol; -using ModelContextProtocol.Server; -using System.Text; -using System.Text.Json; -using System.Text.Json.Nodes; - -namespace ModelContextProtocol.Tests.Server; - -/// -/// End-to-end tests for skills served through a custom : pagination, skills that -/// are served but not listed, dynamic skills, the error contract, and the protocol-version gating of -/// resultType, ttlMs, and cacheScope (issue #1721). -/// -public class McpServerSkillsCatalogTests : ClientServerTestBase -{ - private const string TamperedUri = "skill://tampered/SKILL.md"; - private const string UnlistedUri = "skill://hidden/SKILL.md"; - private const string DynamicUri = "skill://generated/SKILL.md"; - private const string BrokenUri = "skill://broken/SKILL.md"; - private const string SwappedUri = "skill://swapped/SKILL.md"; - - public McpServerSkillsCatalogTests(ITestOutputHelper testOutputHelper) - : base(testOutputHelper) - { - } - - protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder) - { - var listed = new[] { "alpha", "beta", "gamma" }.Select(name => CreateEntry($"skill://{name}/SKILL.md", name)).ToList(); - - // The manifest declares one content, the resource serves another. - var tampered = CreateEntry(TamperedUri, "tampered", Encoding.UTF8.GetBytes("the content the host approved")); - - // Served and answerable through skills/get, but withheld from skills/list. - var unlisted = CreateEntry(UnlistedUri, "hidden"); - - var dynamic = new Skill - { - Uri = DynamicUri, - Frontmatter = new JsonObject { ["name"] = "generated", ["description"] = "Assembled from live data" }, - Resources = SkillResources.Dynamic, - }; - - // A catalog bug: an entry whose manifest omits its own SKILL.md. The handler must not publish it. - var broken = new Skill - { - Uri = BrokenUri, - Frontmatter = new JsonObject { ["name"] = "broken", ["description"] = "d" }, - Resources = SkillResources.FromResources([new SkillResource { Uri = "skill://broken/other.md", Digest = "sha256:" + new string('a', 64), Size = 1 }]), - }; - - var catalog = new PartialCatalog( - listed: new InMemoryMcpSkillCatalog([.. listed, tampered, dynamic], pageSize: 2), - unlisted: unlisted, - broken: broken); - - mcpServerBuilder - .WithSkills(catalog, options => - { - options.TimeToLive = TimeSpan.FromMinutes(5); - options.CacheScope = CacheScope.Public; - }) - .WithResources([McpServerResource.Create(() => "the content the server actually serves", new() { UriTemplate = TamperedUri })]); - } - - private Skill CreateEntry(string uri, string name, byte[]? content = null) - { - content ??= Encoding.UTF8.GetBytes($"---\nname: {name}\n---\n"); - return new Skill - { - Uri = uri, - Frontmatter = new JsonObject { ["name"] = name, ["description"] = $"The {name} skill" }, - Resources = SkillResources.FromResources([new SkillResource { Uri = uri, Digest = SkillVerifier.ComputeDigest(content), Size = content.Length }]), - }; - } - - [Fact] - public async Task ListSkillsAsync_FollowsPaginationToTheEnd() - { - await using McpClient client = await CreateMcpClientForServer(); - - var skills = await client.ListSkillsAsync(TestContext.Current.CancellationToken); - - Assert.Equal(5, skills.Count); - Assert.Equal(skills.Select(s => s.Uri).OrderBy(u => u, StringComparer.Ordinal), skills.Select(s => s.Uri)); - Assert.DoesNotContain(skills, s => s.Uri == UnlistedUri); - } - - [Fact] - public async Task ListSkillsAsync_PerPage_ExposesCursorAndCacheHints() - { - await using McpClient client = await CreateMcpClientForServer(); - - var first = await client.ListSkillsAsync(new ListSkillsRequestParams(), TestContext.Current.CancellationToken); - Assert.Equal(2, first.Skills.Count); - Assert.NotNull(first.NextCursor); - Assert.Equal(TimeSpan.FromMinutes(5), first.TimeToLive); - Assert.Equal(CacheScope.Public, first.CacheScope); - - var second = await client.ListSkillsAsync(new ListSkillsRequestParams { Cursor = first.NextCursor }, TestContext.Current.CancellationToken); - Assert.Equal(2, second.Skills.Count); - Assert.NotEqual(first.Skills[0].Uri, second.Skills[0].Uri); - } - - [Fact] - public async Task GetSkillAsync_AnswersForSkillAbsentFromListing() - { - await using McpClient client = await CreateMcpClientForServer(); - - var skill = await client.GetSkillAsync(UnlistedUri, TestContext.Current.CancellationToken); - - Assert.Equal("hidden", skill.Name); - } - - [Fact] - public async Task SkillsGet_WithCatalogAnsweringForADifferentUri_ReturnsInternalError() - { - await using McpClient client = await CreateMcpClientForServer(); - - var exception = await Assert.ThrowsAsync( - async () => await client.GetSkillAsync(SwappedUri, TestContext.Current.CancellationToken)); - - Assert.Equal(McpErrorCode.InternalError, exception.ErrorCode); - Assert.Contains(SwappedUri, exception.Message); - } - - [Fact] - public async Task SkillsGet_WithInvalidCatalogEntry_ReturnsInternalError() - { - await using McpClient client = await CreateMcpClientForServer(); - - var exception = await Assert.ThrowsAsync( - async () => await client.GetSkillAsync(BrokenUri, TestContext.Current.CancellationToken)); - - Assert.Equal(McpErrorCode.InternalError, exception.ErrorCode); - Assert.Contains("invalid entry", exception.Message); - } - - [Fact] - public async Task SkillsList_WithInvalidCustomCatalogEntry_ReturnsInternalError() - { - await using var client = await CreateMcpClientForServer(); - - var error = await Assert.ThrowsAsync( - async () => await client.ListSkillsAsync( - new ListSkillsRequestParams { Cursor = "broken-entry" }, TestContext.Current.CancellationToken)); - - Assert.Equal(McpErrorCode.InternalError, error.ErrorCode); - Assert.Contains("invalid entry", error.Message); - } - - [Fact] - public async Task DynamicSkill_IsListedWithTheDynamicMarker() - { - await using McpClient client = await CreateMcpClientForServer(); - - var skills = await client.ListSkillsAsync(TestContext.Current.CancellationToken); - var dynamic = Assert.Single(skills, s => s.Uri == DynamicUri); - - Assert.True(dynamic.Resources.IsDynamic); - await Assert.ThrowsAsync( - async () => await client.ReadSkillResourceAsync(dynamic, DynamicUri, TestContext.Current.CancellationToken)); - } - - [Fact] - public async Task ReadSkillResourceAsync_RejectsContentThatDoesNotMatchTheManifest() - { - await using McpClient client = await CreateMcpClientForServer(); - var skill = await client.GetSkillAsync(TamperedUri, TestContext.Current.CancellationToken); - - var exception = await Assert.ThrowsAsync( - async () => await client.ReadSkillResourceAsync(skill, TamperedUri, TestContext.Current.CancellationToken)); - - Assert.Contains(TamperedUri, exception.Message); - - // The unverified read itself still works through the base API; only the verified path refuses it. - var raw = await client.ReadResourceAsync(TamperedUri, cancellationToken: TestContext.Current.CancellationToken); - Assert.Single(raw.Contents); - } - - [Theory] - [InlineData("""{ "uri": 42 }""")] - [InlineData("""{ }""")] - [InlineData("""{ "uri": "" }""")] - public async Task SkillsGet_WithInvalidParams_ReturnsInvalidParams(string paramsJson) - { - await using McpClient client = await CreateMcpClientForServer(); - - var exception = await Assert.ThrowsAsync(async () => await client.SendRequestAsync( - new JsonRpcRequest { Method = SkillsProtocol.MethodSkillsGet, Params = JsonNode.Parse(paramsJson) }, - TestContext.Current.CancellationToken)); - - Assert.Equal(McpErrorCode.InvalidParams, exception.ErrorCode); - } - - [Theory] - [InlineData("""{ "cursor": "not-base64!!" }""")] - [InlineData("""{ "cursor": 42 }""")] - public async Task SkillsList_WithInvalidCursor_ReturnsInvalidParams(string paramsJson) - { - await using McpClient client = await CreateMcpClientForServer(); - - var exception = await Assert.ThrowsAsync(async () => await client.SendRequestAsync( - new JsonRpcRequest { Method = SkillsProtocol.MethodSkillsList, Params = JsonNode.Parse(paramsJson) }, - TestContext.Current.CancellationToken)); - - Assert.Equal(McpErrorCode.InvalidParams, exception.ErrorCode); - } - - [Fact] - public async Task SkillsList_WithoutParams_Succeeds() - { - await using McpClient client = await CreateMcpClientForServer(); - - var response = await client.SendRequestAsync( - new JsonRpcRequest { Method = SkillsProtocol.MethodSkillsList }, - TestContext.Current.CancellationToken); - - Assert.Equal(2, response.Result!["skills"]!.AsArray().Count); - } - - [Fact] - public async Task SkillsMethods_On2026_07_28Session_IncludeResultTypeAndCacheHints() - { - await using McpClient client = await CreateMcpClientForServer( - new McpClientOptions { ProtocolVersion = McpProtocolVersions.July2026ProtocolVersion }); - Assert.Equal(McpProtocolVersions.July2026ProtocolVersion, client.NegotiatedProtocolVersion); - - var list = (await client.SendRequestAsync( - new JsonRpcRequest { Method = SkillsProtocol.MethodSkillsList }, - TestContext.Current.CancellationToken)).Result!.AsObject(); - Assert.Equal("complete", list["resultType"]?.GetValue()); - Assert.Equal(300_000, list["ttlMs"]?.GetValue()); - Assert.Equal("public", list["cacheScope"]?.GetValue()); - - var get = (await client.SendRequestAsync( - new JsonRpcRequest { Method = SkillsProtocol.MethodSkillsGet, Params = new JsonObject { ["uri"] = UnlistedUri } }, - TestContext.Current.CancellationToken)).Result!.AsObject(); - Assert.Equal("complete", get["resultType"]?.GetValue()); - Assert.False(get.ContainsKey("nextCursor")); - Assert.Equal(300_000, get["ttlMs"]?.GetValue()); - Assert.Equal("public", get["cacheScope"]?.GetValue()); - - var typed = await client.GetSkillAsync(new GetSkillRequestParams { Uri = UnlistedUri }, TestContext.Current.CancellationToken); - var cacheable = Assert.IsAssignableFrom(typed); - Assert.Equal(TimeSpan.FromMinutes(5), cacheable.TimeToLive); - Assert.Equal(CacheScope.Public, cacheable.CacheScope); - } - - [Fact] - public async Task SkillsMethods_On2025_11_25Session_OmitResultTypeAndCacheHints() - { - await using McpClient client = await CreateMcpClientForServer( - new McpClientOptions { ProtocolVersion = McpProtocolVersions.November2025ProtocolVersion }); - Assert.Equal(McpProtocolVersions.November2025ProtocolVersion, client.NegotiatedProtocolVersion); - - var list = (await client.SendRequestAsync( - new JsonRpcRequest { Method = SkillsProtocol.MethodSkillsList }, - TestContext.Current.CancellationToken)).Result!.AsObject(); - Assert.False(list.ContainsKey("resultType"), "resultType must be absent on a 2025-11-25 skills/list result."); - Assert.False(list.ContainsKey("ttlMs"), "ttlMs must be absent on a 2025-11-25 skills/list result."); - Assert.False(list.ContainsKey("cacheScope"), "cacheScope must be absent on a 2025-11-25 skills/list result."); - Assert.Equal(2, list["skills"]!.AsArray().Count); - - var get = (await client.SendRequestAsync( - new JsonRpcRequest { Method = SkillsProtocol.MethodSkillsGet, Params = new JsonObject { ["uri"] = UnlistedUri } }, - TestContext.Current.CancellationToken)).Result!.AsObject(); - Assert.False(get.ContainsKey("resultType"), "resultType must be absent on a 2025-11-25 skills/get result."); - Assert.False(get.ContainsKey("ttlMs"), "ttlMs must be absent on a 2025-11-25 skills/get result."); - Assert.False(get.ContainsKey("cacheScope"), "cacheScope must be absent on a 2025-11-25 skills/get result."); - Assert.Equal(UnlistedUri, get["skill"]!["uri"]!.GetValue()); - } - - /// - /// A catalog that lists some skills and serves one more by URI only. - /// - private sealed class PartialCatalog(InMemoryMcpSkillCatalog listed, Skill unlisted, Skill broken) : IMcpSkillCatalog - { - public ValueTask ListAsync(string? cursor, McpSkillRequestContext context, CancellationToken cancellationToken) - { - AssertContext(context, SkillsProtocol.MethodSkillsList); - if (string.Equals(cursor, "broken-entry", StringComparison.Ordinal)) - { - return new ValueTask(new McpSkillPage { Skills = [broken] }); - } - return listed.ListAsync(cursor, context, cancellationToken); - } - - public async ValueTask GetAsync(string uri, McpSkillRequestContext context, CancellationToken cancellationToken) - { - AssertContext(context, SkillsProtocol.MethodSkillsGet); - if (string.Equals(uri, unlisted.Uri, StringComparison.Ordinal)) - { - return unlisted; - } - - if (string.Equals(uri, broken.Uri, StringComparison.Ordinal)) - { - return broken; - } - - if (string.Equals(uri, SwappedUri, StringComparison.Ordinal)) - { - // A catalog bug: a valid entry, for the wrong skill. - return await listed.GetAsync("skill://alpha/SKILL.md", context, cancellationToken); - } - - return await listed.GetAsync(uri, context, cancellationToken); - } - - // The catalog receives the request it is answering, so a per-caller catalog can decide from it. - private void AssertContext(McpSkillRequestContext context, string method) - { - Assert.Equal(method, context.JsonRpcRequest.Method); - Assert.Null(context.User); - } - } -} diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerSkillsCompositionTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerSkillsCompositionTests.cs new file mode 100644 index 000000000..2293f2e90 --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Server/McpServerSkillsCompositionTests.cs @@ -0,0 +1,431 @@ +using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol.Extensions.Skills; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using ModelContextProtocol.Tests.Utils; +using System.Security.Claims; +using System.Text.Json.Nodes; + +namespace ModelContextProtocol.Tests.Server; + +public class McpServerSkillsCompositionTests(ITestOutputHelper output) : ClientServerTestBase(output, startServer: false) +{ + private readonly McpServerSkill _registered = CreateSkill("alpha"); + + private static McpServerSkill CreateSkill(string name) => + McpServerSkillPackage.Create([McpServerSkillFile.FromText("SKILL.md", $"---\nname: {name}\ndescription: {name}\n---\n")]).Skill; + + [Fact] + public async Task RegisteredEntriesAreListedInInsertionOrder() + { + var skills = new[] { CreateSkill("gamma"), CreateSkill("alpha"), CreateSkill("beta") }; + McpServerBuilder.WithSkills(skills); + StartServer(); + await using var client = await CreateMcpClientForServer(); + + var page = await client.ListSkillsAsync(new ListSkillsRequestParams(), TestContext.Current.CancellationToken); + + Assert.Equal(skills.Select(skill => skill.Id), page.Skills.Select(skill => skill.Uri)); + Assert.Null(page.NextCursor); + } + + [Theory] + [InlineData("")] + [InlineData("not-base64!!")] + public async Task InitialCustomPageIsAugmented_AndContinuationPagesDoNotRepeatRegisteredEntries(string cursor) + { + var application = CreateSkill("application").ProtocolSkill!; + var page = new ListSkillsResult { Skills = [application], NextCursor = cursor }; + McpServerBuilder.WithSkills([_registered]).WithSkills(options => + options.ListSkillsHandler = (context, _) => new(context.Params.Cursor is null ? page : new ListSkillsResult())); + StartServer(); + await using var client = await CreateMcpClientForServer(); + + var first = await client.ListSkillsAsync(new ListSkillsRequestParams(), cancellationToken: TestContext.Current.CancellationToken); + Assert.Equal([application.Uri, _registered.Id], first.Skills.Select(skill => skill.Uri)); + Assert.Equal(cursor, first.NextCursor); + var next = await client.ListSkillsAsync(new ListSkillsRequestParams { Cursor = cursor }, cancellationToken: TestContext.Current.CancellationToken); + Assert.Empty(next.Skills); + Assert.Null(next.NextCursor); + Assert.Equal(2, page.Skills.Count); + } + + [Fact] + public async Task ComposedPageCanDescribeDifferentSnapshotsOfSharedFiles() + { + var inner = McpServerSkillPackage.Create( + [McpServerSkillFile.FromText("SKILL.md", "---\nname: inner\ndescription: Inner\n---\n")], + new McpServerSkillPackageCreateOptions + { + Uri = "skill://catalog/inner/SKILL.md", + }).Skill; + var outer = McpServerSkillPackage.Create( + [ + McpServerSkillFile.FromText("SKILL.md", "---\nname: catalog\ndescription: Catalog\n---\n"), + McpServerSkillFile.FromText("inner/SKILL.md", "---\nname: inner\ndescription: Changed\n---\n"), + ], new McpServerSkillPackageCreateOptions + { + Uri = "skill://catalog/SKILL.md", + }).Skill; + McpServerBuilder.WithSkills([inner]).WithSkills(options => + { + options.ListSkillsFilters.Add(next => async (context, cancellationToken) => + { + var page = await next(context, cancellationToken); + page.Skills.Add(outer.ProtocolSkill!); + return page; + }); + }); + + StartServer(); + await using var client = await CreateMcpClientForServer(); + var page = await client.ListSkillsAsync(cancellationToken: TestContext.Current.CancellationToken); + + var returnedOuter = Assert.Single(page, entry => entry.Uri == outer.Id); + var returnedInner = Assert.Single(page, entry => entry.Uri == inner.Id); + Assert.Equal("Catalog", returnedOuter.Description); + Assert.Equal("Inner", returnedInner.Description); + Assert.Equal(2, page.Count); + Assert.NotEqual(Assert.Single(returnedInner.Resources.Entries!).Digest, + Assert.Single(returnedOuter.Resources.Entries!, file => file.Uri == inner.Id).Digest); + } + + [Fact] + public async Task RegisteredGetWins_AndCustomFallbackServesUnlistedEntries() + { + var hidden = CreateSkill("hidden").ProtocolSkill!; + int calls = 0; + McpServerBuilder.WithSkills([_registered]).WithSkills(options => + { + options.GetSkillHandler = (context, _) => + { + calls++; + Assert.Null(context.MatchedPrimitive); + if (!new Uri(context.Params.Uri).Equals(new Uri(hidden.Uri))) + { + throw new McpProtocolException("Denied.", McpErrorCode.InvalidParams); + } + + return new(new GetSkillResult { Skill = hidden }); + }; + }); + + StartServer(); + await using var client = await CreateMcpClientForServer(); + Assert.Equal(_registered.Id, (await client.GetSkillAsync(_registered.Id, cancellationToken: TestContext.Current.CancellationToken)).Uri); + Assert.Equal(0, calls); + Assert.Single(await client.ListSkillsAsync(cancellationToken: TestContext.Current.CancellationToken)); + Assert.Equal(hidden.Uri, (await client.GetSkillAsync("SKILL://HIDDEN/SKILL.md", cancellationToken: TestContext.Current.CancellationToken)).Uri); + var error = await Assert.ThrowsAsync( + async () => await client.GetSkillAsync("skill://denied/SKILL.md", cancellationToken: TestContext.Current.CancellationToken)); + Assert.Equal(McpErrorCode.InvalidParams, error.ErrorCode); + Assert.Equal(2, calls); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task WrongFallbackUriIsRejectedEvenWhenHandlerMutatesParameters(bool mutate) + { + var other = CreateSkill("other"); + McpServerBuilder.WithSkills(options => options.GetSkillHandler = (context, _) => + { + if (mutate) + { + context.Params.Uri = other.Id; + } + + return new(new GetSkillResult { Skill = other.ProtocolSkill! }); + }); + + StartServer(); + await using var client = await CreateMcpClientForServer(); + var error = await Assert.ThrowsAsync( + async () => await client.GetSkillAsync(_registered.Id, cancellationToken: TestContext.Current.CancellationToken)); + Assert.Equal(McpErrorCode.InternalError, error.ErrorCode); + Assert.Equal("Request failed (remote): An error occurred.", error.Message); + Assert.Contains(MockLoggerProvider.LogMessages, log => + log.Exception is InvalidOperationException local && local.Message.Contains(_registered.Id)); + } + + [Theory] + [InlineData("skills/list", "null-result")] + [InlineData("skills/list", "null-entries")] + [InlineData("skills/list", "frontmatter")] + [InlineData("skills/list", "manifest")] + [InlineData("skills/get", "null-result")] + [InlineData("skills/get", "null-entries")] + [InlineData("skills/get", "frontmatter")] + [InlineData("skills/get", "manifest")] + public async Task InvalidCustomResultsAreInternalErrors(string method, string failure) + { + var entry = _registered.ProtocolSkill!; + if (failure == "frontmatter") entry.Frontmatter["description"] = ""; + if (failure == "manifest") entry.Resources.Entries![0].Digest = "sha256:invalid"; + McpServerBuilder.WithSkills(options => + { + options.ListSkillsHandler = (_, _) => new(failure == "null-result" ? null! : new ListSkillsResult + { + Skills = failure == "null-entries" ? null! : [entry], + }); + + options.GetSkillHandler = (_, _) => new(failure == "null-result" ? null! : new GetSkillResult + { + Skill = failure == "null-entries" ? null! : entry, + }); + }); + + StartServer(); + await using var client = await CreateMcpClientForServer(); + var error = await Assert.ThrowsAsync(() => client.SendRequestAsync(new JsonRpcRequest + { + Method = method, Params = new JsonObject { ["uri"] = _registered.Id }, + }, TestContext.Current.CancellationToken)); + Assert.Equal(McpErrorCode.InternalError, error.ErrorCode); + Assert.Equal("Request failed (remote): An error occurred.", error.Message); + if (failure is "frontmatter" or "manifest") + { + Assert.Contains(MockLoggerProvider.LogMessages, log => + log.Exception is ArgumentException local && local.Message.Contains(entry.Uri)); + } + } + + [Fact] + public async Task RegisteredGetUsesTheActualContext_WithoutInvokingGetForListing() + { + var entry = _registered.ProtocolSkill!; + entry.Frontmatter["description"] = "get"; + var user = new ClaimsPrincipal(new ClaimsIdentity([new Claim(ClaimTypes.Name, "reader")], "test")); + var item = new object(); + RequestContext? filtered = null; + CancellationToken filteredToken = default; + RequestService? dependency = null; + ServiceCollection.AddScoped(); + var primitive = new RequestSkill(entry.Uri, null, async (context, cancellationToken) => + { + Assert.Same(filtered, context); + Assert.Equal(filteredToken, cancellationToken); + Assert.True(cancellationToken.CanBeCanceled); + Assert.Same(user, context.User); + Assert.Same(item, context.Items["request-item"]); + Assert.Same(dependency, context.Services!.GetRequiredService()); + Assert.NotSame(Server.Services, context.Services); + Assert.Equal(SkillsProtocol.MethodSkillsGet, context.JsonRpcRequest.Method); + Assert.Equal("SKILL://ALPHA/SKILL.md", context.Params.Uri); + Assert.Equal("get", context.Params.Meta!["example.com/request"]!.GetValue()); + await Task.Yield(); + cancellationToken.ThrowIfCancellationRequested(); + return new GetSkillResult { Skill = entry }; + }); + + McpServerBuilder.WithMessageFilters(filters => filters.AddIncomingFilter(next => (context, cancellationToken) => + { + context.User = user; + context.Items["request-item"] = item; + return next(context, cancellationToken); + })).WithSkills([primitive]).WithSkills(options => + { + options.GetSkillHandler = (_, _) => throw new InvalidOperationException("The fallback must not run."); + options.GetSkillFilters.Add(next => (context, cancellationToken) => + { + Assert.Same(primitive, context.MatchedPrimitive); + Assert.Equal("request-skill", Assert.Single(primitive.Metadata)); + filtered = context; + filteredToken = cancellationToken; + dependency = context.Services!.GetRequiredService(); + return next(context, cancellationToken); + }); + }); + + StartServer(); + Assert.Equal(0, primitive.GetCalls); + await using var client = await CreateMcpClientForServer(); + Assert.Empty(await client.ListSkillsAsync(cancellationToken: TestContext.Current.CancellationToken)); + Assert.Equal(0, primitive.GetCalls); + var result = await client.GetSkillAsync(new GetSkillRequestParams + { + Uri = "SKILL://ALPHA/SKILL.md", Meta = new JsonObject { ["example.com/request"] = "get" }, + }, TestContext.Current.CancellationToken); + Assert.Equal("get", result.Skill.Description); + Assert.Equal(1, primitive.GetCalls); + } + + [Fact] + public async Task RegisteredListingAndGetCanHaveDifferentEntries() + { + var listing = _registered.ProtocolSkill!; + var fetched = CreateSkill("alpha").ProtocolSkill!; + fetched.Frontmatter["description"] = "request-time entry"; + var primitive = new RequestSkill(listing.Uri, listing, (_, _) => new(new GetSkillResult { Skill = fetched })); + McpServerBuilder.WithSkills([primitive]); + StartServer(); + await using var client = await CreateMcpClientForServer(); + Assert.Equal("alpha", Assert.Single(await client.ListSkillsAsync(cancellationToken: TestContext.Current.CancellationToken)).Description); + Assert.Equal(0, primitive.GetCalls); + Assert.Equal("request-time entry", (await client.GetSkillAsync( + primitive.Id, cancellationToken: TestContext.Current.CancellationToken)).Description); + Assert.Equal(1, primitive.GetCalls); + } + + [Fact] + public async Task RegisteredGetCanSupplyEnvelopeHints() + { + var source = new GetSkillResult + { + Skill = _registered.ProtocolSkill!, + ResultType = "input", + Meta = new JsonObject { ["example.com/result"] = "get" }, + TimeToLive = TimeSpan.FromMinutes(2), + CacheScope = CacheScope.Public, + }; + + var primitive = new RequestSkill(_registered.Id, null, (_, _) => new(source)); + McpServerBuilder.WithSkills([primitive]); + + StartServer(); + var options = TestMcpClient.CreateOptions(); + options.ProtocolVersion = McpProtocolVersions.July2026ProtocolVersion; + await using var client = await CreateMcpClientForServer(options); + var result = (await client.SendRequestAsync(new JsonRpcRequest + { + Method = SkillsProtocol.MethodSkillsGet, Params = new JsonObject { ["uri"] = primitive.Id }, + }, TestContext.Current.CancellationToken)).Result!.AsObject(); + Assert.Equal("get", result["_meta"]!["example.com/result"]!.GetValue()); + Assert.Equal("complete", result["resultType"]!.GetValue()); + Assert.Equal(120_000, result["ttlMs"]!.GetValue()); + Assert.Equal("public", result["cacheScope"]!.GetValue()); + Assert.Equal("input", source.ResultType); + Assert.Equal(TimeSpan.FromMinutes(2), source.TimeToLive); + Assert.Equal(CacheScope.Public, source.CacheScope); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void StartupRejectsInvalidOrDifferentlyIdentifiedListingEntries_WithoutInvokingGet(bool differentUri) + { + var entry = differentUri ? CreateSkill("other").ProtocolSkill! : _registered.ProtocolSkill!; + if (!differentUri) entry.Frontmatter["description"] = ""; + var primitive = new RequestSkill(_registered.Id, entry, (_, _) => throw new InvalidOperationException("Get must not run.")); + McpServerBuilder.WithSkills([primitive]); + Assert.Throws(() => StartServer()); + Assert.Equal(0, primitive.GetCalls); + } + + [Theory] + [InlineData("skills/list", "frontmatter")] + [InlineData("skills/list", "uri")] + [InlineData("skills/get", "frontmatter")] + [InlineData("skills/get", "uri")] + [InlineData("skills/get", "null")] + public async Task RuntimeRegisteredResultsStillEnforceStructureAndIdentity(string method, string failure) + { + GetSkillResult? result = null; + var primitive = new RequestSkill(_registered.Id, _registered.ProtocolSkill, (_, _) => new(result!)); + int fallbacks = 0; + McpServerBuilder.WithSkills([primitive]).WithSkills(options => options.GetSkillHandler = (_, _) => + { + fallbacks++; + throw new InvalidOperationException("The fallback must not run."); + }); + + StartServer(); + var entry = failure == "uri" ? CreateSkill("other").ProtocolSkill! : _registered.ProtocolSkill!; + if (failure == "frontmatter") entry.Frontmatter["description"] = ""; + primitive.ListingEntry = entry; + if (failure != "null") result = new GetSkillResult { Skill = entry }; + await using var client = await CreateMcpClientForServer(); + var error = await Assert.ThrowsAsync(() => client.SendRequestAsync(new JsonRpcRequest + { + Method = method, Params = new JsonObject { ["uri"] = primitive.Id }, + }, TestContext.Current.CancellationToken)); + Assert.Equal(McpErrorCode.InternalError, error.ErrorCode); + Assert.Equal(method == SkillsProtocol.MethodSkillsGet ? 1 : 0, primitive.GetCalls); + Assert.Equal(0, fallbacks); + } + + private sealed class RequestService; + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ResourceOverridesUseFirstWins_AndVerifiedReadsStillRejectMismatchedContent(bool applicationFirst) + { + var package = McpServerSkillPackage.Create( + [McpServerSkillFile.FromText("SKILL.md", "---\nname: alpha\ndescription: alpha\n---\n")]); + var application = McpServerResource.Create(() => "application content", new() { UriTemplate = package.Skill.Id }); + if (applicationFirst) McpServerBuilder.WithResources([application]); + McpServerBuilder.WithSkillPackages([package]); + if (!applicationFirst) McpServerBuilder.WithResources([application]); + StartServer(); + await using var client = await CreateMcpClientForServer(); + var entry = await client.GetSkillAsync(package.Skill.Id, cancellationToken: TestContext.Current.CancellationToken); + if (applicationFirst) + { + await Assert.ThrowsAsync(async () => await client.ReadSkillResourceAsync( + entry, entry.Uri, cancellationToken: TestContext.Current.CancellationToken)); + } + else + { + Assert.Single((await client.ReadSkillResourceAsync(entry, entry.Uri, cancellationToken: TestContext.Current.CancellationToken)).Contents); + } + } + + private sealed class RequestSkill( + string uri, Skill? listingEntry, McpRequestHandler handler) + : McpServerSkill(uri) + { + public Skill? ListingEntry { get; set; } = listingEntry; + public int GetCalls { get; private set; } + public override IReadOnlyList Metadata => ["request-skill"]; + public override Skill? ProtocolSkill => ListingEntry; + + public override ValueTask GetAsync( + RequestContext request, CancellationToken cancellationToken = default) + { + GetCalls++; + return handler(request, cancellationToken); + } + } + + [Fact] + public async Task RuntimeEntriesAndFilesHaveSeparateLifetimes() + { + var package = McpServerSkillPackage.Create( + [McpServerSkillFile.FromText("SKILL.md", "---\nname: alpha\ndescription: alpha\n---\n")]); + McpSkillsOptions? options = null; + McpServerBuilder.WithSkills([_registered]).WithResources(package.Resources).WithSkills(configured => options = configured); + StartServer(); + await using var client = await CreateMcpClientForServer(); + var held = await client.GetSkillAsync(_registered.Id, cancellationToken: TestContext.Current.CancellationToken); + Assert.True(options!.SkillCollection.Remove(_registered)); + Assert.Empty(await client.ListSkillsAsync(cancellationToken: TestContext.Current.CancellationToken)); + Assert.Single((await client.ReadSkillResourceAsync(held, held.Uri, cancellationToken: TestContext.Current.CancellationToken)).Contents); + var added = McpServerSkillPackage.Create( + [McpServerSkillFile.FromText("SKILL.md", "---\nname: beta\ndescription: beta\n---\n")]); + foreach (var resource in added.Resources) Server.ServerOptions.ResourceCollection!.Add(resource); + options.SkillCollection.Add(added.Skill); + Assert.Equal(added.Skill.Id, (await client.GetSkillAsync(added.Skill.Id, cancellationToken: TestContext.Current.CancellationToken)).Uri); + Server.ServerOptions.ResourceCollection!.Clear(); + Assert.Equal(added.Skill.Id, Assert.Single(await client.ListSkillsAsync(cancellationToken: TestContext.Current.CancellationToken)).Uri); + await Assert.ThrowsAsync(async () => await client.ReadResourceAsync( + added.Skill.Id, cancellationToken: TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task CustomListHintsCoverTheCompleteAugmentedPage() + { + var page = new ListSkillsResult { TimeToLive = TimeSpan.FromMinutes(2), CacheScope = CacheScope.Public }; + McpServerBuilder.WithSkills([_registered]).WithSkills(options => options.ListSkillsHandler = (_, _) => new(page)); + + StartServer(); + var clientOptions = TestMcpClient.CreateOptions(); + clientOptions.ProtocolVersion = McpProtocolVersions.July2026ProtocolVersion; + await using var client = await CreateMcpClientForServer(clientOptions); + + var result = await client.ListSkillsAsync(new ListSkillsRequestParams(), TestContext.Current.CancellationToken); + Assert.Equal(_registered.Id, Assert.Single(result.Skills).Uri); + Assert.Equal(TimeSpan.FromMinutes(2), result.TimeToLive); + Assert.Equal(CacheScope.Public, result.CacheScope); + } +} diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerSkillsDefaultsTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerSkillsDefaultsTests.cs index 22211794c..c6a2ee26b 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpServerSkillsDefaultsTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpServerSkillsDefaultsTests.cs @@ -23,10 +23,8 @@ protected override void ConfigureServices(ServiceCollection services, IMcpServer { mcpServerBuilder.WithSkills( [ - McpServerSkill.Create( - "skill://alpha/SKILL.md", - new JsonObject { ["name"] = "alpha", ["description"] = "d" }, - [McpServerSkillFile.FromText("SKILL.md", "---\nname: alpha\ndescription: d\n---\n")]), + McpServerSkillPackage.Create( + [McpServerSkillFile.FromText("SKILL.md", "---\nname: alpha\ndescription: d\n---\n")], new McpServerSkillPackageCreateOptions { Uri = "skill://alpha/SKILL.md", Frontmatter = new JsonObject { ["name"] = "alpha", ["description"] = "d" } }).Skill, ]); } @@ -44,7 +42,7 @@ public async Task SkillsList_WithDefaultOptions_On2026_07_28_CarriesConservative Assert.Equal(0, result["ttlMs"]?.GetValue()); Assert.Equal("private", result["cacheScope"]?.GetValue()); - var typed = await client.ListSkillsAsync(new ListSkillsRequestParams(), TestContext.Current.CancellationToken); + var typed = await client.ListSkillsAsync(new ListSkillsRequestParams(), cancellationToken: TestContext.Current.CancellationToken); Assert.Equal(TimeSpan.Zero, typed.TimeToLive); Assert.Equal(CacheScope.Private, typed.CacheScope); } @@ -94,8 +92,8 @@ public async Task ClientExtensions_ThrowWhenServerDoesNotDeclareSkills() await using McpClient client = await CreateMcpClientForServer(); client.ServerCapabilities.Extensions!.Remove(SkillsProtocol.ExtensionId); - Assert.False(client.SupportsSkills()); - await Assert.ThrowsAsync(async () => await client.ListSkillsAsync(TestContext.Current.CancellationToken)); - await Assert.ThrowsAsync(async () => await client.GetSkillAsync("skill://alpha/SKILL.md", TestContext.Current.CancellationToken)); + Assert.False(client.ServerCapabilities.Extensions.ContainsKey(SkillsProtocol.ExtensionId)); + await Assert.ThrowsAsync(async () => await client.ListSkillsAsync(new ListSkillsRequestParams(), cancellationToken: TestContext.Current.CancellationToken)); + await Assert.ThrowsAsync(async () => await client.GetSkillAsync("skill://alpha/SKILL.md", cancellationToken: TestContext.Current.CancellationToken)); } } diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerSkillsFilterTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerSkillsFilterTests.cs new file mode 100644 index 000000000..516248be2 --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Server/McpServerSkillsFilterTests.cs @@ -0,0 +1,244 @@ +using ModelContextProtocol.Extensions.Skills; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using ModelContextProtocol.Tests.Utils; +using System.Text.Json.Nodes; + +namespace ModelContextProtocol.Tests.Server; + +public class McpServerSkillsFilterTests(ITestOutputHelper output) : ClientServerTestBase(output, startServer: false) +{ + private readonly McpServerSkill _registered = new MetadataSkill(CreateSkill("registered")); + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task FiltersAreBuiltOnce_AndWrapCompleteOperationsInOrder(bool get) + { + var calls = new List(); + int built = 0; + var application = CreateSkill("application").ProtocolSkill!; + McpServerBuilder.WithSkills([_registered]).WithSkills(options => + { + options.ListSkillsHandler = (_, _) => new(new ListSkillsResult { Skills = [application] }); + options.GetSkillHandler = (_, _) => throw new InvalidOperationException("Registered gets must not use the fallback."); + foreach (string name in new[] { "first", "second" }) + { + options.ListSkillsFilters.Add(Track(name)); + options.GetSkillFilters.Add(Track(name)); + } + }); + + Assert.Equal(0, built); + StartServer(); + await using var client = await CreateMcpClientForServer(); + if (get) + { + Assert.Equal(_registered.Id, (await client.GetSkillAsync( + _registered.Id, cancellationToken: TestContext.Current.CancellationToken)).Uri); + } + else + { + var skills = await client.ListSkillsAsync(cancellationToken: TestContext.Current.CancellationToken); + Assert.Equal([application.Uri, _registered.Id], skills.Select(skill => skill.Uri)); + } + + Assert.Equal(["first:before", "second:before", "second:after", "first:after"], calls); + Assert.Equal(4, built); + + McpRequestFilter Track(string name) => next => + { + built++; + return async (context, cancellationToken) => + { + calls.Add(name + ":before"); + var result = await next(context, cancellationToken); + calls.Add(name + ":after"); + return result; + }; + }; + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task GetFiltersReceiveMatchedMetadata_AndCanDenyBothDispatchPaths(bool registered) + { + McpServerBuilder.WithSkills(registered ? [_registered] : []).WithSkills(options => + { + options.GetSkillHandler = (_, _) => throw new InvalidOperationException("The fallback must not run."); + options.GetSkillFilters.Add(_ => (context, _) => + { + Assert.Equal(registered ? _registered : null, context.MatchedPrimitive); + if (registered) + { + Assert.Equal("reader", Assert.Single(_registered.Metadata)); + } + throw new McpProtocolException("Denied.", McpErrorCode.InvalidParams); + }); + }); + + StartServer(); + await using var client = await CreateMcpClientForServer(); + var error = await Assert.ThrowsAsync(async () => await client.GetSkillAsync( + "SKILL://REGISTERED/SKILL.md", cancellationToken: TestContext.Current.CancellationToken)); + Assert.Equal(McpErrorCode.InvalidParams, error.ErrorCode); + } + + [Theory] + [InlineData(false, "valid")] + [InlineData(true, "valid")] + [InlineData(false, "null")] + [InlineData(true, "null")] + [InlineData(false, "null-entries")] + [InlineData(true, "null-entries")] + [InlineData(false, "null-entry")] + [InlineData(false, "manifest")] + [InlineData(true, "manifest")] + public async Task ShortCircuitsReplaceTheCompleteOperation_ButStillValidateResults(bool get, string shape) + { + var entry = CreateSkill("registered").ProtocolSkill!; + if (shape == "manifest") entry.Resources.Entries![0].Digest = "invalid"; + McpServerBuilder.WithSkills([_registered]).WithSkills(options => + { + options.ListSkillsHandler = (_, _) => throw new InvalidOperationException("The handler must not run."); + options.GetSkillHandler = (_, _) => throw new InvalidOperationException("The handler must not run."); + options.ListSkillsFilters.Add(_ => (_, _) => new(shape == "null" ? null! : new ListSkillsResult + { + Skills = shape switch { "null-entries" => null!, "null-entry" => [null!], _ => [entry] }, + })); + + options.GetSkillFilters.Add(_ => (_, _) => new(shape == "null" ? null! : new GetSkillResult + { + Skill = shape == "null-entries" ? null! : entry, + })); + }); + + StartServer(); + await using var client = await CreateMcpClientForServer(); + if (shape == "valid") + { + Assert.NotNull((await Request()).Result); + } + else + { + var error = await Assert.ThrowsAsync(Request); + Assert.Equal(McpErrorCode.InternalError, error.ErrorCode); + } + + Task Request() => client.SendRequestAsync(new JsonRpcRequest + { + Method = get ? SkillsProtocol.MethodSkillsGet : SkillsProtocol.MethodSkillsList, + Params = new JsonObject { ["uri"] = _registered.Id }, + }, TestContext.Current.CancellationToken); + } + + [Fact] + public async Task ListFiltersSeeRegistrations_WithoutMutatingTheirCollection() + { + McpServerBuilder.WithSkills([_registered]).WithSkills(options => options.ListSkillsFilters.Add(next => async (context, cancellationToken) => + { + var page = await next(context, cancellationToken); + Assert.Equal(_registered.Id, Assert.Single(page.Skills).Uri); + page.Skills.Clear(); + return page; + })); + StartServer(); + await using var client = await CreateMcpClientForServer(); + Assert.Empty(await client.ListSkillsAsync(cancellationToken: TestContext.Current.CancellationToken)); + Assert.Equal(_registered.Id, (await client.GetSkillAsync( + _registered.Id, cancellationToken: TestContext.Current.CancellationToken)).Uri); + } + + [Fact] + public async Task GetFilterCannotRebindTheOriginalRequestByReplacingItsParameters() + { + var other = CreateSkill("other").ProtocolSkill!; + McpServerBuilder.WithSkills([_registered]).WithSkills(options => options.GetSkillFilters.Add(_ => (context, _) => + { + context.Params = new GetSkillRequestParams { Uri = other.Uri }; + return new(new GetSkillResult { Skill = other }); + })); + StartServer(); + await using var client = await CreateMcpClientForServer(); + var error = await Assert.ThrowsAsync(async () => await client.GetSkillAsync( + _registered.Id, cancellationToken: TestContext.Current.CancellationToken)); + Assert.Equal(McpErrorCode.InternalError, error.ErrorCode); + Assert.Equal("Request failed (remote): An error occurred.", error.Message); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task FilterHintsOverrideOperationDefaults(bool get) + { + Result? filterResult = null; + McpServerBuilder.WithSkills([_registered]).WithSkills(options => + { + options.ListSkillsFilters.Add(next => async (context, cancellationToken) => Hints(await next(context, cancellationToken))); + options.GetSkillFilters.Add(next => async (context, cancellationToken) => Hints(await next(context, cancellationToken))); + }); + + StartServer(); + var options = TestMcpClient.CreateOptions(); + options.ProtocolVersion = McpProtocolVersions.July2026ProtocolVersion; + await using var client = await CreateMcpClientForServer(options); + var result = (await client.SendRequestAsync(new JsonRpcRequest + { + Method = get ? SkillsProtocol.MethodSkillsGet : SkillsProtocol.MethodSkillsList, + Params = new JsonObject { ["uri"] = _registered.Id }, + }, TestContext.Current.CancellationToken)).Result!.AsObject(); + Assert.Equal("complete", result["resultType"]!.GetValue()); + Assert.Equal(60_000, result["ttlMs"]!.GetValue()); + Assert.Equal("public", result["cacheScope"]!.GetValue()); + + Assert.Equal("input", filterResult!.ResultType); + var hints = Assert.IsAssignableFrom(filterResult); + Assert.Equal(TimeSpan.FromMinutes(1), hints.TimeToLive); + Assert.Equal(CacheScope.Public, hints.CacheScope); + + T Hints(T result) where T : Result, ICacheableResult + { + result.ResultType = "input"; + result.TimeToLive = TimeSpan.FromMinutes(1); + result.CacheScope = CacheScope.Public; + filterResult = result; + return result; + } + } + + [Theory] + [InlineData("skills/list", """{"cursor":42}""")] + [InlineData("skills/get", """{"uri":42}""")] + [InlineData("skills/get", """{}""")] + public async Task InvalidParametersAreRejectedBeforeFilters(string method, string parameters) + { + McpServerBuilder.WithSkills(options => + { + options.ListSkillsFilters.Add(_ => (_, _) => throw new InvalidOperationException("Invalid input reached a filter.")); + options.GetSkillFilters.Add(_ => (_, _) => throw new InvalidOperationException("Invalid input reached a filter.")); + }); + + StartServer(); + await using var client = await CreateMcpClientForServer(); + var error = await Assert.ThrowsAsync(() => client.SendRequestAsync(new JsonRpcRequest + { + Method = method, Params = JsonNode.Parse(parameters), + }, TestContext.Current.CancellationToken)); + Assert.Equal(McpErrorCode.InvalidParams, error.ErrorCode); + Assert.Equal("Request failed (remote): Invalid Skills parameters.", error.Message); + Assert.Contains(MockLoggerProvider.LogMessages, log => + log.Exception is McpProtocolException { InnerException: System.Text.Json.JsonException }); + } + + private static McpServerSkill CreateSkill(string name) => + McpServerSkillPackage.Create([McpServerSkillFile.FromText("SKILL.md", $"---\nname: {name}\ndescription: {name}\n---\n")]).Skill; + + private sealed class MetadataSkill(McpServerSkill inner) : McpServerSkill(inner.Id) + { + public override Skill? ProtocolSkill => inner.ProtocolSkill; + public override IReadOnlyList Metadata => ["reader"]; + public override ValueTask GetAsync(RequestContext request, CancellationToken cancellationToken = default) => + inner.GetAsync(request, cancellationToken); + } +} diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerSkillsFromDirectoryTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerSkillsFromSubdirectoriesTests.cs similarity index 61% rename from tests/ModelContextProtocol.Tests/Server/McpServerSkillsFromDirectoryTests.cs rename to tests/ModelContextProtocol.Tests/Server/McpServerSkillsFromSubdirectoriesTests.cs index f141950a1..b5e1da553 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpServerSkillsFromDirectoryTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpServerSkillsFromSubdirectoriesTests.cs @@ -1,4 +1,5 @@ using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; using ModelContextProtocol.Client; using ModelContextProtocol.Extensions.Skills; using ModelContextProtocol.Protocol; @@ -7,15 +8,15 @@ namespace ModelContextProtocol.Tests.Server; /// -/// End-to-end tests for WithSkillsFromDirectory: a directory of skill folders becomes a served catalog with +/// End-to-end tests for WithSkillsFromSubdirectories: a directory of skill folders becomes a served catalog with /// frontmatter read from each SKILL.md, URIs derived from the frontmatter names under the given prefix, and /// non-skill subdirectories ignored. /// -public class McpServerSkillsFromDirectoryTests : ClientServerTestBase +public class McpServerSkillsFromSubdirectoriesTests : ClientServerTestBase { private readonly string _root = Path.Combine(Path.GetTempPath(), "mcp-skills-root-" + Guid.NewGuid().ToString("N")); - public McpServerSkillsFromDirectoryTests(ITestOutputHelper testOutputHelper) + public McpServerSkillsFromSubdirectoriesTests(ITestOutputHelper testOutputHelper) : base(testOutputHelper, startServer: false) { Directory.CreateDirectory(Path.Combine(_root, "git-workflow", "references")); @@ -37,7 +38,7 @@ public McpServerSkillsFromDirectoryTests(ITestOutputHelper testOutputHelper) Directory.CreateDirectory(Path.Combine(_root, ".private-skill")); File.WriteAllText(Path.Combine(_root, ".private-skill", "SKILL.md"), "---\nname: private-skill\ndescription: d\n---\n"); - McpServerBuilder.WithSkillsFromDirectory(_root, uriPrefix: "skill://acme/billing"); + McpServerBuilder.WithSkillsFromSubdirectories(_root, new McpServerSkillPackageDiscoveryOptions { UriPrefix = "skill://acme/billing" }); StartServer(); } @@ -46,14 +47,14 @@ public async Task ServesEverySkillDirectory_WithFrontmatterFromTheFile() { await using McpClient client = await CreateMcpClientForServer(); - var skills = await client.ListSkillsAsync(TestContext.Current.CancellationToken); + var skills = await client.ListSkillsAsync(cancellationToken: TestContext.Current.CancellationToken); Assert.Equal(2, skills.Count); var gitWorkflow = Assert.Single(skills, s => s.Uri == "skill://acme/billing/git-workflow/SKILL.md"); Assert.Equal("MIT", gitWorkflow.Frontmatter["license"]?.GetValue()); - Assert.Equal(2, gitWorkflow.Resources.Resources!.Count); - Assert.Contains(gitWorkflow.Resources.Resources, r => r.Uri == "skill://acme/billing/git-workflow/references/STYLE.md"); + Assert.Equal(2, gitWorkflow.Resources.Entries!.Count); + Assert.Contains(gitWorkflow.Resources.Entries, r => r.Uri == "skill://acme/billing/git-workflow/references/STYLE.md"); var refunds = Assert.Single(skills, s => s.Uri == "skill://acme/billing/refunds/SKILL.md"); Assert.Equal("Process refunds per policy.\n", refunds.Description); @@ -64,11 +65,11 @@ public async Task ServesEverySkillDirectory_WithFrontmatterFromTheFile() public async Task ServedFiles_VerifyAgainstTheirManifest() { await using McpClient client = await CreateMcpClientForServer(); - var skill = await client.GetSkillAsync("skill://acme/billing/git-workflow/SKILL.md", TestContext.Current.CancellationToken); + var skill = await client.GetSkillAsync("skill://acme/billing/git-workflow/SKILL.md", cancellationToken: TestContext.Current.CancellationToken); - foreach (var file in skill.Resources.Resources!) + foreach (var file in skill.Resources.Entries!) { - var result = await client.ReadSkillResourceAsync(skill, file.Uri, TestContext.Current.CancellationToken); + var result = await client.ReadSkillResourceAsync(skill, file.Uri, cancellationToken: TestContext.Current.CancellationToken); Assert.Single(result.Contents); } } @@ -77,21 +78,22 @@ public async Task ServedFiles_VerifyAgainstTheirManifest() public async Task HiddenEntriesAreNeitherListedNorReadable() { await using var client = await CreateMcpClientForServer(); - var skills = await client.ListSkillsAsync(TestContext.Current.CancellationToken); + var skills = await client.ListSkillsAsync(cancellationToken: TestContext.Current.CancellationToken); Assert.DoesNotContain(skills, skill => skill.Name == "private-skill"); - Assert.DoesNotContain(skills.SelectMany(skill => skill.Resources.Resources!), resource => resource.Uri.Contains("/.")); + Assert.DoesNotContain(skills.SelectMany(skill => skill.Resources.Entries!), resource => resource.Uri.Contains("/.")); await Assert.ThrowsAsync(async () => await client.ReadResourceAsync( "skill://acme/billing/git-workflow/.env", cancellationToken: TestContext.Current.CancellationToken)); } [Fact] - public void BuilderCanIncludeHiddenEntriesExplicitly() + public async Task BuilderCanIncludeHiddenEntriesExplicitly() { var services = new ServiceCollection(); - services.AddMcpServer().WithSkillsFromDirectory(_root, includeHiddenAndSystemFiles: true); - using var provider = services.BuildServiceProvider(); - var resources = provider.GetServices().Select(resource => resource.ProtocolResource!.Uri).ToList(); + services.AddMcpServer().WithSkillsFromSubdirectories(_root, new McpServerSkillPackageDiscoveryOptions { IncludeHiddenAndSystemFiles = true }); + await using var provider = services.BuildServiceProvider(); + var resources = provider.GetRequiredService>().Value.ResourceCollection! + .Select(resource => resource.ProtocolResource!.Uri).ToList(); Assert.Contains("skill://private-skill/SKILL.md", resources); Assert.Contains("skill://git-workflow/.env", resources); @@ -99,56 +101,57 @@ public void BuilderCanIncludeHiddenEntriesExplicitly() } [Fact] - public void WithSkills_DetectsFilesThatCollideUnderUriEquivalence() + public async Task PackageBatchesDetectFilesThatCollideUnderUriEquivalence() { // The resource collection compares URIs case-insensitively in scheme and authority, so these two skills // would silently share one registered resource. With different content that must be an error. - var upper = McpServerSkill.Create("skill://Acme/refunds/SKILL.md", [McpServerSkillFile.FromText("SKILL.md", "---\nname: refunds\ndescription: upper\n---\n")]); - var lower = McpServerSkill.Create("skill://acme/refunds/SKILL.md", [McpServerSkillFile.FromText("SKILL.md", "---\nname: refunds\ndescription: lower\n---\n")]); + var upper = McpServerSkillPackage.Create([McpServerSkillFile.FromText("SKILL.md", "---\nname: refunds\ndescription: upper\n---\n")], new McpServerSkillPackageCreateOptions { Uri = "skill://Acme/refunds/SKILL.md" }); + var lower = McpServerSkillPackage.Create([McpServerSkillFile.FromText("SKILL.md", "---\nname: refunds\ndescription: lower\n---\n")], new McpServerSkillPackageCreateOptions { Uri = "skill://acme/refunds/SKILL.md" }); - var exception = Assert.Throws(() => new ServiceCollection().AddMcpServer().WithSkills([upper, lower])); - Assert.Contains("equivalent", exception.Message); + await Assert.ThrowsAsync(() => BuildOptionsAsync(upper, lower)); - // Identical content does not help: the collection would register one resource whose contents carry the - // first URI, so a verified read of the alias would fail. Only the identical URI text may be shared. - var same = McpServerSkill.Create("skill://ACME/refunds/SKILL.md", [McpServerSkillFile.FromText("SKILL.md", "---\nname: refunds\ndescription: upper\n---\n")]); - Assert.Throws(() => new ServiceCollection().AddMcpServer().WithSkills([upper, same])); + // Equivalent identities keep the first contribution when content is identical too. + var same = McpServerSkillPackage.Create([McpServerSkillFile.FromText("SKILL.md", "---\nname: refunds\ndescription: upper\n---\n")], new McpServerSkillPackageCreateOptions { Uri = "skill://ACME/refunds/SKILL.md" }); + Assert.Same(upper.Resources[0], Assert.Single((await BuildOptionsAsync(upper, same)).ResourceCollection!)); // The legitimate shared-file case: a nested skill's SKILL.md is also a file of the enclosing skill, under // the identical URI text and with identical content. That registers once and is fine. const string InnerMarkdown = "---\nname: inner\ndescription: nested\n---\n"; - var outer = McpServerSkill.Create("skill://acme/SKILL.md", + var outer = McpServerSkillPackage.Create( [ McpServerSkillFile.FromText("SKILL.md", "---\nname: acme\ndescription: outer\n---\n"), McpServerSkillFile.FromText("inner/SKILL.md", InnerMarkdown), - ]); - var inner = McpServerSkill.Create("skill://acme/inner/SKILL.md", [McpServerSkillFile.FromText("SKILL.md", InnerMarkdown)]); - new ServiceCollection().AddMcpServer().WithSkills([outer, inner]); + ], new McpServerSkillPackageCreateOptions { Uri = "skill://acme/SKILL.md" }); + var inner = McpServerSkillPackage.Create([McpServerSkillFile.FromText("SKILL.md", InnerMarkdown)], new McpServerSkillPackageCreateOptions { Uri = "skill://acme/inner/SKILL.md" }); + var shared = (await BuildOptionsAsync(outer, inner)).ResourceCollection!; + Assert.Equal(2, shared.Count); + Assert.Same(outer.Resources[1], shared[inner.Skill.Id]); } [Theory] [InlineData("café")] [InlineData("数据分析")] [InlineData("\U00010428")] - public void WithSkills_RegistersNestedUnicodeSkillsWithIdenticalSharedUris(string name) + public async Task WithSkills_RegistersNestedUnicodeSkillsWithIdenticalSharedUris(string name) { var innerMarkdown = $"---\nname: {name}\ndescription: nested\n---\n"; - var outer = McpServerSkill.Create("skill://outer/SKILL.md", + var outer = McpServerSkillPackage.Create( [ McpServerSkillFile.FromText("SKILL.md", "---\nname: outer\ndescription: parent\n---\n"), McpServerSkillFile.FromText($"{name}/SKILL.md", innerMarkdown), - ]); + ], new McpServerSkillPackageCreateOptions { Uri = "skill://outer/SKILL.md" }); var uri = $"skill://outer/{Uri.EscapeDataString(name)}/SKILL.md"; - var inner = McpServerSkill.Create(uri, [McpServerSkillFile.FromText("SKILL.md", innerMarkdown)]); + var inner = McpServerSkillPackage.Create([McpServerSkillFile.FromText("SKILL.md", innerMarkdown)], new McpServerSkillPackageCreateOptions { Uri = uri }); var services = new ServiceCollection(); - services.AddMcpServer().WithSkills([outer, inner]); - using var provider = services.BuildServiceProvider(); - var resources = provider.GetServices().Select(resource => resource.ProtocolResource!.Uri).ToArray(); + services.AddMcpServer().WithSkillPackages([outer, inner]); + await using var provider = services.BuildServiceProvider(); + var resources = provider.GetRequiredService>().Value.ResourceCollection! + .Select(resource => resource.ProtocolResource!.Uri).ToArray(); Assert.Equal(2, resources.Length); Assert.Contains(uri, resources); - Assert.Equal(uri, inner.ProtocolSkill.Uri); - Assert.Contains(outer.ProtocolSkill.Resources.Resources!, resource => resource.Uri == uri); + Assert.Equal(uri, inner.Skill.ProtocolSkill!.Uri); + Assert.Contains(outer.Skill.ProtocolSkill!.Resources.Entries!, resource => resource.Uri == uri); } [Theory] @@ -158,33 +161,41 @@ public void WithSkills_RegistersNestedUnicodeSkillsWithIdenticalSharedUris(strin [InlineData("café", "repo:/skills/")] [InlineData("数据分析", "repo:skills/")] [InlineData("\U00010428", "repo:/skills/")] - public void WithSkillsFromDirectory_EscapesUnicodeNamePathSegments(string name, string prefix) + public async Task WithSkillsFromSubdirectories_EscapesUnicodeNamePathSegments(string name, string prefix) { var root = Path.Combine(_root, "unicode-root"); var directory = Path.Combine(root, name); Directory.CreateDirectory(directory); File.WriteAllText(Path.Combine(directory, "SKILL.md"), $"---\nname: {name}\ndescription: d\n---\n"); var services = new ServiceCollection(); - services.AddMcpServer().WithSkillsFromDirectory(root, uriPrefix: prefix); - using var provider = services.BuildServiceProvider(); + services.AddMcpServer().WithSkillsFromSubdirectories(root, new McpServerSkillPackageDiscoveryOptions { UriPrefix = prefix }); + await using var provider = services.BuildServiceProvider(); - var resource = Assert.Single(provider.GetServices()); + var resource = Assert.Single(provider.GetRequiredService>().Value.ResourceCollection!); Assert.Equal($"{prefix}{Uri.EscapeDataString(name)}/SKILL.md", resource.ProtocolResource!.Uri); } + private static async Task BuildOptionsAsync(params McpServerSkillPackage[] skills) + { + var services = new ServiceCollection(); + services.AddMcpServer().WithSkillPackages(skills); + await using var provider = services.BuildServiceProvider(); + return provider.GetRequiredService>().Value; + } + [Fact] - public void WithSkillsFromDirectory_RejectsDirectoriesWithoutSkills() + public void WithSkillsFromSubdirectories_RejectsDirectoriesWithoutSkills() { string empty = Path.Combine(Path.GetTempPath(), "mcp-skills-empty-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(Path.Combine(empty, "not-a-skill")); try { var services = new ServiceCollection(); - var exception = Assert.Throws(() => services.AddMcpServer().WithSkillsFromDirectory(empty)); + var exception = Assert.Throws(() => services.AddMcpServer().WithSkillsFromSubdirectories(empty)); Assert.Contains("No skill directories", exception.Message); - Assert.Throws(() => services.AddMcpServer().WithSkillsFromDirectory(Path.Combine(empty, "missing"))); - Assert.Throws(() => services.AddMcpServer().WithSkillsFromDirectory(empty, uriPrefix: "")); + Assert.Throws(() => services.AddMcpServer().WithSkillsFromSubdirectories(Path.Combine(empty, "missing"))); + Assert.Throws(() => services.AddMcpServer().WithSkillsFromSubdirectories(empty, new McpServerSkillPackageDiscoveryOptions { UriPrefix = "" })); } finally { @@ -193,7 +204,7 @@ public void WithSkillsFromDirectory_RejectsDirectoriesWithoutSkills() } [Fact] - public void WithSkillsFromDirectory_RejectsAnInvalidSkillRatherThanSkippingIt() + public void WithSkillsFromSubdirectories_RejectsAnInvalidSkillRatherThanSkippingIt() { string root = Path.Combine(Path.GetTempPath(), "mcp-skills-invalid-" + Guid.NewGuid().ToString("N")); try @@ -203,7 +214,7 @@ public void WithSkillsFromDirectory_RejectsAnInvalidSkillRatherThanSkippingIt() Directory.CreateDirectory(Path.Combine(root, "broken")); File.WriteAllText(Path.Combine(root, "broken", "SKILL.md"), "---\nname: broken\n---\n"); - var exception = Assert.Throws(() => new ServiceCollection().AddMcpServer().WithSkillsFromDirectory(root)); + var exception = Assert.Throws(() => new ServiceCollection().AddMcpServer().WithSkillsFromSubdirectories(root)); Assert.Contains("description", exception.Message); Assert.Equal("directoryPath", exception.ParamName); } @@ -213,31 +224,9 @@ public void WithSkillsFromDirectory_RejectsAnInvalidSkillRatherThanSkippingIt() } } - [Fact] - public void WithSkillsFromDirectory_RejectsTwoDirectoriesDeclaringTheSameName() - { - string root = Path.Combine(Path.GetTempPath(), "mcp-skills-samename-" + Guid.NewGuid().ToString("N")); - try - { - // The URI is derived from the frontmatter name, not the directory name, so these collide. - foreach (string directory in new[] { "first", "second" }) - { - Directory.CreateDirectory(Path.Combine(root, directory)); - File.WriteAllText(Path.Combine(root, directory, "SKILL.md"), "---\nname: same\ndescription: d\n---\n"); - } - - var exception = Assert.Throws(() => new ServiceCollection().AddMcpServer().WithSkillsFromDirectory(root)); - Assert.Contains("skill://same/SKILL.md", exception.Message); - } - finally - { - Directory.Delete(root, recursive: true); - } - } - #if NET [Fact] - public void WithSkillsFromDirectory_RejectsSymbolicLinkSkillDirectories() + public void WithSkillsFromSubdirectories_RejectsSymbolicLinkSkillDirectories() { string root = Path.Combine(Path.GetTempPath(), "mcp-skills-linkroot-" + Guid.NewGuid().ToString("N")); try @@ -258,7 +247,7 @@ public void WithSkillsFromDirectory_RejectsSymbolicLinkSkillDirectories() Assert.Skip($"Cannot create symbolic links here: {e.Message}"); } - var exception = Assert.Throws(() => new ServiceCollection().AddMcpServer().WithSkillsFromDirectory(skills)); + var exception = Assert.Throws(() => new ServiceCollection().AddMcpServer().WithSkillsFromSubdirectories(skills)); Assert.Contains("symbolic link", exception.Message); } finally diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerSkillsIntegrityTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerSkillsIntegrityTests.cs index 05902deb0..4c93d52eb 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpServerSkillsIntegrityTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpServerSkillsIntegrityTests.cs @@ -10,7 +10,7 @@ namespace ModelContextProtocol.Tests.Server; /// /// End-to-end regression tests for two integrity properties of the Skills extension: the bytes a -/// serves are the bytes its manifest describes, even if the caller mutates its +/// serves are the bytes its manifest describes, even if the caller mutates its /// buffer afterwards; and a verified read is bound to the file that was requested, not merely to files of the /// same skill. /// @@ -31,10 +31,8 @@ protected override void ConfigureServices(ServiceCollection services, IMcpServer // A fresh buffer per fixture instance: ConfigureServices runs once per test, and the mutation below must // not leak into the next test's skill. byte[] callerOwnedBuffer = Encoding.UTF8.GetBytes(OriginalMarkdown); - var snapshot = McpServerSkill.Create( - SnapshotUri, - new JsonObject { ["name"] = "snapshot", ["description"] = "d" }, - [new McpServerSkillFile { Path = "SKILL.md", Content = callerOwnedBuffer }]); + var snapshot = McpServerSkillPackage.Create( + [new McpServerSkillFile { Path = "SKILL.md", Content = callerOwnedBuffer }], new McpServerSkillPackageCreateOptions { Uri = SnapshotUri, Frontmatter = new JsonObject { ["name"] = "snapshot", ["description"] = "d" } }); // Mutate the caller's buffer after the skill was created. The served content must not change. callerOwnedBuffer[callerOwnedBuffer.Length - 2] = (byte)'X'; @@ -55,8 +53,14 @@ protected override void ConfigureServices(ServiceCollection services, IMcpServer }; mcpServerBuilder - .WithSkills(new InMemoryMcpSkillCatalog([snapshot.ProtocolSkill, swapEntry])) - .WithResources(snapshot.Resources) + .WithSkillPackages([snapshot]).WithSkills(options => + { + options.ListSkillsHandler = (_, _) => new(new ListSkillsResult { Skills = [swapEntry] }); + options.GetSkillHandler = (context, _) => new(new GetSkillResult + { + Skill = context.Params.Uri == SwapUri ? swapEntry : snapshot.Skill.ProtocolSkill!, + }); + }) .WithResources( [ McpServerResource.Create( @@ -72,9 +76,9 @@ protected override void ConfigureServices(ServiceCollection services, IMcpServer public async Task ServedContent_IsTheContentTheManifestDescribes_EvenAfterCallerMutatesItsBuffer() { await using McpClient client = await CreateMcpClientForServer(); - var skill = await client.GetSkillAsync(SnapshotUri, TestContext.Current.CancellationToken); + var skill = await client.GetSkillAsync(SnapshotUri, cancellationToken: TestContext.Current.CancellationToken); - var result = await client.ReadSkillResourceAsync(skill, SnapshotUri, TestContext.Current.CancellationToken); + var result = await client.ReadSkillResourceAsync(skill, SnapshotUri, cancellationToken: TestContext.Current.CancellationToken); Assert.Equal(OriginalMarkdown, Assert.IsType(Assert.Single(result.Contents)).Text); } @@ -83,17 +87,17 @@ public async Task ServedContent_IsTheContentTheManifestDescribes_EvenAfterCaller public async Task ReadSkillResourceAsync_RejectsAResponseContainingOnlyADifferentFileOfTheSkill() { await using McpClient client = await CreateMcpClientForServer(); - var skill = await client.GetSkillAsync(SwapUri, TestContext.Current.CancellationToken); + var skill = await client.GetSkillAsync(SwapUri, cancellationToken: TestContext.Current.CancellationToken); // Sanity check: the substituted response is for a listed, correctly digested file. var raw = await client.ReadResourceAsync(SwapUri, cancellationToken: TestContext.Current.CancellationToken); Assert.Equal(SwapOtherUri, Assert.Single(raw.Contents).Uri); - var exception = await Assert.ThrowsAsync( - async () => await client.ReadSkillResourceAsync(skill, SwapUri, TestContext.Current.CancellationToken)); + var exception = await Assert.ThrowsAsync( + async () => await client.ReadSkillResourceAsync(skill, SwapUri, cancellationToken: TestContext.Current.CancellationToken)); Assert.Contains(SwapUri, exception.Message); // Reading the other file directly still verifies. - await client.ReadSkillResourceAsync(skill, SwapOtherUri, TestContext.Current.CancellationToken); + await client.ReadSkillResourceAsync(skill, SwapOtherUri, cancellationToken: TestContext.Current.CancellationToken); } } diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerSkillsOwnershipTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerSkillsOwnershipTests.cs index 847f22bc8..22b3e6c76 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpServerSkillsOwnershipTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpServerSkillsOwnershipTests.cs @@ -37,8 +37,16 @@ protected override void ConfigureServices(ServiceCollection services, IMcpServer ]), }; + var entries = new[] { files.Skill.ProtocolSkill!, directory.Skill.ProtocolSkill!, catalog }; mcpServerBuilder - .WithSkills(new InMemoryMcpSkillCatalog([files.ProtocolSkill, directory.ProtocolSkill, catalog])) + .WithSkills(options => + { + options.ListSkillsHandler = (_, _) => new(new ListSkillsResult { Skills = entries }); + options.GetSkillHandler = (context, _) => new(new GetSkillResult + { + Skill = entries.Single(entry => entry.Uri == context.Params.Uri), + }); + }) .WithResources(files.Resources) .WithResources(directory.Resources) .WithResources( @@ -56,8 +64,8 @@ public async Task CatalogListAndGet_TransportStructuredMetadataWithoutReadingFil { await using var client = await CreateMcpClientForServer(); - var listed = await client.ListSkillsAsync(TestContext.Current.CancellationToken); - var retrieved = await client.GetSkillAsync(CatalogUri, TestContext.Current.CancellationToken); + var listed = await client.ListSkillsAsync(cancellationToken: TestContext.Current.CancellationToken); + var retrieved = await client.GetSkillAsync(CatalogUri, cancellationToken: TestContext.Current.CancellationToken); Assert.Equal("Advertised metadata", Assert.Single(listed, skill => skill.Uri == CatalogUri).Description); Assert.Equal("Advertised metadata", retrieved.Description); @@ -71,43 +79,30 @@ public async Task ExplicitAuthoring_PreservesPrevalidatedUnsupportedMetadataAndB { await using var client = await CreateMcpClientForServer(); var uri = $"skill://{name}/SKILL.md"; - var entry = await client.GetSkillAsync(uri, TestContext.Current.CancellationToken); - var contents = await client.ReadSkillResourceAsync(entry, uri, TestContext.Current.CancellationToken); + var entry = await client.GetSkillAsync(uri, cancellationToken: TestContext.Current.CancellationToken); + var contents = await client.ReadSkillResourceAsync(entry, uri, cancellationToken: TestContext.Current.CancellationToken); var text = Assert.IsType(Assert.Single(contents.Contents)).Text; Assert.True(JsonNode.DeepEquals(PrevalidatedFrontmatter(name), entry.Frontmatter)); Assert.Equal(UnsupportedMarkdown(name), text); - Assert.Equal(Encoding.UTF8.GetByteCount(text), Assert.Single(entry.Resources.Resources!).Size); - Assert.Equal(SkillVerifier.ComputeDigest(Encoding.UTF8.GetBytes(text)), Assert.Single(entry.Resources.Resources!).Digest); + Assert.Equal(Encoding.UTF8.GetByteCount(text), Assert.Single(entry.Resources.Entries!).Size); + Assert.Equal(SkillVerifier.ComputeDigest(Encoding.UTF8.GetBytes(text)), Assert.Single(entry.Resources.Entries!).Digest); } [Fact] public async Task DigestVerifiedRead_DoesNotEstablishFrontmatterEquality() { await using var client = await CreateMcpClientForServer(); - var entry = await client.GetSkillAsync(CatalogUri, TestContext.Current.CancellationToken); - var result = await client.ReadSkillResourceAsync(entry, CatalogUri, TestContext.Current.CancellationToken); + var entry = await client.GetSkillAsync(CatalogUri, cancellationToken: TestContext.Current.CancellationToken); + var result = await client.ReadSkillResourceAsync(entry, CatalogUri, cancellationToken: TestContext.Current.CancellationToken); var text = Assert.IsType(Assert.Single(result.Contents)).Text; + Assert.Equal("Advertised metadata", entry.Description); Assert.Equal(CatalogMarkdown, text); Assert.Equal(1, _catalogReadCount); - Assert.Throws(() => VerifyHostFrontmatter(entry, text)); - } - - [Fact] - public async Task HostRejectsUnsupportedFetchedYaml_InsteadOfTrustingServerJson() - { - await using var client = await CreateMcpClientForServer(); - var entry = await client.GetSkillAsync("skill://provided-files/SKILL.md", TestContext.Current.CancellationToken); - var result = await client.ReadSkillResourceAsync(entry, entry.Uri, TestContext.Current.CancellationToken); - var text = Assert.IsType(Assert.Single(result.Contents)).Text; - - Assert.True(JsonNode.DeepEquals(PrevalidatedFrontmatter("provided-files"), entry.Frontmatter)); - var exception = Assert.Throws(() => VerifyHostFrontmatter(entry, text)); - Assert.IsAssignableFrom(exception.InnerException); } - private McpServerSkill CreatePrevalidatedSkill(string name, bool fromDirectory) + private McpServerSkillPackage CreatePrevalidatedSkill(string name, bool fromDirectory) { var markdown = UnsupportedMarkdown(name); var bytes = Encoding.UTF8.GetBytes(markdown); @@ -120,9 +115,8 @@ private McpServerSkill CreatePrevalidatedSkill(string name, bool fromDirectory) try { File.WriteAllBytes(Path.Combine(path, "SKILL.md"), bytes); - var skill = McpServerSkill.CreateFromDirectory(uri, supplied, path); + var skill = McpServerSkillPackage.CreateFromDirectory(path, new McpServerSkillPackageCreateOptions { Uri = uri, Frontmatter = supplied }); File.WriteAllText(Path.Combine(path, "SKILL.md"), "changed after creation"); - supplied["description"] = "changed after creation"; return skill; } finally @@ -131,9 +125,8 @@ private McpServerSkill CreatePrevalidatedSkill(string name, bool fromDirectory) } } - var result = McpServerSkill.Create(uri, supplied, [new McpServerSkillFile { Path = "SKILL.md", Content = bytes }]); + var result = McpServerSkillPackage.Create([new McpServerSkillFile { Path = "SKILL.md", Content = bytes }], new McpServerSkillPackageCreateOptions { Uri = uri, Frontmatter = supplied }); bytes[bytes.Length - 2] = (byte)'X'; - supplied["description"] = "changed after creation"; return result; } @@ -146,20 +139,4 @@ private string UnsupportedMarkdown(string name) => ["description"] = "Provided", ["metadata"] = new JsonObject { ["owner"] = "team", ["reviewer"] = "team" }, }; - - private void VerifyHostFrontmatter(Skill entry, string markdown) - { - try - { - var parsed = SkillFrontmatter.Parse(markdown); - if (!JsonNode.DeepEquals(parsed, entry.Frontmatter)) - { - throw new SkillVerificationException("The fetched frontmatter differs from the held entry."); - } - } - catch (FormatException exception) - { - throw new SkillVerificationException("The fetched frontmatter could not be verified.", exception); - } - } } diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerSkillsProviderTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerSkillsProviderTests.cs new file mode 100644 index 000000000..965953601 --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Server/McpServerSkillsProviderTests.cs @@ -0,0 +1,156 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using ModelContextProtocol.Client; +using ModelContextProtocol.Extensions.Skills; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using ModelContextProtocol.Tests.Utils; +using System.IO.Pipelines; +using System.Text.Json.Nodes; + +namespace ModelContextProtocol.Tests.Server; + +public class McpServerSkillsProviderTests(ITestOutputHelper output) : LoggedTest(output) +{ + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task SharedResultRetainsItsHintsAcrossModernLegacyAndModernRequests(bool get) + { + var entry = McpServerSkillPackage.Create( + [McpServerSkillFile.FromText("SKILL.md", "---\nname: alpha\ndescription: Alpha\n---\n")]).Skill.ProtocolSkill!; + var listResult = new ListSkillsResult { Skills = [entry], NextCursor = "application-cursor" }; + var getResult = new GetSkillResult { Skill = entry }; + Result source = get ? getResult : listResult; + source.ResultType = "input"; + source.Meta = new JsonObject { ["example.com/result"] = "shared" }; + var hints = (ICacheableResult)source; + hints.TimeToLive = TimeSpan.FromMinutes(2); + hints.CacheScope = CacheScope.Public; + + foreach (string version in new[] + { + McpProtocolVersions.July2026ProtocolVersion, + McpProtocolVersions.November2025ProtocolVersion, + McpProtocolVersions.July2026ProtocolVersion, + }) + { + var services = new ServiceCollection(); + services.AddMcpServer().WithSkills(options => + { + options.ListSkillsHandler = (_, _) => new(listResult); + options.GetSkillHandler = (_, _) => new(getResult); + }); + await using var provider = services.BuildServiceProvider(); + var options = provider.GetRequiredService>().Value; + Pipe input = new(), responses = new(); + await using var server = McpServer.Create(new StreamServerTransport( + input.Reader.AsStream(), responses.Writer.AsStream()), options, LoggerFactory); + var running = server.RunAsync(TestContext.Current.CancellationToken); + try + { + var clientOptions = TestMcpClient.CreateOptions(); + clientOptions.ProtocolVersion = version; + await using var client = await TestMcpClient.CreateAsync(new StreamClientTransport( + input.Writer.AsStream(), responses.Reader.AsStream()), clientOptions, LoggerFactory, + TestContext.Current.CancellationToken); + var result = (await client.SendRequestAsync(new JsonRpcRequest + { + Method = get ? SkillsProtocol.MethodSkillsGet : SkillsProtocol.MethodSkillsList, + Params = new JsonObject { ["uri"] = entry.Uri }, + }, TestContext.Current.CancellationToken)).Result!.AsObject(); + + Assert.Equal("shared", result["_meta"]!["example.com/result"]!.GetValue()); + if (get) + { + Assert.Equal(entry.Uri, result["skill"]!["uri"]!.GetValue()); + } + else + { + Assert.Equal(entry.Uri, Assert.Single(result["skills"]!.AsArray())!["uri"]!.GetValue()); + Assert.Equal("application-cursor", result["nextCursor"]!.GetValue()); + } + + if (version == McpProtocolVersions.July2026ProtocolVersion) + { + Assert.Equal("complete", result["resultType"]!.GetValue()); + Assert.Equal(120_000, result["ttlMs"]!.GetValue()); + Assert.Equal("public", result["cacheScope"]!.GetValue()); + } + else + { + Assert.False(result.ContainsKey("resultType") || result.ContainsKey("ttlMs") || result.ContainsKey("cacheScope")); + } + + Assert.Equal("input", source.ResultType); + Assert.Equal(TimeSpan.FromMinutes(2), hints.TimeToLive); + Assert.Equal(CacheScope.Public, hints.CacheScope); + } + finally + { + await server.DisposeAsync(); + await running.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken); + } + } + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task OptionsOnlyServerWorksWithoutProvider_ForFixedSkillsAndCustomHandlers(bool custom) + { + const string Markdown = "---\nname: alpha\ndescription: Alpha\n---\n"; + var package = McpServerSkillPackage.Create([McpServerSkillFile.FromText("SKILL.md", Markdown)]); + var skill = package.Skill; + var services = new ServiceCollection(); + var builder = services.AddMcpServer(); + if (custom) + { + builder.WithSkills(options => + { + options.ListSkillsHandler = (context, _) => + { + Assert.Null(context.Services); + return new(new ListSkillsResult { Skills = [skill.ProtocolSkill!] }); + }; + + options.GetSkillHandler = (context, _) => + { + Assert.Null(context.Services); + return new(new GetSkillResult { Skill = skill.ProtocolSkill! }); + }; + }).WithResources(package.Resources); + } + else + { + builder.WithSkillPackages([package]); + } + + McpServerOptions options; + await using (var provider = services.BuildServiceProvider(validateScopes: true)) + { + options = provider.GetRequiredService>().Value; + } + + Pipe input = new(), responses = new(); + await using var server = McpServer.Create(new StreamServerTransport( + input.Reader.AsStream(), responses.Writer.AsStream()), options, LoggerFactory); + Assert.Null(server.Services); + var running = server.RunAsync(TestContext.Current.CancellationToken); + try + { + await using var client = await TestMcpClient.CreateAsync(new StreamClientTransport( + input.Writer.AsStream(), responses.Reader.AsStream()), loggerFactory: LoggerFactory, + cancellationToken: TestContext.Current.CancellationToken); + Assert.Equal(skill.Id, Assert.Single(await client.ListSkillsAsync(cancellationToken: TestContext.Current.CancellationToken)).Uri); + var entry = await client.GetSkillAsync(skill.Id, cancellationToken: TestContext.Current.CancellationToken); + Assert.Equal(Markdown, Assert.IsType(Assert.Single((await client.ReadSkillResourceAsync( + entry, entry.Uri, cancellationToken: TestContext.Current.CancellationToken)).Contents)).Text); + } + finally + { + await server.DisposeAsync(); + await running.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken); + } + } +} diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerSkillsRegistrationTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerSkillsRegistrationTests.cs index 46f0e2962..c0e3594e3 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpServerSkillsRegistrationTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpServerSkillsRegistrationTests.cs @@ -5,15 +5,10 @@ namespace ModelContextProtocol.Tests.Server; -/// -/// Tests that a server has one Skills registration, rejected early when repeated through any public method, and -/// that each Skills file is the only resource at its URI. -/// public sealed class McpServerSkillsRegistrationTests : IDisposable { private const string GitWorkflowUri = "skill://git-workflow/SKILL.md"; private const string GitWorkflowMarkdown = "---\nname: git-workflow\ndescription: Git conventions\n---\n# Git\n"; - private readonly string _root = Path.Combine(Path.GetTempPath(), "mcp-skills-registration-" + Guid.NewGuid().ToString("N")); public McpServerSkillsRegistrationTests() @@ -25,208 +20,258 @@ public McpServerSkillsRegistrationTests() public void Dispose() => Directory.Delete(_root, recursive: true); [Theory] - [InlineData("skills")] - [InlineData("directory")] - [InlineData("directory-with-hidden-files")] - [InlineData("catalog")] - public void SecondRegistrationOnTheSameBuilder_ThrowsBeforeRegisteringAnything(string method) + [InlineData("bad scheme://git-workflow/SKILL.md")] + [InlineData("skill://other/SKILL.md")] + [InlineData("skill://git-workflow/missing.md")] + public void ExplicitUriErrorsNameOptionsAcrossAuthoringEntrypoints(string uri) { + var options = new McpServerSkillPackageCreateOptions { Uri = uri }; + Assert.Throws("options", () => McpServerSkillPackage.Create( + [McpServerSkillFile.FromText("SKILL.md", GitWorkflowMarkdown)], options)); + string directory = Path.Combine(_root, "git-workflow"); + Assert.Throws("options", () => McpServerSkillPackage.CreateFromDirectory(directory, options)); var services = new ServiceCollection(); var builder = services.AddMcpServer(); - builder.WithSkills([CreateGitWorkflow()]); - int registrationCount = services.Count; + var before = services.ToArray(); - var exception = Assert.Throws(() => Register(builder, method)); + Assert.Throws("options", () => builder.WithSkillFromDirectory(directory, options)); - Assert.Contains("Skills are already registered", exception.Message); - Assert.Equal(registrationCount, services.Count); + Assert.Equal(before, services); } [Theory] - [InlineData("skills")] - [InlineData("directory")] - [InlineData("directory-with-hidden-files")] - [InlineData("catalog")] - public void SecondRegistrationThroughAnotherBuilderForTheSameServices_Throws(string method) + [InlineData("bad scheme://")] + [InlineData("skill://catalog?query/")] + [InlineData("skill://catalog#fragment/")] + public void InvalidDiscoveryPrefixesNameOptionsBeforeRegisteringServices(string prefix) { - // For example a library and the application each enabling Skills on the same service collection. + var options = new McpServerSkillPackageDiscoveryOptions { UriPrefix = prefix }; + Assert.Throws("options", () => McpServerSkillPackage.CreateFromSubdirectories(_root, options)); var services = new ServiceCollection(); - Register(services.AddMcpServer(), method); - var secondBuilder = services.AddMcpServer(); - int registrationCount = services.Count; + var builder = services.AddMcpServer(); + var before = services.ToArray(); - var exception = Assert.Throws(() => secondBuilder.WithSkillsFromDirectory(_root)); + Assert.Throws("options", () => builder.WithSkillsFromSubdirectories(_root, options)); - Assert.Contains("Skills are already registered", exception.Message); - Assert.Equal(registrationCount, services.Count); + Assert.Equal(before, services); } [Theory] - [InlineData("skills")] - [InlineData("directory")] - [InlineData("directory-with-hidden-files")] - [InlineData("catalog")] - public void SingleRegistration_InstallsEachSkillsMethodOnce(string method) + [InlineData("# no frontmatter\n")] + [InlineData("---\nname: git-workflow\n---\n")] + [InlineData("---\nname: 42\ndescription: Git conventions\n---\n")] + public void InvalidFileFrontmatterKeepsFileAttributionWithMetadataOrAValidUriOption(string markdown) { - var services = new ServiceCollection(); - Register(services.AddMcpServer(), method); - using var provider = services.BuildServiceProvider(); + string directory = Path.Combine(_root, "git-workflow"); + File.WriteAllText(Path.Combine(directory, "SKILL.md"), markdown); + McpServerSkillPackageCreateOptions[] optionSets = + [ + new() { Metadata = ["application-tag"] }, + new() { Uri = GitWorkflowUri }, + ]; + foreach (var options in optionSets) + { + Assert.Throws("files", () => McpServerSkillPackage.Create( + [McpServerSkillFile.FromText("SKILL.md", markdown)], options)); + Assert.Throws("directoryPath", () => McpServerSkillPackage.CreateFromDirectory(directory, options)); + var services = new ServiceCollection(); + var builder = services.AddMcpServer(); + var before = services.ToArray(); - var options = provider.GetRequiredService>().Value; + Assert.Throws("directoryPath", () => builder.WithSkillFromDirectory(directory, options)); -#pragma warning disable MCPEXP002 // RequestHandlers is experimental. - Assert.Single(options.RequestHandlers!, handler => handler.Method == "skills/list"); - Assert.Single(options.RequestHandlers!, handler => handler.Method == "skills/get"); -#pragma warning restore MCPEXP002 + Assert.Equal(before, services); + } } [Fact] - public void FailedRegistration_DoesNotBlockALaterOne() + public async Task ContributionsAreAdditive_AndInstallEachWireHandlerOnce() { - var empty = Path.Combine(_root, "not-a-skill"); - Directory.CreateDirectory(empty); + var alpha = McpServerSkillPackage.Create([McpServerSkillFile.FromText("SKILL.md", "---\nname: alpha\ndescription: Alpha\n---\n")]); + var package = CreateGitWorkflow(); var services = new ServiceCollection(); - - Assert.Throws(() => services.AddMcpServer().WithSkillsFromDirectory(empty)); - services.AddMcpServer().WithSkillsFromDirectory(_root); + services.AddMcpServer() + .WithSkills([alpha.Skill]) + .WithSkillPackages([package]) + .WithSkills([alpha.Skill]) + .WithSkills(); + await using var provider = services.BuildServiceProvider(new ServiceProviderOptions { ValidateScopes = true, ValidateOnBuild = true }); + var options = provider.GetRequiredService>().Value; + var skills = provider.GetRequiredService>().Value.SkillCollection; +#pragma warning disable MCPEXP002 + Assert.Single(options.RequestHandlers!, handler => handler.Method == SkillsProtocol.MethodSkillsList); + Assert.Single(options.RequestHandlers!, handler => handler.Method == SkillsProtocol.MethodSkillsGet); +#pragma warning restore MCPEXP002 + Assert.Same(alpha.Skill, skills[alpha.Skill.Id]); + Assert.Same(package.Skill, skills[package.Skill.Id]); + Assert.Equal(2, skills.Count); + Assert.Same(package.Resources[0], Assert.Single(options.ResourceCollection!)); } [Theory] - [InlineData("conflicting-file", typeof(ArgumentException))] - [InlineData("duplicate-skill", typeof(ArgumentException))] - [InlineData("configure", typeof(InvalidOperationException))] - public void FailedRegistration_LeavesNoResources_AndARetryWithNewSkillsSucceeds(string failure, Type expectedException) + [InlineData("enumeration")] + [InlineData("null")] + [InlineData("conflict")] + [InlineData("null-manifest")] + [InlineData("null-manifest-entry")] + [InlineData("identity")] + public async Task PackageFailuresLeaveTheCallsServicesUntouched(string failure) { + var first = CreateGitWorkflow(); + var changed = McpServerSkillPackage.Create( + [McpServerSkillFile.FromText("SKILL.md", GitWorkflowMarkdown + "changed")]); + var entry = changed.Skill.ProtocolSkill!; + switch (failure) + { + case "null-manifest": entry.Resources = null!; break; + case "null-manifest-entry": + Assert.IsAssignableFrom>(entry.Resources.Entries)[0] = null!; + break; + case "identity": + entry.Uri = "skill://other/git-workflow/SKILL.md"; + entry.Resources = SkillResources.Dynamic; + break; + } + var services = new ServiceCollection(); var builder = services.AddMcpServer(); - int registrationCount = services.Count; + var before = services.ToArray(); + int enumerations = 0; + var exception = Record.Exception(() => builder.WithSkillPackages(Packages())); - Assert.Throws(expectedException, () => RegisterFailing(builder, failure)); - Assert.Equal(registrationCount, services.Count); + Assert.IsType(failure == "enumeration" ? typeof(InvalidOperationException) : typeof(ArgumentException), exception); + if (failure is "null" or "null-manifest" or "null-manifest-entry" or "identity") + { + Assert.Equal("packages", Assert.IsType(exception).ParamName); + } + Assert.Equal(before, services); + Assert.Equal(1, enumerations); - // New skill instances: a resource left behind by the failed call would conflict with them. - builder.WithSkills([CreateGitWorkflow()]); - using var provider = services.BuildServiceProvider(); - var options = provider.GetRequiredService>().Value; + enumerations = 0; + builder.WithSkillPackages(ValidPackages()); + Assert.Equal(1, enumerations); + await using var provider = services.BuildServiceProvider(); + Assert.Same(first.Skill, Assert.Single(provider.GetRequiredService>().Value.SkillCollection)); + Assert.Same(first.Resources[0], Assert.Single(provider.GetRequiredService>().Value.ResourceCollection!)); - Assert.Equal([GitWorkflowUri], options.ResourceCollection!.PrimitiveNames); - } - - [Theory] - [InlineData(true, GitWorkflowUri)] - [InlineData(false, GitWorkflowUri)] - [InlineData(true, "skill://GIT-WORKFLOW/SKILL.md")] - [InlineData(false, "SKILL://git-workflow/SKILL.md")] - public void ApplicationResourceAtASkillFileUri_FailsWhenTheOptionsAreBuilt(bool applicationResourceFirst, string applicationUri) - { - // The reported shape: an application resource whose parameterless template is a Skills file's URI. - var applicationResource = McpServerResource.Create( - () => "application content", - new McpServerResourceCreateOptions { UriTemplate = applicationUri }); - var services = new ServiceCollection(); - var builder = services.AddMcpServer(); - if (applicationResourceFirst) + IEnumerable Packages() { - builder.WithResources([applicationResource]).WithSkillsFromDirectory(_root); + enumerations++; + yield return first; + if (failure == "enumeration") throw new InvalidOperationException("Enumeration failed."); + yield return failure == "null" ? null! : changed; } - else + + IEnumerable ValidPackages() { - builder.WithSkillsFromDirectory(_root).WithResources([applicationResource]); + enumerations++; + yield return first; } - - using var provider = services.BuildServiceProvider(); - - var exception = Assert.Throws(() => provider.GetRequiredService>().Value); - Assert.Contains(GitWorkflowUri, exception.Message); - Assert.Contains(applicationUri, exception.Message); } [Fact] - public void ResourceAddedDirectlyToTheOptions_ConflictsLikeARegisteredOne() + public async Task EmptyPackagesEnableSkills_AndPackageRegistrationRejectsNullArguments() { - var applicationResource = McpServerResource.Create( - () => "application content", - new McpServerResourceCreateOptions { UriTemplate = GitWorkflowUri }); var services = new ServiceCollection(); - services.AddMcpServer(options => options.ResourceCollection = [applicationResource]) - .WithSkills([CreateGitWorkflow()]); - using var provider = services.BuildServiceProvider(); + var builder = services.AddMcpServer(); + Assert.Throws("builder", () => McpSkillsBuilderExtensions.WithSkillPackages(null!, [])); + Assert.Throws("packages", () => builder.WithSkillPackages(null!)); + Assert.Same(builder, builder.WithSkillPackages([])); + await using var provider = services.BuildServiceProvider(); + var options = provider.GetRequiredService>().Value; + Assert.True(options.Capabilities!.Extensions!.ContainsKey(SkillsProtocol.ExtensionId)); + Assert.NotNull(options.Capabilities.Resources); + Assert.Empty(provider.GetRequiredService>().Value.SkillCollection); + } - var exception = Assert.Throws(() => provider.GetRequiredService>().Value); - Assert.Contains(GitWorkflowUri, exception.Message); + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ConfigureCanRemoveSkills_WithoutSuppressingTheirResources(bool laterCallback) + { + var package = CreateGitWorkflow(); + var services = new ServiceCollection(); + var builder = services.AddMcpServer().WithSkillPackages([package]); + if (laterCallback) services.Configure(RemoveSkill); + else builder.WithSkills(RemoveSkill); + await using var provider = services.BuildServiceProvider(); + + Assert.Empty(provider.GetRequiredService>().Value.SkillCollection); + Assert.Same(package.Resources[0], Assert.Single(provider.GetRequiredService>().Value.ResourceCollection!)); + + void RemoveSkill(McpSkillsOptions options) + { + Assert.Same(package.Skill, Assert.Single(options.SkillCollection)); + options.SkillCollection.Clear(); + } } [Fact] - public void SharedNestedFiles_TheSkillsOwnResources_AndOtherUris_DoNotConflict() + public async Task SeparatePackageCallsDoNotHideResourcesFromAnIgnoredSkillIdentity() { - const string InnerMarkdown = "---\nname: inner\ndescription: nested\n---\n"; - var outer = McpServerSkill.Create("skill://acme/SKILL.md", + var first = CreateGitWorkflow(); + var duplicate = McpServerSkillPackage.Create( [ - McpServerSkillFile.FromText("SKILL.md", "---\nname: acme\ndescription: outer\n---\n"), - McpServerSkillFile.FromText("inner/SKILL.md", InnerMarkdown), + McpServerSkillFile.FromText("SKILL.md", GitWorkflowMarkdown + "changed"), + McpServerSkillFile.FromText("extra.md", "extra"), ]); - var inner = McpServerSkill.Create("skill://acme/inner/SKILL.md", [McpServerSkillFile.FromText("SKILL.md", InnerMarkdown)]); var services = new ServiceCollection(); - services.AddMcpServer() - .WithSkills([outer, inner]) - .WithResources(outer.Resources) - .WithResources( - [ - McpServerResource.Create(() => "a different path", new McpServerResourceCreateOptions { UriTemplate = "skill://acme/skill.md" }), - McpServerResource.Create((string name) => name, new McpServerResourceCreateOptions { UriTemplate = "skill://{name}/SKILL.md" }), - ]); - using var provider = services.BuildServiceProvider(); + services.AddMcpServer().WithSkillPackages([first]).WithSkillPackages([duplicate]); + await using var provider = services.BuildServiceProvider(); - var options = provider.GetRequiredService>().Value; - - Assert.Equal( - ["skill://acme/SKILL.md", "skill://acme/inner/SKILL.md", "skill://acme/skill.md", "skill://{name}/SKILL.md"], - options.ResourceCollection!.PrimitiveNames); - Assert.Same(outer.Resources[1], options.ResourceCollection["skill://acme/inner/SKILL.md"]); + var skills = provider.GetRequiredService>().Value.SkillCollection; + var resources = provider.GetRequiredService>().Value.ResourceCollection!; + Assert.Same(first.Skill, Assert.Single(skills)); + Assert.Equal(2, resources.Count); + Assert.Same(first.Resources[0], resources[first.Skill.Id]); + Assert.Same(duplicate.Resources[1], resources["skill://git-workflow/extra.md"]); } - private void Register(IMcpServerBuilder builder, string method) + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task IndependentRegistrationsCanDescribeDifferentSharedFileSnapshots(bool packages) { - switch (method) + var outer = CreateOuter(); + var conflicting = McpServerSkillPackage.Create( + [McpServerSkillFile.FromText("SKILL.md", InnerMarkdown + "changed")], new() { Uri = "skill://catalog/inner/SKILL.md" }); + var collection = new McpServerSkillCollection { outer.Skill, conflicting.Skill }; + var services = new ServiceCollection(); + var builder = services.AddMcpServer(); + if (packages) { - case "skills": - builder.WithSkills([CreateGitWorkflow()]); - break; - case "directory": - builder.WithSkillsFromDirectory(_root); - break; - case "directory-with-hidden-files": - builder.WithSkillsFromDirectory(_root, includeHiddenAndSystemFiles: true); - break; - case "catalog": - builder.WithSkills(new InMemoryMcpSkillCatalog([CreateGitWorkflow().ProtocolSkill])); - break; - default: - throw new ArgumentOutOfRangeException(nameof(method), method, "Unknown registration method."); + builder.WithSkillPackages([outer]).WithSkillPackages([conflicting]); + } + else + { + builder.WithSkills(options => options.SkillCollection = collection); } - } - private void RegisterFailing(IMcpServerBuilder builder, string failure) - { - switch (failure) + await using var provider = services.BuildServiceProvider(); + var options = provider.GetRequiredService>().Value; + var registered = provider.GetRequiredService>().Value.SkillCollection; + Assert.Same(outer.Skill, registered[outer.Skill.Id]); + Assert.Same(conflicting.Skill, registered[conflicting.Skill.Id]); + if (packages) { - case "conflicting-file": - // The second skill lists the first one's file with different content. - var changed = McpServerSkill.Create(GitWorkflowUri, [McpServerSkillFile.FromText("SKILL.md", GitWorkflowMarkdown + "Changed.\n")]); - builder.WithSkills([CreateGitWorkflow(), changed]); - break; - case "duplicate-skill": - // Identical files are shared, so this fails later, when the catalog sees the same skill URI twice. - builder.WithSkills([CreateGitWorkflow(), CreateGitWorkflow()]); - break; - case "configure": - builder.WithSkills([CreateGitWorkflow()], _ => throw new InvalidOperationException("The configuration failed.")); - break; - default: - throw new ArgumentOutOfRangeException(nameof(failure), failure, "Unknown failure."); + Assert.Same(outer.Resources[1], options.ResourceCollection![conflicting.Skill.Id]); + } + else + { + Assert.Same(collection, registered); } + + Assert.Equal(2, registered.Count); } - private McpServerSkill CreateGitWorkflow() => - McpServerSkill.Create(GitWorkflowUri, [McpServerSkillFile.FromText("SKILL.md", GitWorkflowMarkdown)]); + private const string InnerMarkdown = "---\nname: inner\ndescription: Inner\n---\n"; + + private static McpServerSkillPackage CreateOuter() => McpServerSkillPackage.Create( + [ + McpServerSkillFile.FromText("SKILL.md", "---\nname: catalog\ndescription: Outer\n---\n"), + McpServerSkillFile.FromText("inner/SKILL.md", InnerMarkdown), + ], new() { Uri = "skill://catalog/SKILL.md" }); + + private static McpServerSkillPackage CreateGitWorkflow() => McpServerSkillPackage.Create( + [McpServerSkillFile.FromText("SKILL.md", GitWorkflowMarkdown)], new() { Uri = GitWorkflowUri }); } diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerSkillsTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerSkillsTests.cs index 864450afc..86b57d6c4 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpServerSkillsTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpServerSkillsTests.cs @@ -9,15 +9,17 @@ namespace ModelContextProtocol.Tests.Server; /// -/// End-to-end tests for skills registered through : capability declaration, listing, +/// End-to-end tests for skills registered through : capability declaration, listing, /// retrieval, automatic resource registration, and verified reads through the client extensions. /// public class McpServerSkillsTests : ClientServerTestBase { private const string GitWorkflowUri = "skill://git-workflow/SKILL.md"; private const string GitWorkflowMarkdown = "---\nname: git-workflow\ndescription: Git conventions\nlicense: MIT\n---\n\n# Git workflow\n"; + private const string UnlistedFileUri = "skill://git-workflow/references/NEW.md"; private const string RefundsUri = "skill://acme/billing/refunds/SKILL.md"; private static readonly byte[] s_binaryFile = [0x89, 0x50, 0x4E, 0x47, 0xFF, 0xFE, 0x00]; + private int _unlistedFileReadCount; public McpServerSkillsTests(ITestOutputHelper testOutputHelper) : base(testOutputHelper) @@ -26,21 +28,25 @@ public McpServerSkillsTests(ITestOutputHelper testOutputHelper) protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder) { - var gitWorkflow = McpServerSkill.Create( - GitWorkflowUri, - new JsonObject { ["name"] = "git-workflow", ["description"] = "Git conventions", ["license"] = "MIT" }, + var gitWorkflow = McpServerSkillPackage.Create( [ McpServerSkillFile.FromText("SKILL.md", GitWorkflowMarkdown), McpServerSkillFile.FromText("references/GUIDE.md", "# Guide\n"), new McpServerSkillFile { Path = "assets/logo.png", Content = s_binaryFile }, - ]); + ], new McpServerSkillPackageCreateOptions { Uri = GitWorkflowUri, Frontmatter = new JsonObject { ["name"] = "git-workflow", ["description"] = "Git conventions", ["license"] = "MIT" } }); - var refunds = McpServerSkill.Create( - RefundsUri, - new JsonObject { ["name"] = "refunds", ["description"] = "Process refunds" }, - [McpServerSkillFile.FromText("SKILL.md", "---\nname: refunds\ndescription: Process refunds\n---\n")]); + var refunds = McpServerSkillPackage.Create( + [McpServerSkillFile.FromText("SKILL.md", "---\nname: refunds\ndescription: Process refunds\n---\n")], new McpServerSkillPackageCreateOptions { Uri = RefundsUri, Frontmatter = new JsonObject { ["name"] = "refunds", ["description"] = "Process refunds" } }); - mcpServerBuilder.WithSkills([gitWorkflow, refunds]); + mcpServerBuilder.WithSkillPackages([gitWorkflow, refunds]) + .WithResources( + [ + McpServerResource.Create(() => + { + Interlocked.Increment(ref _unlistedFileReadCount); + return "# New guide\n"; + }, new McpServerResourceCreateOptions { UriTemplate = UnlistedFileUri }), + ]); } [Fact] @@ -48,7 +54,7 @@ public async Task Server_DeclaresSkillsExtensionAndResourcesCapability() { await using McpClient client = await CreateMcpClientForServer(); - Assert.True(client.SupportsSkills()); + Assert.True(client.ServerCapabilities.SupportsSkills()); Assert.NotNull(client.ServerCapabilities.Resources); var settings = System.Text.Json.JsonSerializer.SerializeToNode(client.ServerCapabilities.Extensions![SkillsProtocol.ExtensionId], McpJsonUtilities.DefaultOptions)?.AsObject(); @@ -61,13 +67,13 @@ public async Task ListSkillsAsync_ReturnsEveryRegisteredSkill() { await using McpClient client = await CreateMcpClientForServer(); - var skills = await client.ListSkillsAsync(TestContext.Current.CancellationToken); + var skills = await client.ListSkillsAsync(cancellationToken: TestContext.Current.CancellationToken); Assert.Equal(2, skills.Count); var gitWorkflow = Assert.Single(skills, s => s.Uri == GitWorkflowUri); Assert.Equal("git-workflow", gitWorkflow.Name); Assert.Equal("MIT", gitWorkflow.Frontmatter["license"]?.GetValue()); - Assert.Equal(3, gitWorkflow.Resources.Resources!.Count); + Assert.Equal(3, gitWorkflow.Resources.Entries!.Count); Assert.Contains(skills, s => s.Uri == RefundsUri && s.Name == "refunds"); } @@ -76,11 +82,11 @@ public async Task GetSkillAsync_ReturnsTheRequestedEntry() { await using McpClient client = await CreateMcpClientForServer(); - var skill = await client.GetSkillAsync(RefundsUri, TestContext.Current.CancellationToken); + var skill = await client.GetSkillAsync(RefundsUri, cancellationToken: TestContext.Current.CancellationToken); Assert.Equal(RefundsUri, skill.Uri); Assert.Equal("refunds", skill.Name); - Assert.Single(skill.Resources.Resources!); + Assert.Single(skill.Resources.Entries!); } [Fact] @@ -89,7 +95,7 @@ public async Task GetSkillAsync_WithUnknownUri_ReturnsInvalidParams() await using McpClient client = await CreateMcpClientForServer(); var exception = await Assert.ThrowsAsync( - async () => await client.GetSkillAsync("skill://missing/SKILL.md", TestContext.Current.CancellationToken)); + async () => await client.GetSkillAsync("skill://missing/SKILL.md", cancellationToken: TestContext.Current.CancellationToken)); Assert.Equal(McpErrorCode.InvalidParams, exception.ErrorCode); } @@ -114,9 +120,9 @@ public async Task SkillFiles_AreListedAsResources() public async Task ReadSkillResourceAsync_ReturnsVerifiedText() { await using McpClient client = await CreateMcpClientForServer(); - var skill = await client.GetSkillAsync(GitWorkflowUri, TestContext.Current.CancellationToken); + var skill = await client.GetSkillAsync(GitWorkflowUri, cancellationToken: TestContext.Current.CancellationToken); - var result = await client.ReadSkillResourceAsync(skill, GitWorkflowUri, TestContext.Current.CancellationToken); + var result = await client.ReadSkillResourceAsync(skill, GitWorkflowUri, cancellationToken: TestContext.Current.CancellationToken); var text = Assert.IsType(Assert.Single(result.Contents)); Assert.Equal(GitWorkflowMarkdown, text.Text); @@ -127,9 +133,9 @@ public async Task ReadSkillResourceAsync_ReturnsVerifiedText() public async Task ReadSkillResourceAsync_ReturnsVerifiedBlobForBinaryFile() { await using McpClient client = await CreateMcpClientForServer(); - var skill = await client.GetSkillAsync(GitWorkflowUri, TestContext.Current.CancellationToken); + var skill = await client.GetSkillAsync(GitWorkflowUri, cancellationToken: TestContext.Current.CancellationToken); - var result = await client.ReadSkillResourceAsync(skill, "skill://git-workflow/assets/logo.png", TestContext.Current.CancellationToken); + var result = await client.ReadSkillResourceAsync(skill, "skill://git-workflow/assets/logo.png", cancellationToken: TestContext.Current.CancellationToken); var blob = Assert.IsType(Assert.Single(result.Contents)); Assert.Equal(s_binaryFile, blob.DecodedData.ToArray()); @@ -140,25 +146,27 @@ public async Task ReadSkillResourceAsync_ReturnsVerifiedBlobForBinaryFile() public async Task ReadSkillResourceAsync_RejectsUnlistedFileWithoutContactingServer() { await using McpClient client = await CreateMcpClientForServer(); - var skill = await client.GetSkillAsync(GitWorkflowUri, TestContext.Current.CancellationToken); + var skill = await client.GetSkillAsync(GitWorkflowUri, cancellationToken: TestContext.Current.CancellationToken); + + await Assert.ThrowsAsync( + async () => await client.ReadSkillResourceAsync(skill, UnlistedFileUri, cancellationToken: TestContext.Current.CancellationToken)); - await Assert.ThrowsAsync( - async () => await client.ReadSkillResourceAsync(skill, "skill://git-workflow/references/NEW.md", TestContext.Current.CancellationToken)); + Assert.Equal(0, _unlistedFileReadCount); } [Fact] public async Task ReadSkillResourceAsync_DetectsContentThatNoLongerMatchesTheHeldEntry() { await using McpClient client = await CreateMcpClientForServer(); - var skill = await client.GetSkillAsync(GitWorkflowUri, TestContext.Current.CancellationToken); + var skill = await client.GetSkillAsync(GitWorkflowUri, cancellationToken: TestContext.Current.CancellationToken); // Simulate a stale held entry: the host approved the skill against a different manifest. - var stale = skill.Resources.Resources!.Select(r => new SkillResource { Uri = r.Uri, Digest = r.Digest, Size = r.Size }).ToList(); + var stale = skill.Resources.Entries!.Select(r => new SkillResource { Uri = r.Uri, Digest = r.Digest, Size = r.Size }).ToList(); stale[0].Digest = SkillVerifier.ComputeDigest(Encoding.UTF8.GetBytes("previous content")); stale[0].Size = Encoding.UTF8.GetByteCount("previous content"); skill.Resources = SkillResources.FromResources(stale); - await Assert.ThrowsAsync( - async () => await client.ReadSkillResourceAsync(skill, GitWorkflowUri, TestContext.Current.CancellationToken)); + await Assert.ThrowsAsync( + async () => await client.ReadSkillResourceAsync(skill, GitWorkflowUri, cancellationToken: TestContext.Current.CancellationToken)); } } diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerToolConditionalRepairTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerToolConditionalRepairTests.cs new file mode 100644 index 000000000..f195eed69 --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Server/McpServerToolConditionalRepairTests.cs @@ -0,0 +1,388 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; +using ModelContextProtocol.Server; + +namespace ModelContextProtocol.Tests.Server; + +public partial class McpServerToolTests +{ + [Fact] + public async Task StructuredOutput_ConditionalRepair_LeavesExactContainsSearchExhaustionUnchanged() + { + McpServerTool tool = CreateStructuredOutputTestTool( + Enumerable.Range(0, 129).Select(_ => new { Tag = 1, A = (string?)null, B = (string?)null }).ToArray(), + """ + { + "contains":{ + "if":{"properties":{"tag":{"const":0}}}, + "then":{"required":["a"],"not":{"required":["b"]}}, + "else":{"required":["b"],"not":{"required":["a"]}} + }, + "minContains":64, + "maxContains":64 + } + """); + + JsonElement result = await InvokeStructuredOutputTestTool(tool); + + // Exact-count subset search remains bounded; never emit an invalid partial repair. + Assert.Equal(129, result.GetArrayLength()); + Assert.All(result.EnumerateArray(), item => + { + Assert.Equal(1, item.GetProperty("tag").GetInt32()); + Assert.False(item.TryGetProperty("a", out _)); + Assert.False(item.TryGetProperty("b", out _)); + }); + } + + [Fact] + public async Task StructuredOutput_ConditionalRepair_ComposesSiblingChoicesBeforeQualifyingAncestor() + { + McpServerTool tool = CreateStructuredOutputTestTool( + Enumerable.Range(0, 129).Select(_ => new + { + Tag = 1, + Left = new { A0 = (string?)null, B0 = (string?)null, A1 = (string?)null, B1 = (string?)null }, + Right = new { A0 = (string?)null, B0 = (string?)null, A1 = (string?)null, B1 = (string?)null }, + }).ToArray(), + """ + { + "items":{"oneOf":[ + {"properties":{"tag":{"const":0},"left":{"$ref":"#/$defs/zero"},"right":{"$ref":"#/$defs/zero"}},"required":["tag"]}, + {"properties":{"tag":{"const":1},"left":{"$ref":"#/$defs/one"},"right":{"$ref":"#/$defs/one"}},"required":["tag"]} + ]}, + "$defs":{ + "zero":{"oneOf":[{"required":["a0"]},{"required":["b0"]}]}, + "one":{"oneOf":[{"required":["a1"]},{"required":["b1"]}]} + } + } + """); + + JsonElement result = await InvokeStructuredOutputTestTool(tool); + + AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result); + Assert.All(result.EnumerateArray(), item => + { + foreach (string name in new[] { "left", "right" }) + { + JsonElement child = item.GetProperty(name); + Assert.False(child.TryGetProperty("a0", out _)); + Assert.False(child.TryGetProperty("b0", out _)); + Assert.NotEqual(child.TryGetProperty("a1", out _), child.TryGetProperty("b1", out _)); + } + }); + } + + [Theory] + [InlineData("""{"properties":{"tag":{"const":1}}}""")] + [InlineData("""{"type":"object","properties":{"tag":{"const":1}},"required":["marker","items"]}""")] + [InlineData("""{"properties":{"tag":{"const":1}},"minProperties":3}""")] + [InlineData("""{"properties":{"tag":{"const":1}},"maxProperties":3}""")] + public async Task StructuredOutput_ConditionalRepair_ValidatesAncestorGuardWithoutLosingRequiredProperties(string guard) + { + McpServerTool tool = CreateStructuredOutputTestTool( + new + { + Tag = 1, + Marker = "present", + Items = Enumerable.Range(0, 1024).Select(_ => new { A = (string?)null, B = (string?)null }).ToArray(), + }, + """ + { + "if":GUARD, + "then":{"properties":{"items":{"items":{"oneOf":[ + {"required":["a"],"not":{"required":["b"]}}, + {"required":["absent"]} + ]}}}}, + "else":{"properties":{"items":{"items":{"required":["b"],"not":{"required":["a"]}}}}} + } + """.Replace("GUARD", guard)); + + JsonElement result = await InvokeStructuredOutputTestTool(tool); + + AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result); + Assert.All(result.GetProperty("items").EnumerateArray(), item => + { + Assert.True(item.TryGetProperty("a", out _)); + Assert.False(item.TryGetProperty("b", out _)); + }); + } + + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public async Task StructuredOutput_ConditionalRepair_QualifiesAncestorChoices(bool omitTag, bool nestedChild) + { + int tag = omitTag ? 0 : 1; + var items = Enumerable.Range(0, 129).Select(_ => new + { + Tag = tag, + A0 = (string?)null, + B0 = (string?)null, + A1 = (string?)null, + B1 = (string?)null, + Child = new { A0 = (string?)null, B0 = (string?)null, A1 = (string?)null, B1 = (string?)null }, + }).ToArray(); + JsonObject CreateBranch(int branchTag, string first, string second) + { + JsonArray alternatives = [ + new JsonObject { ["required"] = new JsonArray(first) }, + new JsonObject { ["required"] = new JsonArray(second) }, + ]; + JsonObject properties = new() { ["tag"] = new JsonObject { ["const"] = branchTag } }; + JsonObject branch = new() { ["properties"] = properties, ["required"] = new JsonArray("tag") }; + if (nestedChild) + { + properties["child"] = new JsonObject { ["oneOf"] = alternatives }; + } + else + { + branch["oneOf"] = alternatives; + } + + return branch; + } + + JsonObject schema = new() + { + ["items"] = new JsonObject + { + ["oneOf"] = new JsonArray(CreateBranch(1 - tag, "a0", "b0"), CreateBranch(tag, "a1", "b1")), + }, + }; + McpServerTool tool = CreateStructuredOutputTestTool( + items, schema.ToJsonString(), CreateStructuredOutputTestOptions( + omitTag ? JsonIgnoreCondition.WhenWritingDefault : JsonIgnoreCondition.WhenWritingNull)); + + JsonElement result = await InvokeStructuredOutputTestTool(tool); + + AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result); + Assert.All(result.EnumerateArray(), item => + { + Assert.Equal(tag, item.GetProperty("tag").GetInt32()); + JsonElement value = nestedChild ? item.GetProperty("child") : item; + Assert.False(value.TryGetProperty("a0", out _)); + Assert.False(value.TryGetProperty("b0", out _)); + Assert.NotEqual(value.TryGetProperty("a1", out _), value.TryGetProperty("b1", out _)); + }); + } + + [Fact] + public async Task StructuredOutput_ConditionalRepair_ReevaluatesInsideOneOfAfterRequiredRepair() + { + McpServerTool tool = CreateStructuredOutputTestTool( + Enumerable.Range(0, 129).Select(_ => new { Tag = 0, A = (string?)null, B = (string?)null }).ToArray(), + """ + { + "items":{"oneOf":[ + { + "required":["tag"], + "if":{"properties":{"tag":{"const":1}}}, + "then":{"required":["a"],"not":{"required":["b"]}}, + "else":{"required":["b"],"not":{"required":["a"]}} + }, + {"required":["absent"]} + ]} + } + """, CreateStructuredOutputTestOptions(JsonIgnoreCondition.WhenWritingDefault)); + JsonElement result = await InvokeStructuredOutputTestTool(tool); + AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result); + } + + [Fact] + public async Task StructuredOutput_ConditionalRepair_PreservesGuardsDuringRecursiveRepair() + { + McpServerTool tool = CreateStructuredOutputTestTool( + Enumerable.Range(0, 129).Select(_ => new { Child = default(ConditionalRepairChild) }).ToArray(), + """ + { + "items":{ + "if":{"properties":{"child":{"required":["b"]}}}, + "then":{ + "required":["child"], + "properties":{"child":{"oneOf":[ + {"required":["a"],"not":{"required":["b"]}}, + {"required":["b"],"not":{"required":["a"]}} + ]}} + }, + "else":false + } + } + """, CreateStructuredOutputTestOptions(JsonIgnoreCondition.WhenWritingDefault)); + + JsonElement result = await InvokeStructuredOutputTestTool(tool); + + AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result); + Assert.All(result.EnumerateArray(), item => + { + Assert.True(item.GetProperty("child").TryGetProperty("b", out _)); + Assert.False(item.GetProperty("child").TryGetProperty("a", out _)); + }); + } + + [Theory] + [InlineData(129, false, false)] + [InlineData(129, false, true)] + [InlineData(1024, false, true)] + [InlineData(1024, true, true)] + public async Task StructuredOutput_ConditionalRepair_ComposesOnlyMatchingBranches(int count, bool dictionary, bool mixed) + { + var items = Enumerable.Range(0, count) + .Select(index => new { Tag = mixed ? index % 2 : 1, A = (string?)null, B = (string?)null }).ToArray(); + object output = dictionary + ? items.Select((item, index) => (item, index)).ToDictionary(pair => $"item/~1/{pair.index}~", pair => pair.item) + : items; + string container = dictionary ? "\"additionalProperties\"" : "\"items\""; + McpServerTool tool = CreateStructuredOutputTestTool(output, $$$""" + { + {{{container}}}:{ + "if":{"properties":{"tag":{"const":0} } }, + "then":{"required":["a"],"not":{"required":["b"]}}, + "else":{"required":["b"],"not":{"required":["a"]}} + } + } + """); + + JsonElement result = await InvokeStructuredOutputTestTool(tool); + + AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result); + IEnumerable repaired = dictionary + ? result.EnumerateObject().Select(property => property.Value) + : result.EnumerateArray(); + Assert.Equal(count, repaired.Count()); + foreach (JsonElement item in repaired) + { + bool zero = item.GetProperty("tag").GetInt32() == 0; + Assert.Equal(JsonValueKind.Null, item.GetProperty(zero ? "a" : "b").ValueKind); + Assert.False(item.TryGetProperty(zero ? "b" : "a", out _)); + } + } + + [Fact] + public async Task StructuredOutput_ConditionalRepair_ReevaluatesAfterRequiredRepair() + { + McpServerTool tool = CreateStructuredOutputTestTool( + Enumerable.Range(0, 129).Select(_ => new { Tag = 0, A = (string?)null, B = (string?)null }).ToArray(), + """ + { + "items":{ + "required":["tag"], + "if":{"properties":{"tag":{"const":1}}}, + "then":{"required":["a"],"not":{"required":["b"]}}, + "else":{"required":["b"],"not":{"required":["a"]}} + } + } + """, CreateStructuredOutputTestOptions(JsonIgnoreCondition.WhenWritingDefault)); + + JsonElement result = await InvokeStructuredOutputTestTool(tool); + + AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result); + Assert.All(result.EnumerateArray(), item => Assert.True(item.TryGetProperty("b", out _))); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task StructuredOutput_ConditionalRepair_PreservesAncestorGuardsThroughOneOf(bool conditionalInsideOneOf) + { + const string conditional = """ + { + "if":{"properties":{"tag":{"const":0}}}, + "then":{"properties":{"child":{"oneOf":[{"$ref":"#/$defs/a"},{"required":["absent"]}]}}}, + "else":{"properties":{"child":{"oneOf":[{"$ref":"#/$defs/b"},{"required":["absent"]}]}}} + } + """; + string entrySchema = conditionalInsideOneOf + ? $$"""{"oneOf":[{"allOf":[{{conditional}}],"required":["tag"]},{"required":["absent"]}]}""" + : conditional; + McpServerTool tool = CreateStructuredOutputTestTool( + Enumerable.Range(0, 129).Select(_ => new { Tag = 1, Child = new { A = (string?)null, B = (string?)null } }).ToArray(), + $$$""" + { + "items":{{{entrySchema}}}, + "$defs":{ + "a":{"required":["a"],"not":{"required":["b"]}}, + "b":{"required":["b"],"not":{"required":["a"]}} + } + } + """); + + JsonElement result = await InvokeStructuredOutputTestTool(tool); + + AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result); + Assert.All(result.EnumerateArray(), item => + { + Assert.True(item.GetProperty("child").TryGetProperty("b", out _)); + Assert.False(item.GetProperty("child").TryGetProperty("a", out _)); + }); + } + + [Fact] + public async Task StructuredOutput_ConditionalRepair_KeepsIndependentConditionsAtSamePath() + { + McpServerTool tool = CreateStructuredOutputTestTool( + Enumerable.Range(0, 129).Select(_ => new { Tag = 1, A = (string?)null, B = (string?)null, C = (string?)null }).ToArray(), + """ + { + "items":{"allOf":[ + {"if":{"properties":{"tag":{"const":0}}},"then":{"$ref":"#/$defs/a"},"else":{"$ref":"#/$defs/b"}}, + {"if":{"properties":{"tag":{"const":1}}},"then":{"required":["c"]},"else":{"$ref":"#/$defs/a"}} + ]}, + "$defs":{"a":{"required":["a"],"not":{"required":["b"]}},"b":{"required":["b"],"not":{"required":["a"]}}} + } + """); + + JsonElement result = await InvokeStructuredOutputTestTool(tool); + + AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result); + Assert.All(result.EnumerateArray(), item => + { + Assert.True(item.TryGetProperty("b", out _)); + Assert.True(item.TryGetProperty("c", out _)); + Assert.False(item.TryGetProperty("a", out _)); + }); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task StructuredOutput_ConditionalRepair_UsesCallerReferenceScope(bool dynamicReference) + { + string condition = dynamicReference ? """{"$dynamicRef":"#guard"}""" : """{"$ref":"#/$defs/guard"}"""; + string anchor = dynamicReference ? "\"$dynamicAnchor\":\"guard\"," : ""; + int localTag = dynamicReference ? 1 : 0; + McpServerTool tool = CreateStructuredOutputTestTool( + Enumerable.Range(0, 129).Select(_ => new { Tag = 1, A = (string?)null, B = (string?)null }).ToArray(), + $$$$""" + { + "$id":"https://example.test/conditional/root", + "$defs":{ + "guard":{ {{{{anchor}}}} "properties":{"tag":{"const":0} } }, + "entry":{ + "$id":"entry", + "$defs":{"guard":{ {{{{anchor}}}} "properties":{"tag":{"const":{{{{localTag}}}} } } } }, + "if":{{{{condition}}}}, + "then":{"required":["a"],"not":{"required":["b"]}}, + "else":{"required":["b"],"not":{"required":["a"]}} + } + }, + "items":{"$ref":"entry"} + } + """); + + JsonElement result = await InvokeStructuredOutputTestTool(tool); + + AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result); + Assert.All(result.EnumerateArray(), item => Assert.True(item.TryGetProperty("b", out _))); + } + + private struct ConditionalRepairChild + { + public string? A { get; set; } + + public string? B { get; set; } + } +} diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerToolRecoveryBoundaryTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerToolRecoveryBoundaryTests.cs new file mode 100644 index 000000000..d0f1d83eb --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Server/McpServerToolRecoveryBoundaryTests.cs @@ -0,0 +1,1074 @@ +using System.Collections; +using System.Reflection; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; +using Microsoft.Extensions.AI; +using ModelContextProtocol.Server; +using ModelContextProtocol.Tests.Utils; + +namespace ModelContextProtocol.Tests.Server; + +public partial class McpServerToolTests +{ + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task StructuredOutput_RecoveryBoundary_PrunesUnevaluatedRepairsAtControlCharacterPaths(bool sourceGenerated) + { + JsonSerializerOptions options = CreateStructuredOutputTestOptions(); + if (sourceGenerated) + { + options.TypeInfoResolver = RecoveryBoundaryJsonContext.Default; + } + McpServerTool tool = CreateRecoveryBoundaryTool( + new Dictionary + { + ["evaluated\0path"] = new(), + ["other"] = new(), + }, """{"allOf":[{"properties":{"evaluated\u0000path":{"required":["real"]}}}],"unevaluatedProperties":{"required":["note"]}}""", options); + + JsonElement result = await InvokeStructuredOutputTestTool(tool); + + Assert.Equal(JsonValueKind.Null, result.GetProperty("evaluated\0path").GetProperty("real").ValueKind); + Assert.False(result.GetProperty("evaluated\0path").TryGetProperty("note", out _)); + Assert.Equal(JsonValueKind.Null, result.GetProperty("other").GetProperty("note").ValueKind); + AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result); + } + + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public async Task StructuredOutput_RecoveryBoundary_DoesNotTraverseHiddenExtensionDataCollisions(bool sourceGenerated, bool defaultIgnored) + { + JsonSerializerOptions options = CreateStructuredOutputTestOptions(JsonIgnoreCondition.WhenWritingDefault); + IJsonTypeInfoResolver resolver = sourceGenerated ? RecoveryBoundaryJsonContext.Default : new DefaultJsonTypeInfoResolver(); + options.TypeInfoResolver = resolver.WithAddedModifier(info => + { + if (info.Type == typeof(RecoveryBoundaryFilteredParent) && !defaultIgnored) + { + info.Properties.Single(property => property.Name == "child").ShouldSerialize = static (_, _) => false; + } + }); + object output = defaultIgnored + ? new RecoveryBoundaryDefaultParent { Child = new() { Count = 42 } } + : new RecoveryBoundaryFilteredParent(); + McpServerTool tool = CreateRecoveryBoundaryTool(output, """ + {"properties":{"child":{"required":["count"],"properties":{"count":{"type":"integer"}}}}} + """, options); + + JsonElement result = await InvokeStructuredOutputTestTool(tool); + + Assert.Empty(result.GetProperty("child").EnumerateObject()); + } + + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public async Task StructuredOutput_RecoveryBoundary_SupportsCachedReadonlyMetadata(bool sourceGenerated, bool record) + { + JsonSerializerOptions options = CreateStructuredOutputTestOptions(); + RecoveryBoundaryCachingResolver resolver = new( + sourceGenerated ? RecoveryBoundaryJsonContext.Default : new DefaultJsonTypeInfoResolver()); + options.TypeInfoResolver = resolver; + object output = record ? new RecoveryBoundaryNullableRecord() : new RecoveryBoundaryNullableOutput(); + McpServerTool tool = CreateStructuredOutputTestTool(output, """{"required":["note"]}""", options); + JsonTypeInfo original = options.GetTypeInfo(output.GetType()); + Func? originalGetter = original.Properties.Single().Get; + + JsonElement result = await InvokeStructuredOutputTestTool(tool); + + Assert.Equal(JsonValueKind.Null, result.GetProperty("note").ValueKind); + Assert.True(original.IsReadOnly); + Assert.Same(original, resolver.GetTypeInfo(original.Type, options)); + Assert.Same(originalGetter, original.Properties.Single().Get); + AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task StructuredOutput_RecoveryBoundary_DoesNotRebuildWideContracts(bool cacheMetadata) + { + const int width = 32; + JsonSerializerOptions options = CreateStructuredOutputTestOptions(JsonIgnoreCondition.WhenWritingDefault); + int ownerResolutions = 0; + IJsonTypeInfoResolver resolver = new DefaultJsonTypeInfoResolver().WithAddedModifier(typeInfo => + { + if (typeInfo.Type == typeof(RecoveryBoundaryValue)) + { + Interlocked.Increment(ref ownerResolutions); + typeInfo.Properties.Clear(); + for (int i = 0; i < width; i++) + { + JsonPropertyInfo property = typeInfo.CreateJsonPropertyInfo(typeof(int), $"count{i}"); + property.Get = static value => ((RecoveryBoundaryValue)value).Count; + typeInfo.Properties.Add(property); + } + } + }); + options.TypeInfoResolver = cacheMetadata ? new RecoveryBoundaryCachingResolver(resolver) : resolver; + string required = string.Join(",", Enumerable.Range(0, width).Select(i => $"\"count{i}\"")); + McpServerTool tool = CreateStructuredOutputTestTool( + new RecoveryBoundaryValue[8], $$$$"""{"items":{"required":[{{{{required}}}}]}}""", options); + options.MakeReadOnly(); + JsonSerializer.SerializeToElement(new RecoveryBoundaryValue[8], options.GetTypeInfo(typeof(RecoveryBoundaryValue[]))); + int resolutionsBeforeRecovery = ownerResolutions; + + for (int invocation = 0; invocation < 2; invocation++) + { + JsonElement result = await InvokeStructuredOutputTestTool(tool); + + Assert.All(result.EnumerateArray(), item => + { + Assert.Equal(width, item.EnumerateObject().Count()); + Assert.All(item.EnumerateObject(), property => Assert.Equal(0, property.Value.GetInt32())); + }); + AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result); + } + + Assert.Equal(resolutionsBeforeRecovery, ownerResolutions); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task StructuredOutput_RecoveryBoundary_KeepsConcurrentPlanValuesSeparate(bool nested) + { + using Barrier barrier = new(2); + JsonSerializerOptions options = CreateStructuredOutputTestOptions(JsonIgnoreCondition.WhenWritingDefault); + options.ReferenceHandler = nested ? ReferenceHandler.IgnoreCycles : null; + options.TypeInfoResolver = new RecoveryBoundaryCachingResolver(new DefaultJsonTypeInfoResolver()); + options.Converters.Add(new RecoveryBoundaryInvocationConverter(barrier, TestContext.Current.CancellationToken)); + McpServerTool[] tools = [CreateTool(1), CreateTool(2)]; + + JsonElement[] results = await Task.WhenAll(tools.Select(tool => + Task.Run(() => InvokeStructuredOutputTestTool(tool), TestContext.Current.CancellationToken))); + + Assert.Equal(1, (nested ? results[0].GetProperty("child") : results[0]).GetProperty("value").GetInt32()); + Assert.Equal(2, (nested ? results[1].GetProperty("child") : results[1]).GetProperty("value").GetInt32()); + + McpServerTool CreateTool(int value) + { + RecoveryBoundaryInvocationOutput output = new() { Value = new(value) }; + return nested + ? CreateRecoveryBoundaryTool(new RecoveryBoundaryAncestorRoot { Child = output }, + """{"properties":{"child":{"required":["value"]}}}""", options) + : CreateStructuredOutputTestTool(output, """{"required":["value"]}""", options); + } + } + + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public async Task StructuredOutput_RecoveryBoundary_PreservesOwnerCycleScope(bool valueTypeOwner, bool sourceGenerated) + { + JsonSerializerOptions options = CreateStructuredOutputTestOptions(JsonIgnoreCondition.Never); + options.ReferenceHandler = ReferenceHandler.IgnoreCycles; + options.TypeInfoResolver = new RecoveryBoundaryCachingResolver( + sourceGenerated ? RecoveryBoundaryJsonContext.Default : new DefaultJsonTypeInfoResolver()); + object output = valueTypeOwner ? new RecoveryBoundaryCyclicStructOwner() : new RecoveryBoundaryCyclicOwner(); + RecoveryBoundaryCyclicValue value = new() { Owner = output }; + if (output is RecoveryBoundaryCyclicOwner referenceOwner) + { + referenceOwner.Missing = value; + } + else + { + typeof(RecoveryBoundaryCyclicStructOwner).GetProperty(nameof(RecoveryBoundaryCyclicStructOwner.Missing))!.SetValue(output, value); + } + McpServerTool tool = CreateRecoveryBoundaryTool(output, """{"required":["missing"]}""", options); + + JsonElement result = await InvokeStructuredOutputTestTool(tool); + + JsonElement owner = result.GetProperty("missing").GetProperty("owner"); + if (valueTypeOwner) + { + Assert.Equal(JsonValueKind.Object, owner.ValueKind); + Assert.Empty(owner.EnumerateObject()); + } + else + { + Assert.Equal(JsonValueKind.Null, owner.ValueKind); + } + } + + public static IEnumerable AncestorCycleCases() + { + foreach (string shape in new[] { "object", "nestedObject", "struct", "array", "arrayContainer", "dictionary", "dictionaryContainer", "rootArray", "rootDictionary", "extension" }) + { + foreach (bool strict in new[] { false, true }) + { + foreach (bool sourceGenerated in new[] { false, true }) + { + yield return [shape, strict, sourceGenerated]; + } + } + } + } + + [Theory] + [MemberData(nameof(AncestorCycleCases))] + public async Task StructuredOutput_RecoveryBoundary_PreservesAncestorCycleScope(string shape, bool strict, bool sourceGenerated) + { + JsonSerializerOptions options = CreateStructuredOutputTestOptions(JsonIgnoreCondition.Never); + options.ReferenceHandler = ReferenceHandler.IgnoreCycles; + options.TypeInfoResolver = new RecoveryBoundaryCachingResolver( + sourceGenerated ? RecoveryBoundaryJsonContext.Default : new DefaultJsonTypeInfoResolver()); + RecoveryBoundaryAncestorRoot root = new(); + RecoveryBoundaryCyclicOwner first = new(), second = new(); + RecoveryBoundaryCyclicOwner[] array = [first, second]; + Dictionary dictionary = new() + { + ["first/~"] = first, + ["second"] = second, + }; + object output = root; + object cycleTarget = root; + root.Child = shape switch + { + "nestedObject" => new RecoveryBoundaryAncestorRoot { Child = first }, + "struct" => new RecoveryBoundaryStructAncestor { Child = first }, + "array" or "arrayContainer" => array, + "dictionary" or "dictionaryContainer" => dictionary, + "extension" => new RecoveryBoundaryAncestorExtension { Data = new() { ["entry/~"] = first } }, + _ => first, + }; + if (shape is "arrayContainer" or "dictionaryContainer") + { + cycleTarget = root.Child!; + } + else if (shape is "rootArray" or "rootDictionary") + { + output = cycleTarget = shape == "rootArray" ? array : dictionary; + } + first.Missing = second.Missing = new() { Owner = cycleTarget }; + string ownerSchema = strict ? """{"type":"null"}""" : "{}"; + string itemSchema = $$$$""" + {"type":"object","required":["missing"],"properties":{"missing":{ + "type":"object","required":["owner"],"properties":{"owner":{{{{ownerSchema}}}}} + }}} + """; + string schema = (shape switch + { + "nestedObject" or "struct" => """{"properties":{"child":{"properties":{"child":ITEM}}}}""", + "array" or "arrayContainer" => """{"properties":{"child":{"items":ITEM}}}""", + "dictionary" or "dictionaryContainer" => """{"properties":{"child":{"additionalProperties":ITEM}}}""", + "rootArray" => """{"items":ITEM}""", + "rootDictionary" => """{"additionalProperties":ITEM}""", + "extension" => """{"properties":{"child":{"properties":{"entry/~":ITEM}}}}""", + _ => """{"properties":{"child":ITEM}}""", + }).Replace("ITEM", itemSchema); + McpServerTool tool = CreateRecoveryBoundaryTool(output, schema, options); + + JsonElement result = await InvokeStructuredOutputTestTool(tool); + + JsonElement child = shape switch + { + "nestedObject" or "struct" => result.GetProperty("child").GetProperty("child"), + "rootArray" or "rootDictionary" => result, + "extension" => result.GetProperty("child").GetProperty("entry/~"), + _ => result.GetProperty("child"), + }; + if (shape is "array" or "arrayContainer" or "rootArray") + { + Assert.All(child.EnumerateArray(), AssertNullOwner); + } + else if (shape is "dictionary" or "dictionaryContainer" or "rootDictionary") + { + Assert.All(child.EnumerateObject(), property => AssertNullOwner(property.Value)); + } + else + { + AssertNullOwner(child); + } + AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result); + if (ReferenceEquals(root, output)) + { + Assert.Equal(2, root.SerializingCalls); + Assert.Equal(2, root.GetterCalls); + } + + static void AssertNullOwner(JsonElement item) => + Assert.Equal(JsonValueKind.Null, item.GetProperty("missing").GetProperty("owner").ValueKind); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task StructuredOutput_RecoveryBoundary_DoesNotTreatNonAncestorAsCycle(bool sourceGenerated) + { + JsonSerializerOptions options = CreateStructuredOutputTestOptions(JsonIgnoreCondition.Never); + options.ReferenceHandler = ReferenceHandler.IgnoreCycles; + options.TypeInfoResolver = new RecoveryBoundaryCachingResolver( + sourceGenerated ? RecoveryBoundaryJsonContext.Default : new DefaultJsonTypeInfoResolver()); + RecoveryBoundaryCyclicOwner child = new() + { + Missing = new() { Owner = new RecoveryBoundaryNullableOutput { Note = "unrelated" } }, + }; + McpServerTool tool = CreateRecoveryBoundaryTool(new RecoveryBoundaryAncestorRoot { Child = child }, """ + {"properties":{"child":{"required":["missing"],"properties":{"missing":{ + "required":["owner"],"properties":{"owner":{ + "type":"object","required":["note"],"properties":{"note":{"const":"unrelated"}} + }} + }}}}} + """, options); + + JsonElement result = await InvokeStructuredOutputTestTool(tool); + + Assert.Equal("unrelated", result.GetProperty("child").GetProperty("missing").GetProperty("owner").GetProperty("note").GetString()); + AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task StructuredOutput_RecoveryBoundary_DoesNotTrackValueTypeAncestorBoxes(bool sourceGenerated) + { + JsonSerializerOptions options = CreateStructuredOutputTestOptions(JsonIgnoreCondition.Never); + options.ReferenceHandler = ReferenceHandler.IgnoreCycles; + options.TypeInfoResolver = new RecoveryBoundaryCachingResolver( + sourceGenerated ? RecoveryBoundaryJsonContext.Default : new DefaultJsonTypeInfoResolver()); + RecoveryBoundaryCyclicOwner child = new(); + object boxedAncestor = new RecoveryBoundaryStructAncestor { Child = child }; + child.Missing = new() { Owner = boxedAncestor }; + McpServerTool tool = CreateRecoveryBoundaryTool(new RecoveryBoundaryAncestorRoot { Child = boxedAncestor }, """ + {"properties":{"child":{"properties":{"child":{"required":["missing"]}}}}} + """, options); + + JsonElement result = await InvokeStructuredOutputTestTool(tool); + + JsonElement owner = result.GetProperty("child").GetProperty("child").GetProperty("missing").GetProperty("owner"); + Assert.Equal(JsonValueKind.Object, owner.ValueKind); + Assert.Equal(JsonValueKind.Null, owner.GetProperty("child").ValueKind); + } + + [Theory] + [InlineData(false, false, false)] + [InlineData(false, true, false)] + [InlineData(true, false, false)] + [InlineData(true, true, false)] + [InlineData(false, false, true)] + [InlineData(false, true, true)] + [InlineData(true, false, true)] + [InlineData(true, true, true)] + public async Task StructuredOutput_RecoveryBoundary_PreservesMemberConverterScope(bool factory, bool sourceGenerated, bool globalConverter) + { + RecoveryBoundaryDelegatingConverter converter = new(); + JsonSerializerOptions options = CreateStructuredOutputTestOptions(JsonIgnoreCondition.WhenWritingDefault); + if (globalConverter) + { + options.Converters.Insert(0, new RecoveryBoundaryGlobalIntConverter()); + } + + IJsonTypeInfoResolver resolver = sourceGenerated ? RecoveryBoundaryJsonContext.Default : new DefaultJsonTypeInfoResolver(); + options.TypeInfoResolver = resolver.WithAddedModifier(typeInfo => + { + if (typeInfo.Type == typeof(RecoveryBoundaryConvertedOutput)) + { + foreach (JsonPropertyInfo property in typeInfo.Properties) + { + property.CustomConverter = factory ? new RecoveryBoundaryConverterFactory(converter) : converter; + } + } + }); + McpServerTool tool = CreateStructuredOutputTestTool( + new RecoveryBoundaryConvertedOutput(), + globalConverter + ? """{"required":["count"],"properties":{"count":{"const":"global-0"},"existing":{"const":"global-5"}}}""" + : """{"required":["count"],"properties":{"count":{"const":0},"existing":{"const":5}}}""", options); + + JsonElement result = await InvokeStructuredOutputTestTool(tool); + + Assert.False(converter.RecursionDetected); + Assert.False(converter.OptionsChanged); + if (globalConverter) + { + Assert.Equal("global-0", result.GetProperty("count").GetString()); + Assert.Equal("global-5", result.GetProperty("existing").GetString()); + } + else + { + Assert.Equal(0, result.GetProperty("count").GetInt32()); + Assert.Equal(5, result.GetProperty("existing").GetInt32()); + } + AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result); + } + + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public async Task StructuredOutput_RecoveryBoundary_UsesSerializedDictionaryKeys(bool preserveReferences, bool reorder) + { + JsonSerializerOptions options = CreateStructuredOutputTestOptions(JsonIgnoreCondition.WhenWritingDefault); + options.DictionaryKeyPolicy = JsonNamingPolicy.CamelCase; + if (preserveReferences) + { + options.ReferenceHandler = ReferenceHandler.Preserve; + } + + RecoveryBoundaryDictionary output = new(reorder); + McpServerTool tool = CreateRecoveryBoundaryTool(output, """ + { + "required":["first/~","second"], + "properties":{ + "first/~":{"required":["count"],"properties":{"count":{"type":"integer"}}}, + "second":{"required":["count"],"properties":{"count":{"type":"integer"}}} + } + } + """, options); + + JsonElement result = await InvokeStructuredOutputTestTool(tool); + + Assert.Equal(0, result.GetProperty("first/~").GetProperty("count").GetInt32()); + Assert.Equal(5, result.GetProperty("second").GetProperty("count").GetInt32()); + AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result); + } + + [Fact] + public async Task StructuredOutput_RecoveryBoundary_UsesDictionaryKeyConverter() + { + JsonSerializerOptions options = CreateStructuredOutputTestOptions(JsonIgnoreCondition.WhenWritingDefault); + options.ReferenceHandler = ReferenceHandler.Preserve; + options.Converters.Insert(0, new RecoveryBoundaryKeyConverter()); + McpServerTool tool = CreateRecoveryBoundaryTool( + new Dictionary + { + [new(1)] = default, + [new(2)] = new() { Count = 5 }, + }, + """{"properties":{"key-1":{"required":["count"]},"key-2":{"required":["count"]}}}""", options); + + JsonElement result = await InvokeStructuredOutputTestTool(tool); + + Assert.Equal(0, result.GetProperty("key-1").GetProperty("count").GetInt32()); + Assert.Equal(5, result.GetProperty("key-2").GetProperty("count").GetInt32()); + AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result); + } + + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public async Task StructuredOutput_RecoveryBoundary_UsesNongenericDictionaryKeyDispatch(bool customKey, bool preserveReferences) + { + JsonSerializerOptions options = CreateStructuredOutputTestOptions(JsonIgnoreCondition.WhenWritingDefault); + options.Converters.Insert(0, new RecoveryBoundaryKeyConverter()); + if (preserveReferences) + { + options.ReferenceHandler = ReferenceHandler.Preserve; + } + + Hashtable output = new() + { + [customKey ? new RecoveryBoundaryKey(1) : (object)1] = default(RecoveryBoundaryValue), + [customKey ? new RecoveryBoundaryKey(2) : (object)2] = new RecoveryBoundaryValue { Count = 5 }, + }; + string first = customKey ? "key-1" : "1"; + string second = customKey ? "key-2" : "2"; + McpServerTool tool = CreateRecoveryBoundaryTool(output, $$$$""" + {"properties":{"{{{{first}}}}":{"required":["count"]},"{{{{second}}}}":{"required":["count"]}}} + """, options); + + JsonElement result = await InvokeStructuredOutputTestTool(tool); + + Assert.Equal(0, result.GetProperty(first).GetProperty("count").GetInt32()); + Assert.Equal(5, result.GetProperty(second).GetProperty("count").GetInt32()); + AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result); + } + + [Theory] + [InlineData("root")] + [InlineData("object")] + [InlineData("array")] + public async Task StructuredOutput_RecoveryBoundary_PreservesOutputWithCollidingDictionaryKeys(string shape) + { + JsonSerializerOptions options = CreateStructuredOutputTestOptions(JsonIgnoreCondition.WhenWritingDefault); + options.DictionaryKeyPolicy = JsonNamingPolicy.CamelCase; + Dictionary dictionary = new() + { + ["First"] = default, + ["first"] = new() { Count = 5 }, + }; + object output = shape switch + { + "object" => new { Child = dictionary }, + "array" => new[] { dictionary }, + _ => dictionary, + }; + string schema = shape switch + { + "object" => """{"properties":{"child":{"required":["unavailable"]}}}""", + "array" => """{"items":{"required":["unavailable"]}}""", + _ => """{"required":["unavailable"]}""", + }; + McpServerTool tool = CreateRecoveryBoundaryTool(output, schema, options); + + JsonElement result = await InvokeStructuredOutputTestTool(tool); + result = shape switch + { + "object" => result.GetProperty("child"), + "array" => result[0], + _ => result, + }; + + Assert.Equal(2, result.EnumerateObject().Count()); + Assert.All(result.EnumerateObject(), property => Assert.Equal("first", property.Name)); + } + + [Theory] + [InlineData("original", false)] + [InlineData("original", true)] + [InlineData("missing", false)] + [InlineData("missing", true)] + [InlineData("wrapped", false)] + [InlineData("wrapped", true)] + public async Task StructuredOutput_RecoveryBoundary_DoesNotRecoverExcludedReadonlyField(string provider, bool sourceGenerated) + { + JsonSerializerOptions options = CreateStructuredOutputTestOptions(JsonIgnoreCondition.WhenWritingDefault); + options.IncludeFields = true; + options.IgnoreReadOnlyFields = true; + IJsonTypeInfoResolver resolver = sourceGenerated ? RecoveryBoundaryJsonContext.Default : new DefaultJsonTypeInfoResolver(); + options.TypeInfoResolver = resolver.WithAddedModifier(typeInfo => + { + if (provider != "original") + { + foreach (JsonPropertyInfo property in typeInfo.Properties) + { + property.AttributeProvider = provider == "missing" ? null : new RecoveryBoundaryAttributeProvider(); + } + } + }); + McpServerTool tool = CreateStructuredOutputTestTool( + new RecoveryBoundaryReadonlyOutput(), """{"required":["count"]}""", options); + + JsonElement result = await InvokeStructuredOutputTestTool(tool); + + Assert.False(result.TryGetProperty("count", out _)); + } + + [Fact] + public async Task StructuredOutput_RecoveryBoundary_DoesNotAddMembersToReferenceObjects() + { + JsonSerializerOptions options = CreateStructuredOutputTestOptions(); + options.ReferenceHandler = ReferenceHandler.Preserve; + RecoveryBoundaryNullableOutput child = new(); + McpServerTool tool = CreateRecoveryBoundaryTool( + new { First = child, Second = child }, + """{"properties":{"first":{"required":["note"]},"second":{"required":["note"]}}}""", options); + + JsonElement result = await InvokeStructuredOutputTestTool(tool); + + JsonProperty reference = Assert.Single(result.GetProperty("second").EnumerateObject()); + Assert.Equal("$ref", reference.Name); + Assert.False(result.GetProperty("first").TryGetProperty("note", out _)); + } + + [Fact] + public async Task StructuredOutput_RecoveryBoundary_TraversesPreservedCollectionValues() + { + JsonSerializerOptions options = CreateStructuredOutputTestOptions(); + options.ReferenceHandler = ReferenceHandler.Preserve; + McpServerTool tool = CreateRecoveryBoundaryTool( + new List { new() }, + """{"properties":{"$values":{"items":{"required":["note"]}}}}""", options); + + JsonElement result = await InvokeStructuredOutputTestTool(tool); + + Assert.Equal(JsonValueKind.Null, result.GetProperty("$values")[0].GetProperty("note").ValueKind); + AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result); + } + + [Fact] + public async Task StructuredOutput_RecoveryBoundary_DoesNotIntroduceIndependentReferenceIds() + { + JsonSerializerOptions options = CreateStructuredOutputTestOptions(JsonIgnoreCondition.WhenWritingDefault); + options.ReferenceHandler = ReferenceHandler.Preserve; + McpServerTool tool = CreateRecoveryBoundaryTool( + new { Existing = new RecoveryBoundaryNullableOutput(), Missing = default(RecoveryBoundaryReferenceValue) }, + """{"required":["missing"]}""", options); + + JsonElement result = await InvokeStructuredOutputTestTool(tool); + + Assert.False(result.TryGetProperty("missing", out _)); + Assert.True(result.GetProperty("existing").TryGetProperty("$id", out _)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task StructuredOutput_RecoveryBoundary_DoesNotRepeatOwnerCallbacksOrUnrelatedGetters(bool sourceGenerated) + { + JsonSerializerOptions options = CreateStructuredOutputTestOptions(JsonIgnoreCondition.WhenWritingDefault); + if (sourceGenerated) + { + options.TypeInfoResolver = RecoveryBoundaryJsonContext.Default; + } + + RecoveryBoundaryCallbackOutput output = new(); + McpServerTool tool = CreateStructuredOutputTestTool(output, """{"required":["count"]}""", options); + + JsonElement result = await InvokeStructuredOutputTestTool(tool); + + Assert.Equal(0, result.GetProperty("count").GetInt32()); + Assert.Equal(2, output.SerializingCalls); + Assert.Equal(2, output.SerializedCalls); + Assert.Equal(2, output.GetterCalls); + } + + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public async Task StructuredOutput_RecoveryBoundary_RetainsOriginalWhenCallbackStateGetterFails( + bool sourceGenerated, bool predicate) + { + JsonSerializerOptions options = CreateStructuredOutputTestOptions(); + IJsonTypeInfoResolver resolver = sourceGenerated ? RecoveryBoundaryJsonContext.Default : new DefaultJsonTypeInfoResolver(); + options.TypeInfoResolver = resolver.WithAddedModifier(typeInfo => + { + if (predicate && typeInfo.Type == typeof(RecoveryBoundaryStatefulOutput)) + { + JsonPropertyInfo note = typeInfo.Properties.Single(property => property.Name == "note"); + note.Get = static _ => null; + note.ShouldSerialize = static (owner, _) => + owner is RecoveryBoundaryStatefulOutput output && output.SerializingCalls > output.SerializedCalls + ? false + : throw new InvalidOperationException("Predicate requires serialization callback state."); + } + }); + var output = new RecoveryBoundaryStatefulOutput(); + McpServerTool tool = CreateStructuredOutputTestTool(output, """{"required":["note"]}""", options); + + JsonElement result = await InvokeStructuredOutputTestTool(tool); + + Assert.Equal("""{"kept":"kept"}""", result.GetRawText()); + Assert.Equal(2, output.SerializingCalls); + Assert.Equal(2, output.SerializedCalls); + } + + [Fact] + public async Task StructuredOutput_RecoveryBoundary_RetainsOriginalWhenReflectedIndexerFails() + { + McpServerTool tool = CreateStructuredOutputTestTool( + new RecoveryBoundaryThrowingList(new InvalidOperationException("Indexer failed.")), + """{"items":{"required":["note"]}}"""); + + JsonElement result = await InvokeStructuredOutputTestTool(tool); + + Assert.Equal("[{}]", result.GetRawText()); + } + + [Fact] + public async Task StructuredOutput_RecoveryBoundary_RetainsOriginalForNestedAggregateFailure() + { + Exception failure = new InvalidOperationException("Recovery failed."); + for (int i = 0; i < 32; i++) + { + failure = new AggregateException(failure); + } + McpServerTool tool = CreateStructuredOutputTestTool( + new RecoveryBoundaryStatefulOutput { Failure = failure }, """{"required":["note"]}"""); + + JsonElement result = await InvokeStructuredOutputTestTool(tool); + + Assert.Equal("""{"kept":"kept"}""", result.GetRawText()); + } + + [Fact] + public async Task StructuredOutput_RecoveryBoundary_PropagatesUnboundedExceptionGraphs() + { + Exception failure = new InvalidOperationException("Recovery failed."); + for (int i = 0; i < 20; i++) + { + failure = new AggregateException(failure, failure); + } + McpServerTool tool = CreateStructuredOutputTestTool( + new RecoveryBoundaryStatefulOutput { Failure = failure }, """{"required":["note"]}"""); + + Assert.Same(failure, await Record.ExceptionAsync(() => InvokeStructuredOutputTestTool(tool))); + } + + [Theory] + [InlineData("cancel", false)] + [InlineData("cancel", true)] + [InlineData("memory", false)] + [InlineData("memory", true)] + [InlineData("stack", false)] + [InlineData("stack", true)] + [InlineData("access", false)] + [InlineData("access", true)] + [InlineData("aggregate", false)] + [InlineData("aggregate", true)] + [InlineData("wrapped-memory", false)] + [InlineData("wrapped-memory", true)] + public async Task StructuredOutput_RecoveryBoundary_DoesNotSuppressCancellationOrFatalGetterFailures(string kind, bool indexed) + { + Exception failure = kind switch + { + "cancel" => new OperationCanceledException(), + "memory" => new OutOfMemoryException(), + "stack" => new StackOverflowException(), + "access" => new AccessViolationException(), + "wrapped-memory" => new NotSupportedException("Wrapped fatal failure.", new OutOfMemoryException()), + _ => new AggregateException(new InvalidOperationException(), new OperationCanceledException()), + }; + object output = indexed + ? new RecoveryBoundaryThrowingList(failure) + : new RecoveryBoundaryStatefulOutput { Failure = failure }; + McpServerTool tool = CreateStructuredOutputTestTool(output, + indexed ? """{"items":{"required":["note"]}}""" : """{"required":["note"]}"""); + + Exception? actual = await Record.ExceptionAsync(() => InvokeStructuredOutputTestTool(tool)); + + Assert.Same(failure, actual); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task StructuredOutput_RecoveryBoundary_DoesNotSuppressOriginalSerializationFailure(bool sourceGenerated) + { + JsonSerializerOptions options = CreateStructuredOutputTestOptions(); + if (sourceGenerated) + { + options.TypeInfoResolver = RecoveryBoundaryJsonContext.Default; + } + var failure = new InvalidOperationException("Initial serialization failed."); + McpServerTool tool = CreateStructuredOutputTestTool( + new RecoveryBoundaryStatefulOutput { FailDuringSerialization = true, Failure = failure }, + """{"required":["note"]}""", options); + + Assert.Same(failure, await Record.ExceptionAsync(() => InvokeStructuredOutputTestTool(tool))); + } + + [Fact] + public async Task StructuredOutput_RecoveryBoundary_PreservesCompatibleSourceGeneratedContracts() + { + McpServerTool tool = CreateStructuredOutputTestTool( + new RecoveryBoundaryAttributedOutput(), + """{"required":["count"],"properties":{"count":{"const":0}}}""", + RecoveryBoundaryJsonContext.Default.Options); + + JsonElement result = await InvokeStructuredOutputTestTool(tool); + + Assert.Equal(0, result.GetProperty("count").GetInt32()); + AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result); + } + + private sealed class RecoveryBoundaryConvertedOutput + { + public int Existing { get; set; } = 5; + public int Count { get; set; } + } + + private sealed class RecoveryBoundaryControlNameOutput + { + public string? Real { get; set; } + public string? Note { get; set; } + } + + private sealed class RecoveryBoundaryFilteredParent + { + public RecoveryBoundaryConvertedOutput Child { get; set; } = new() { Count = 42 }; + [JsonExtensionData] + public Dictionary Data { get; set; } = new() { ["child"] = new Dictionary() }; + } + + private sealed class RecoveryBoundaryDefaultParent + { + public RecoveryBoundaryHiddenValue Child { get; set; } + [JsonExtensionData] + public Dictionary Data { get; set; } = new() { ["child"] = new Dictionary() }; + } + + private struct RecoveryBoundaryHiddenValue : IEquatable + { + public int Count { get; set; } + public bool Equals(RecoveryBoundaryHiddenValue other) => true; + public override bool Equals(object? obj) => obj is RecoveryBoundaryHiddenValue; + public override int GetHashCode() => 0; + } + + private sealed class RecoveryBoundaryDelegatingConverter : JsonConverter + { + private bool _writing; + private JsonSerializerOptions? _originalOptions; + + public bool RecursionDetected { get; private set; } + public bool OptionsChanged { get; private set; } + + public override int Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => + throw new NotSupportedException(); + + public override void Write(Utf8JsonWriter writer, int value, JsonSerializerOptions options) + { + if (_writing) + { + RecursionDetected = true; + throw new InvalidOperationException("Recursive member converter invocation."); + } + + _originalOptions ??= options; + OptionsChanged |= !ReferenceEquals(_originalOptions, options); + _writing = true; + try + { + JsonSerializer.Serialize(writer, value, options.GetTypeInfo(typeof(int))); + } + finally + { + _writing = false; + } + } + } + + private sealed class RecoveryBoundaryConverterFactory(RecoveryBoundaryDelegatingConverter converter) : JsonConverterFactory + { + public override bool CanConvert(Type typeToConvert) => typeToConvert == typeof(int); + public override JsonConverter CreateConverter(Type typeToConvert, JsonSerializerOptions options) => converter; + } + + private sealed class RecoveryBoundaryGlobalIntConverter : JsonConverter + { + public override int Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => + throw new NotSupportedException(); + public override void Write(Utf8JsonWriter writer, int value, JsonSerializerOptions options) => + writer.WriteStringValue($"global-{value}"); + } + + private struct RecoveryBoundaryValue + { + public int Count { get; set; } + } + + private sealed class RecoveryBoundaryDictionary(bool reorder) : IReadOnlyDictionary + { + private readonly Dictionary _entries = new() + { + ["First/~"] = default, + ["Second"] = new() { Count = 5 }, + }; + private int _enumerations; + + public RecoveryBoundaryValue this[string key] => _entries[key]; + public IEnumerable Keys => _entries.Keys; + public IEnumerable Values => _entries.Values; + public int Count => _entries.Count; + public bool ContainsKey(string key) => _entries.ContainsKey(key); + public bool TryGetValue(string key, out RecoveryBoundaryValue value) => _entries.TryGetValue(key, out value); + public IEnumerator> GetEnumerator() => + (reorder && ++_enumerations % 2 == 0 ? _entries.Reverse() : _entries).GetEnumerator(); + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + } + + private readonly record struct RecoveryBoundaryKey(int Value); + + private sealed class RecoveryBoundaryKeyConverter : JsonConverter + { + public override RecoveryBoundaryKey Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => + throw new NotSupportedException(); + public override void Write(Utf8JsonWriter writer, RecoveryBoundaryKey value, JsonSerializerOptions options) => + writer.WriteStringValue($"value-{value.Value}"); + public override void WriteAsPropertyName(Utf8JsonWriter writer, RecoveryBoundaryKey value, JsonSerializerOptions options) => + writer.WritePropertyName($"key-{value.Value}"); + } + + private sealed class RecoveryBoundaryReadonlyOutput + { + public readonly int Count = 0; + } + + private sealed class RecoveryBoundaryAttributeProvider : ICustomAttributeProvider + { + public object[] GetCustomAttributes(bool inherit) => []; + public object[] GetCustomAttributes(Type attributeType, bool inherit) => []; + public bool IsDefined(Type attributeType, bool inherit) => false; + } + + private sealed class RecoveryBoundaryAttributedOutput + { + [JsonConverter(typeof(RecoveryBoundaryDelegatingConverter))] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + public int Count { get; set; } + } + + private sealed class RecoveryBoundaryNullableOutput + { + public string? Note { get; set; } + } + + private sealed record RecoveryBoundaryNullableRecord(string? Note = null); + + private sealed class RecoveryBoundaryInvocationOutput + { + public RecoveryBoundaryInvocationValue Value { get; set; } + } + + private readonly struct RecoveryBoundaryInvocationValue(int value) : IEquatable + { + public int Value { get; } = value; + public bool Equals(RecoveryBoundaryInvocationValue other) => true; + public override bool Equals(object? obj) => obj is RecoveryBoundaryInvocationValue; + public override int GetHashCode() => 0; + } + + private sealed class RecoveryBoundaryInvocationConverter(Barrier barrier, CancellationToken cancellationToken) : JsonConverter + { + public override RecoveryBoundaryInvocationValue Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => + throw new NotSupportedException(); + + public override void Write(Utf8JsonWriter writer, RecoveryBoundaryInvocationValue value, JsonSerializerOptions options) + { + if (!barrier.SignalAndWait(TestConstants.DefaultTimeout, cancellationToken)) + { + throw new TimeoutException("Concurrent recovery did not enter both converters."); + } + writer.WriteNumberValue(value.Value); + } + } + + private sealed class RecoveryBoundaryCyclicOwner + { + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + public RecoveryBoundaryCyclicValue Missing { get; set; } + } + + private sealed class RecoveryBoundaryAncestorRoot : IJsonOnSerializing + { + public object? Child { get; set; } + public int Existing { get { GetterCalls++; return 5; } } + [JsonIgnore] + public int GetterCalls { get; private set; } + [JsonIgnore] + public int SerializingCalls { get; private set; } + public void OnSerializing() => SerializingCalls++; + } + + private sealed class RecoveryBoundaryAncestorExtension + { + [JsonExtensionData] + public Dictionary Data { get; set; } = new(); + } + + private struct RecoveryBoundaryStructAncestor + { + public RecoveryBoundaryCyclicOwner Child { get; set; } + } + + private struct RecoveryBoundaryCyclicStructOwner + { + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + public RecoveryBoundaryCyclicValue Missing { get; set; } + } + + private struct RecoveryBoundaryCyclicValue : IEquatable + { + public object? Owner { get; set; } + public bool Equals(RecoveryBoundaryCyclicValue other) => true; + public override bool Equals(object? obj) => obj is RecoveryBoundaryCyclicValue; + public override int GetHashCode() => 0; + } + + private sealed class RecoveryBoundaryCachingResolver(IJsonTypeInfoResolver inner) : IJsonTypeInfoResolver + { + private readonly System.Collections.Concurrent.ConcurrentDictionary<(Type, JsonSerializerOptions), JsonTypeInfo> _contracts = new(); + + public JsonTypeInfo? GetTypeInfo(Type type, JsonSerializerOptions options) => + _contracts.GetOrAdd((type, options), key => inner.GetTypeInfo(key.Item1, key.Item2)!); + } + + private struct RecoveryBoundaryReferenceValue + { + public RecoveryBoundaryNullableOutput Child => new(); + } + + private sealed class RecoveryBoundaryCallbackOutput : IJsonOnSerializing, IJsonOnSerialized + { + public int Count { get; set; } + public int Existing { get { GetterCalls++; return 5; } } + [JsonIgnore] + public int SerializingCalls { get; private set; } + [JsonIgnore] + public int SerializedCalls { get; private set; } + [JsonIgnore] + public int GetterCalls { get; private set; } + public void OnSerializing() => SerializingCalls++; + public void OnSerialized() => SerializedCalls++; + } + + private sealed class RecoveryBoundaryStatefulOutput : IJsonOnSerializing, IJsonOnSerialized + { + private bool _serializing; + public string Kept { get; set; } = "kept"; + public string? Note => _serializing && !FailDuringSerialization ? null : throw Failure; + [JsonIgnore] + public Exception Failure { get; set; } = new InvalidOperationException("Getter requires serialization callback state."); + [JsonIgnore] + public bool FailDuringSerialization { get; set; } + [JsonIgnore] + public int SerializingCalls { get; private set; } + [JsonIgnore] + public int SerializedCalls { get; private set; } + public void OnSerializing() { _serializing = true; SerializingCalls++; } + public void OnSerialized() { _serializing = false; SerializedCalls++; } + } + + private sealed class RecoveryBoundaryThrowingList(Exception failure) : IReadOnlyList + { + public int Count => 1; + public RecoveryBoundaryNullableOutput this[int index] => throw failure; + public IEnumerator GetEnumerator() + { + yield return new(); + } + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + } + + private static McpServerTool CreateRecoveryBoundaryTool(object value, string schema, JsonSerializerOptions options) => + McpServerTool.Create(new RecoveryBoundaryFunction(value, options), new() + { + UseStructuredContent = true, + OutputSchema = JsonDocument.Parse(schema).RootElement, + }); + + private sealed class RecoveryBoundaryFunction(object value, JsonSerializerOptions options) : AIFunction + { + public override string Name => "tool"; + public override JsonElement JsonSchema { get; } = JsonDocument.Parse("""{"type":"object"}""").RootElement; + public override JsonSerializerOptions JsonSerializerOptions => options; + protected override ValueTask InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) => + new(value); + } + + [JsonSourceGenerationOptions(IncludeFields = true, PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] + [JsonSerializable(typeof(RecoveryBoundaryConvertedOutput))] + [JsonSerializable(typeof(RecoveryBoundaryReadonlyOutput))] + [JsonSerializable(typeof(RecoveryBoundaryCallbackOutput))] + [JsonSerializable(typeof(RecoveryBoundaryStatefulOutput))] + [JsonSerializable(typeof(RecoveryBoundaryAttributedOutput))] + [JsonSerializable(typeof(RecoveryBoundaryNullableOutput))] + [JsonSerializable(typeof(RecoveryBoundaryNullableRecord))] + [JsonSerializable(typeof(Dictionary))] + [JsonSerializable(typeof(RecoveryBoundaryFilteredParent))] + [JsonSerializable(typeof(RecoveryBoundaryDefaultParent))] + [JsonSerializable(typeof(RecoveryBoundaryCyclicOwner))] + [JsonSerializable(typeof(RecoveryBoundaryCyclicStructOwner))] + [JsonSerializable(typeof(RecoveryBoundaryAncestorRoot))] + [JsonSerializable(typeof(RecoveryBoundaryAncestorExtension))] + [JsonSerializable(typeof(RecoveryBoundaryStructAncestor))] + [JsonSerializable(typeof(RecoveryBoundaryCyclicOwner[]))] + [JsonSerializable(typeof(Dictionary))] + [JsonSerializable(typeof(object))] + private partial class RecoveryBoundaryJsonContext : JsonSerializerContext; +} diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerToolRepairSearchTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerToolRepairSearchTests.cs new file mode 100644 index 000000000..f2e6573e1 --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Server/McpServerToolRepairSearchTests.cs @@ -0,0 +1,686 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using Json.Schema; +using ModelContextProtocol.Tests.Utils; +using ModelContextProtocol.Server; +using ModelContextProtocol.Protocol; +using Moq; +using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; +using System.Text.RegularExpressions; +using System.IO.Pipelines; +using ModelContextProtocol.Client; + +namespace ModelContextProtocol.Tests.Server; + +public partial class McpServerToolTests +{ + [Theory] + [InlineData("element")] + [InlineData("node")] + [InlineData("document")] + public async Task StructuredOutput_Search_RecoversClrParentWithSerializedJsonChild(string kind) + { + const string json = """{"value":42,"explicit":null}"""; + using JsonDocument document = JsonDocument.Parse(json); + object child = kind switch + { + "element" => document.RootElement, + "node" => JsonNode.Parse(json)!, + _ => document, + }; + McpServerTool tool = CreateStructuredOutputTestTool( + new { Note = (string?)null, Child = child }, """{"required":["note"]}"""); + + JsonElement result = await InvokeStructuredOutputTestTool(tool); + + Assert.Equal(JsonValueKind.Null, result.GetProperty("note").ValueKind); + Assert.Equal(json, result.GetProperty("child").GetRawText()); + } + + [Fact] + public async Task StructuredOutput_Search_PreservesJsonDocumentObjectContractRecovery() + { + using JsonDocument document = JsonDocument.Parse("""{"value":42}"""); + JsonSerializerOptions options = CreateStructuredOutputTestOptions(); + options.TypeInfoResolver = new JsonDocumentObjectContractResolver(); + McpServerTool tool = CreateStructuredOutputTestTool(document, """{"required":["note"]}""", options); + + JsonElement result = await InvokeStructuredOutputTestTool(tool); + + Assert.Equal("""{"note":null}""", result.GetRawText()); + } + + private sealed class JsonDocumentObjectContractResolver : DefaultJsonTypeInfoResolver + { + public override JsonTypeInfo GetTypeInfo(Type type, JsonSerializerOptions options) + { + if (type != typeof(JsonDocument)) + { + return base.GetTypeInfo(type, options); + } + + JsonTypeInfo info = JsonMetadataServices.CreateObjectInfo(options, new() + { + PropertyMetadataInitializer = static _ => [], + }); + JsonPropertyInfo note = info.CreateJsonPropertyInfo(typeof(string), "note"); + note.Get = static _ => null; + info.Properties.Add(note); + return info; + } + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task StructuredOutput_Search_BoundsFiniteRecursiveAnnotationEvaluation(bool dynamicReference) + { + string reference = dynamicReference ? "\"$dynamicRef\":\"#node\"" : "\"$ref\":\"#\""; + string schema = $$$$"""{"$schema":"https://json-schema.org/draft/2020-12/schema","$dynamicAnchor":"node","required":["note"],"properties":{"child":{ {{{{reference}}}} }},"unevaluatedProperties":true}"""; + McpServerTool tool = CreateStructuredOutputTestTool( + new { Note = (string?)null, Child = new { Note = (string?)null, Child = new { Note = (string?)null } } }, schema); + + JsonElement result = await InvokeStructuredOutputTestTool(tool); + + AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result); + Assert.Equal(JsonValueKind.Null, result.GetProperty("note").ValueKind); + Assert.Equal(JsonValueKind.Null, result.GetProperty("child").GetProperty("note").ValueKind); + Assert.Equal(JsonValueKind.Null, result.GetProperty("child").GetProperty("child").GetProperty("note").ValueKind); + } + + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public async Task StructuredOutput_Search_RetainsAnnotationChangingAlternatives(bool array, bool exclusive) + { + string first = exclusive + ? """{"not":{"properties":{"child":{"required":["note"]}}}}""" + : "true"; + string choice = $$$$"""{"{{{{(exclusive ? "oneOf" : "anyOf")}}}}":[{{{{first}}}},{"properties":{"child":{"required":["note"]}}}],"unevaluatedProperties":false}"""; + string schema = array ? $$"""{"items":{{choice}}}""" : choice; + object output = array + ? new[] { new { Child = new { Note = (string?)null } } } + : new { Child = new { Note = (string?)null } }; + McpServerTool tool = CreateStructuredOutputTestTool(output, schema); + + JsonElement result = await InvokeStructuredOutputTestTool(tool); + + AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result); + Assert.Equal(JsonValueKind.Null, (array ? result[0] : result).GetProperty("child").GetProperty("note").ValueKind); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task StructuredOutput_Search_StopsAfterRegexTimeout(bool propertyPattern) + { + string input = new string('a', 26) + "!"; + object output = propertyPattern + ? new { Note = (string?)null, Items = new Dictionary { [input] = new { Value = 1 } } } + : new { Note = (string?)null, Value = input }; + string schema = propertyPattern + ? """{"required":["note"],"properties":{"items":{"patternProperties":{"^(a+)+$":false}}}}""" + : """{"anyOf":[{"properties":{"value":{"pattern":"^(a+)+$"}}},{"required":["note"]}]}"""; + McpServerTool tool = CreateStructuredOutputTestTool(output, schema); + + JsonElement result = await Task.Run( + () => InvokeStructuredOutputTestTool(tool), TestContext.Current.CancellationToken) + .WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken); + + Assert.False(result.TryGetProperty("note", out _)); + if (propertyPattern) + { + Assert.Equal(1, result.GetProperty("items").GetProperty(input).GetProperty("value").GetInt32()); + } + else + { + Assert.Equal(input, result.GetProperty("value").GetString()); + } + } + +#if NET + [Fact] + public async Task StructuredOutput_Search_BoundsLegacyRecursiveDiagnosticExpansion() + { + object output = new Dictionary { ["kept"] = 1 }; + for (int i = 0; i < 18; i++) + { + output = new Dictionary { ["child"] = output }; + } + JsonElement expected = JsonSerializer.SerializeToElement(output, CreateStructuredOutputTestOptions()); + const string schema = """ + { + "$schema":"https://json-schema.org/draft/2019-09/schema", + "$id":"https://example.test/legacy/root","$recursiveAnchor":true, + "properties":{"child":{"$ref":"b"}},"unevaluatedProperties":true, + "$defs":{ + "b":{"$id":"b","properties":{"child":{"$ref":"c"}}}, + "c":{"$id":"c","$recursiveAnchor":true,"allOf":[ + {"properties":{"child":{"$recursiveRef":"#"}}}, + {"properties":{"child":{"$recursiveRef":"#"}}} + ]} + } + } + """; + McpServerTool tool = CreateStructuredOutputTestTool(output, schema); + + await Task.Run(() => + { + long before = GC.GetAllocatedBytesForCurrentThread(); + JsonElement result = InvokeStructuredOutputTestTool(tool).GetAwaiter().GetResult(); + long allocated = GC.GetAllocatedBytesForCurrentThread() - before; + + Assert.Equal(expected.GetRawText(), result.GetRawText()); + Assert.True(allocated < 32 * 1024 * 1024, $"Legacy recursive diagnostics allocated {allocated:N0} bytes."); + }, TestContext.Current.CancellationToken); + } + + [Theory] + [InlineData(4, true)] + [InlineData(14, false)] + public async Task StructuredOutput_Search_BoundsUnevaluatedDagDiagnostics(int depth, bool recoverable) + { + JsonObject definitions = new() { ["s0"] = JsonNode.Parse("""{"properties":{"kept":true}}""") }; + for (int i = 1; i <= depth; i++) + { + definitions[$"s{i}"] = JsonNode.Parse($$"""{"allOf":[{"$ref":"#/$defs/s{{i - 1}}"},{"$ref":"#/$defs/s{{i - 1}}"}]}"""); + } + McpServerTool tool = CreateStructuredOutputTestTool(new { Kept = 1, Child = new { Note = (string?)null } }, + new JsonObject + { + ["$ref"] = $"#/$defs/s{depth}", + ["$defs"] = definitions, + ["unevaluatedProperties"] = JsonNode.Parse("""{"required":["note"]}"""), + }.ToJsonString()); + + await Task.Run(() => + { + long before = GC.GetAllocatedBytesForCurrentThread(); + JsonElement result = InvokeStructuredOutputTestTool(tool).GetAwaiter().GetResult(); + long allocated = GC.GetAllocatedBytesForCurrentThread() - before; + TestContext.Current.TestOutputHelper?.WriteLine($"Allocated {allocated:N0} bytes for diagnostic DAG depth {depth}."); + + Assert.Equal(1, result.GetProperty("kept").GetInt32()); + Assert.Equal(recoverable, result.GetProperty("child").TryGetProperty("note", out _)); + Assert.True(allocated < 32 * 1024 * 1024, $"Diagnostic evaluation allocated {allocated:N0} bytes."); + }, TestContext.Current.CancellationToken); + } + + [Theory] + [InlineData(8, true)] + [InlineData(64, false)] + [InlineData(128, false)] + [InlineData(128, true)] + public async Task StructuredOutput_Search_BoundsWideSchemaAllocations(int width, bool recoverable) + { + JsonArray alternatives = []; + for (int i = 0; i < width; i++) + { + alternatives.Add((JsonNode)new JsonObject + { + ["required"] = new JsonArray($"missing{i}_" + new string('x', 512)), + }); + } + if (recoverable) + { + alternatives.Add((JsonNode)new JsonObject { ["required"] = new JsonArray("note") }); + } + + McpServerTool tool = CreateStructuredOutputTestTool( + new { Value = "kept", Note = (string?)null }, new JsonObject { ["anyOf"] = alternatives }.ToJsonString()); + + await Task.Run(() => + { + long before = GC.GetAllocatedBytesForCurrentThread(); + Task invocation = InvokeStructuredOutputTestTool(tool); + Assert.True(invocation.IsCompleted); + JsonElement result = invocation.GetAwaiter().GetResult(); + long allocated = GC.GetAllocatedBytesForCurrentThread() - before; + TestContext.Current.TestOutputHelper?.WriteLine($"Allocated {allocated:N0} bytes for {width} branches."); + + Assert.Equal("kept", result.GetProperty("value").GetString()); + Assert.Equal(recoverable && width == 8, result.TryGetProperty("note", out _)); + if (width != 8) + { + Assert.Equal("""{"value":"kept"}""", result.GetRawText()); + } + Assert.True(allocated < 32 * 1024 * 1024, $"Recovery allocated {allocated:N0} bytes for {width} branches."); + }, TestContext.Current.CancellationToken); + } +#endif + + [Theory] + [InlineData("anyOf")] + [InlineData("oneOf")] + [InlineData("allOf")] + [InlineData("reference")] + [InlineData("crossParents")] + [InlineData("crossReferences")] + public async Task StructuredOutput_Search_QualifiesEnclosingItemAssertions(string shape) + { + string item = shape switch + { + "oneOf" => """{"properties":{"a":true},"additionalProperties":false,"oneOf":[{"required":["b"]},{"required":["a"]}]}""", + "allOf" => """{"allOf":[{"anyOf":[{"required":["a"]},{"required":["b"]}]},{"not":{"required":["b"]}}]}""", + "reference" => """{"$ref":"#/$defs/choice","properties":{"a":true},"additionalProperties":false}""", + _ => """{"properties":{"a":true},"additionalProperties":false,"anyOf":[{"required":["a"]},{"required":["b"]}]}""", + }; + var output = Enumerable.Range(0, 129).Select(_ => new { A = (string?)null, B = (string?)null, C = (string?)null }).ToArray(); + string schema = shape switch + { + "crossParents" => """{"allOf":[{"items":{"anyOf":[{"required":["b"]},{"required":["a"]}]}},{"items":{"required":["c"],"not":{"required":["b"]}}}]}""", + "crossReferences" => """{"allOf":[{"items":{"$ref":"#/$defs/choice"}},{"items":{"$ref":"#/$defs/guard"}}],"$defs":{"choice":{"$id":"choice","anyOf":[{"required":["b"]},{"required":["a"]}]},"guard":{"$id":"guard","required":["c"],"not":{"required":["b"]}}}}""", + _ => $$$$"""{"items":{{{{item}}}},"$defs":{"choice":{"anyOf":[{"required":["a"]},{"required":["b"]}]}}}""", + }; + McpServerTool tool = CreateStructuredOutputTestTool(output, schema); + + JsonElement result = await InvokeStructuredOutputTestTool(tool); + + AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result); + Assert.All(result.EnumerateArray(), child => + { + Assert.Equal(JsonValueKind.Null, child.GetProperty("a").ValueKind); + Assert.False(child.TryGetProperty("b", out _)); + Assert.Equal(shape is "crossParents" or "crossReferences", child.TryGetProperty("c", out _)); + }); + } + + [Theory] + [InlineData("object")] + [InlineData("array")] + [InlineData("reference")] + [InlineData("allOf")] + [InlineData("anyOf")] + [InlineData("contains")] + [InlineData("becomesEvaluated")] + [InlineData("validAlternative")] + public async Task StructuredOutput_Search_DoesNotRepairEvaluatedOptionalMembers(string shape) + { + var first = new { Note = (string?)null, Real = (string?)null }; + var second = new { Note = (string?)null, Real = (string?)null }; + bool array = shape is "array" or "contains"; + object output = array ? new[] { first, second } : new { Evaluated = first, Other = second }; + string schema = shape switch + { + "array" => """{"prefixItems":[{"type":"object"}],"unevaluatedItems":{"required":["note"]}}""", + "contains" => """{"prefixItems":[{"properties":{"real":true}}],"contains":{"required":["real"]},"unevaluatedItems":{"required":["note"]}}""", + "reference" => """{"$id":"https://example.test/unevaluated","$defs":{"rule":{"properties":{"evaluated":true},"unevaluatedProperties":{"required":["note"]}}},"allOf":[{"$ref":"#/$defs/rule"}]}""", + "allOf" => """{"allOf":[{"properties":{"evaluated":true}}],"unevaluatedProperties":{"required":["note"]}}""", + "anyOf" => """{"anyOf":[{"properties":{"evaluated":true}}],"unevaluatedProperties":{"required":["note"]}}""", + "becomesEvaluated" => """{"properties":{"evaluated":{"required":["real"]}},"unevaluatedProperties":{"required":["note"]}}""", + "validAlternative" => """{"anyOf":[{"properties":{"evaluated":true}},{"properties":{"other":{"required":["real"]}}}],"unevaluatedProperties":{"required":["note"]}}""", + _ => """{"properties":{"evaluated":{"type":"object"}},"unevaluatedProperties":{"required":["note"]}}""", + }; + if (shape == "contains") + { + output = new[] { new { Note = (string?)null, Real = (string?)"present" }, first }; + } + McpServerTool tool = CreateStructuredOutputTestTool(output, schema); + + JsonElement result = await InvokeStructuredOutputTestTool(tool); + + AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result); + JsonElement evaluated = array ? result[0] : result.GetProperty("evaluated"); + JsonElement other = array ? result[1] : result.GetProperty("other"); + Assert.False(evaluated.TryGetProperty("note", out _)); + Assert.Equal(JsonValueKind.Null, other.GetProperty("note").ValueKind); + Assert.False(other.TryGetProperty("real", out _)); + } + + [Fact] + public async Task StructuredOutput_Search_ComposesIndependentExclusiveChoicesAtSamePath() + { + var output = new + { + A0 = (string?)null, + B0 = (string?)null, + A1 = (string?)null, + B1 = (string?)null, + A2 = (string?)null, + B2 = (string?)null, + A3 = (string?)null, + B3 = (string?)null, + A4 = (string?)null, + B4 = (string?)null, + }; + JsonArray allOf = []; + for (int i = 0; i < 5; i++) + { + allOf.Add(JsonNode.Parse($$"""{"oneOf":[{"required":["a{{i}}"]},{"required":["b{{i}}"]}]}""")); + } + + McpServerTool tool = CreateStructuredOutputTestTool(output, new JsonObject { ["allOf"] = allOf }.ToJsonString()); + JsonElement result = await InvokeStructuredOutputTestTool(tool); + AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result); + for (int i = 0; i < 5; i++) + { + Assert.NotEqual(result.TryGetProperty($"a{i}", out _), result.TryGetProperty($"b{i}", out _)); + } + } + + [Theory] + [InlineData(false, false)] + [InlineData(true, false)] + [InlineData(true, true)] + public async Task StructuredOutput_Search_PreservesCallerScopeForBranchValidation(bool dynamicReference, bool siblingReference) + { + string reference = dynamicReference ? "\"$dynamicRef\":\"#entry\"" : "\"$ref\":\"https://example.test/root#entry\""; + if (siblingReference) + { + reference += ",\"$ref\":\"#/$defs/childRule\""; + } + + string schema = $$$$""" + { + "$schema":"https://json-schema.org/draft/2020-12/schema", + "$id":"https://example.test/root", "$dynamicAnchor":"entry", + "required":["note"], + "properties":{"items":{"items":{"$ref":"base"}}}, + "$defs":{"base":{ + "$id":"base","$dynamicAnchor":"entry", + "$defs":{"childRule":{"required":["marker"]}}, + "oneOf":[ + {"required":["tag","good"],"properties":{"tag":{"const":"0"},"good":true,"child":{ {{{{reference}}}} }},"additionalProperties":false}, + {"required":["tag","bad"],"properties":{"tag":{"const":"1"},"bad":true,"child":{ {{{{reference}}}} }},"additionalProperties":false} + ] + }} + } + """; + var output = new + { + Note = (string?)null, + Items = Enumerable.Range(0, 5).Select(_ => new + { + Tag = "0", + Good = (string?)null, + Bad = (string?)null, + Child = new { Note = (string?)null, Marker = (string?)null }, + }).ToArray(), + }; + McpServerTool tool = CreateStructuredOutputTestTool(output, schema); + JsonElement result = await InvokeStructuredOutputTestTool(tool); + AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result); + Assert.Equal(JsonValueKind.Null, result.GetProperty("note").ValueKind); + foreach (JsonElement item in result.GetProperty("items").EnumerateArray()) + { + Assert.Equal(JsonValueKind.Null, item.GetProperty("good").ValueKind); + Assert.False(item.TryGetProperty("bad", out _)); + Assert.Equal(JsonValueKind.Null, item.GetProperty("child").GetProperty("note").ValueKind); + Assert.Equal(siblingReference, item.GetProperty("child").TryGetProperty("marker", out _)); + } + } + + [Fact] + public async Task StructuredOutput_Search_RepairsLargeMixedTagArray() + { + const string schema = """ + {"type":"array","items":{"oneOf":[ + {"required":["tag","good"],"properties":{"tag":{"const":"0"},"good":true},"additionalProperties":false}, + {"required":["tag","bad"],"properties":{"tag":{"const":"1"},"bad":true},"additionalProperties":false} + ]}} + """; + var output = Enumerable.Range(0, 10_000).Select(i => new + { + Tag = (i % 2).ToString(System.Globalization.CultureInfo.InvariantCulture), + Good = (string?)null, + Bad = (string?)null, + }).ToArray(); + McpServerTool tool = CreateStructuredOutputTestTool(output, schema); + JsonElement result = await InvokeStructuredOutputTestTool(tool); + AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result); + Assert.Equal(output.Length, result.GetArrayLength()); + for (int i = 0; i < output.Length; i++) + { + Assert.Equal(JsonValueKind.Null, result[i].GetProperty(i % 2 == 0 ? "good" : "bad").ValueKind); + Assert.False(result[i].TryGetProperty(i % 2 == 0 ? "bad" : "good", out _)); + } + } + + [Fact] + public async Task StructuredOutput_Search_DistinguishesDynamicCallersForSharedBranches() + { + const string schema = """ + { + "$schema":"https://json-schema.org/draft/2020-12/schema", + "$id":"https://example.test/root", + "properties":{"left":{"$ref":"left"},"right":{"$ref":"right"}}, + "$defs":{ + "left":{"$id":"left","$dynamicAnchor":"entry","required":["leftNote"], + "properties":{"items":{"items":{"$ref":"base"}}}}, + "right":{"$id":"right","$dynamicAnchor":"entry","required":["rightNote"], + "properties":{"items":{"items":{"$ref":"base"}}}}, + "base":{"$id":"base","$dynamicAnchor":"entry","oneOf":[ + {"required":["tag","good"],"properties":{"tag":{"const":"0"},"good":true,"child":{"$dynamicRef":"#entry"}},"additionalProperties":false}, + {"required":["tag","bad"],"properties":{"tag":{"const":"1"},"bad":true,"child":{"$dynamicRef":"#entry"}},"additionalProperties":false} + ]} + } + } + """; + var items = Enumerable.Range(0, 5).Select(_ => new + { + Tag = "0", + Good = (string?)null, + Bad = (string?)null, + Child = new { LeftNote = (string?)null, RightNote = (string?)null }, + }).ToArray(); + McpServerTool tool = CreateStructuredOutputTestTool(new + { + Left = new { LeftNote = (string?)null, Items = items }, + Right = new { RightNote = (string?)null, Items = items }, + }, schema); + + JsonElement result = await InvokeStructuredOutputTestTool(tool); + AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result); + foreach (string side in new[] { "left", "right" }) + { + foreach (JsonElement item in result.GetProperty(side).GetProperty("items").EnumerateArray()) + { + JsonElement child = item.GetProperty("child"); + Assert.Equal(JsonValueKind.Null, child.GetProperty(side + "Note").ValueKind); + Assert.False(child.TryGetProperty(side == "left" ? "rightNote" : "leftNote", out _)); + Assert.False(item.TryGetProperty("bad", out _)); + } + } + } + + [Theory] + [InlineData(14, true)] + [InlineData(25, false)] + public async Task StructuredOutput_Search_TraversesSharedReferenceDagOnce(int depth, bool sourceHasValue) + { + string property = sourceHasValue ? "note" : "absent"; + JsonObject definitions = new() { ["s0"] = JsonNode.Parse($$"""{"required":["{{property}}"]}""") }; + for (int i = 1; i <= depth; i++) + { + definitions[$"s{i}"] = JsonNode.Parse($$"""{"allOf":[{"$ref":"#/$defs/s{{i - 1}}"},{"$ref":"#/$defs/s{{i - 1}}"}]}"""); + } + + McpServerTool tool = CreateStructuredOutputTestTool( + new { Note = (string?)null }, + new JsonObject { ["$ref"] = $"#/$defs/s{depth}", ["$defs"] = definitions }.ToJsonString()); + JsonElement result = await Task.Run( + () => InvokeStructuredOutputTestTool(tool), TestContext.Current.CancellationToken) + .WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken); + if (sourceHasValue) + { + Assert.Equal(JsonValueKind.Null, result.GetProperty("note").ValueKind); + // Detailed diagnostics duplicate results for every successful DAG branch. + JsonSchema schema = JsonSchema.Build( + tool.ProtocolTool.OutputSchema!.Value, new BuildOptions { SchemaRegistry = new SchemaRegistry() }); + Assert.True(schema.Evaluate(result, new EvaluationOptions { OutputFormat = OutputFormat.Flag }).IsValid); + } + else + { + Assert.False(result.TryGetProperty("absent", out _)); + } + } + + [Collection(nameof(DisableParallelization))] + public class RecoverySchemaRegistryTests + { + [Theory] + [InlineData("element", false)] + [InlineData("element", true)] + [InlineData("node", false)] + [InlineData("node", true)] + [InlineData("document", false)] + [InlineData("document", true)] + [InlineData("clr", false)] + [InlineData("clr", true)] + public async Task StructuredOutput_Search_DoesNotLoadSchemasForSerializedJson(string kind, bool forwarding) + { + foreach (string protocolVersion in new[] { "2025-06-18", "2025-11-25", "2026-07-28" }) + { + foreach (bool array in new[] { false, true }) + { + Uri uri = new($"https://example.test/recovery/{Guid.NewGuid():N}"); + JsonSchema external = JsonSchema.FromText( + array ? """{"items":{"required":["note"]}}""" : """{"required":["note"]}""", + new BuildOptions { SchemaRegistry = new() }, uri); + int fetches = 0; + var fetch = SchemaRegistry.Global.Fetch; + try + { + SchemaRegistry.Global.Fetch = (requested, registry) => + { + if (requested != uri) + { + return fetch(requested, registry); + } + Interlocked.Increment(ref fetches); + return external; + }; + string json = array ? """[{"value":42,"explicit":null}]""" : """{"value":42,"explicit":null}"""; + using JsonDocument document = JsonDocument.Parse(json); + var clr = new { Note = "present", Child = JsonNode.Parse(json) }; + object source = kind switch + { + "element" => document.RootElement, + "node" => JsonNode.Parse(json)!, + "document" => document, + _ => array ? new[] { clr } : (object)clr, + }; + McpServerTool inner = McpServerTool.Create(() => source, new() + { + Name = "serialized_json", + UseStructuredContent = true, + SerializerOptions = CreateStructuredOutputTestOptions(), + OutputSchema = JsonDocument.Parse($$"""{"type":"{{(array ? "array" : "object")}}","$ref":"{{uri}}"}""").RootElement, + }); + McpServerTool tool = forwarding + ? new ForwardingSchemaTool(inner, JsonDocument.Parse($$"""{"type":"{{(array ? "array" : "object")}}","allOf":[{"$ref":"{{uri}}"}]}""").RootElement) + : inner; + + CallToolResult result = await InvokeRegisteredToolAsync(tool, protocolVersion); + + Assert.NotEqual(true, result.IsError); + JsonElement content = result.StructuredContent!.Value; + if (array && protocolVersion != "2026-07-28") + { + content = content.GetProperty("result"); + } + Assert.Equal(kind is "clr" or "document", fetches > 0); + if (kind == "clr") + { + Assert.Equal("present", (array ? content[0] : content).GetProperty("note").GetString()); + } + else + { + Assert.Equal(json, content.GetRawText()); + } + } + finally + { + SchemaRegistry.Global.Fetch = fetch; + } + } + } + } + + private static async Task InvokeRegisteredToolAsync(McpServerTool tool, string protocolVersion) + { + using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + cts.CancelAfter(TestConstants.DefaultTimeout); + var clientToServer = new Pipe(); + var serverToClient = new Pipe(); + await using var server = McpServer.Create( + new StreamServerTransport(clientToServer.Reader.AsStream(), serverToClient.Writer.AsStream()), + new McpServerOptions { ToolCollection = new() { tool } }); + Task serverTask = server.RunAsync(cts.Token); + try + { + var options = TestMcpClient.CreateOptions(); + options.ProtocolVersion = protocolVersion; + await using var client = await TestMcpClient.CreateAsync( + new StreamClientTransport(clientToServer.Writer.AsStream(), serverToClient.Reader.AsStream()), + options, cancellationToken: cts.Token); + return await client.CallToolAsync(tool.ProtocolTool.Name, cancellationToken: cts.Token); + } + finally + { + await cts.CancelAsync(); + await serverTask; + } + } + + private sealed class ForwardingSchemaTool(McpServerTool inner, JsonElement schema) : DelegatingMcpServerTool(inner) + { + public override Tool ProtocolTool { get; } = new() + { + Name = inner.ProtocolTool.Name, + InputSchema = inner.ProtocolTool.InputSchema, + OutputSchema = schema, + }; + + public override ValueTask InvokeAsync( + RequestContext request, CancellationToken cancellationToken = default) => + base.InvokeAsync(request, cancellationToken); + } + + [Fact] + public async Task StructuredOutput_Search_DoesNotMutateBorrowedSchemaRegexes() + { + Uri uri = new($"https://example.test/recovery/{Guid.NewGuid():N}"); + JsonSchema external = JsonSchema.FromText( + """{"required":["note"],"properties":{"value":{"pattern":"^safe$"}}}""", + new BuildOptions { SchemaRegistry = new() }, uri); + KeywordData pattern = external.Root.Keywords.Single(keyword => keyword.Handler.Name == "properties") + .Subschemas.Single().Keywords.Single(keyword => keyword.Handler.Name == "pattern"); + Regex original = Assert.IsType(pattern.Value); + var fetch = SchemaRegistry.Global.Fetch; + try + { + SchemaRegistry.Global.Fetch = (requested, registry) => requested == uri ? external : fetch(requested, registry); + McpServerTool tool = McpServerTool.Create(() => new { Note = (string?)null, Value = "safe" }, new() + { + Name = "tool", + UseStructuredContent = true, + OutputSchema = JsonDocument.Parse($$"""{"$ref":"{{uri}}"}""").RootElement, + SerializerOptions = new(JsonSerializerDefaults.Web) + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + TypeInfoResolver = new DefaultJsonTypeInfoResolver(), + }, + }); + Mock server = new(); + server.SetupGet(instance => instance.NegotiatedProtocolVersion).Returns("2026-07-28"); + RequestContext request = new(server.Object, + new JsonRpcRequest { Id = new RequestId("test"), Method = "tools/call" }, + new CallToolRequestParams { Name = "tool" }); + + CallToolResult result = await tool.InvokeAsync(request, TestContext.Current.CancellationToken); + + Assert.NotEqual(true, result.IsError); + Assert.Equal("""{"value":"safe"}""", result.StructuredContent!.Value.GetRawText()); + Assert.Same(original, pattern.Value); + } + finally + { + SchemaRegistry.Global.Fetch = fetch; + } + } + } +} diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs index c77151884..f4774df39 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs @@ -4,7 +4,9 @@ using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; using Moq; +using System.Collections.ObjectModel; using System.ComponentModel; +using System.Dynamic; using System.Reflection; using System.Runtime.InteropServices; using System.Text.Json; @@ -516,6 +518,2500 @@ public async Task StructuredOutput_Enabled_ReturnsExpectedSchema(T value) AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema.Value, result.StructuredContent); } + [Theory] + [InlineData(null, Sep2106ProtocolVersion)] + [InlineData("note", Sep2106ProtocolVersion)] + [InlineData(null, "2025-11-25")] + [InlineData("note", "2025-11-25")] + public async Task StructuredOutput_Enabled_RequiredNullableProperties_MatchesOutputSchema(string? note, string protocolVersion) + { + JsonSerializerOptions serializerOptions = new(McpJsonUtilities.DefaultOptions) + { + TypeInfoResolver = JsonTypeInfoResolver.Combine( + McpJsonUtilities.DefaultOptions.TypeInfoResolver, + new DefaultJsonTypeInfoResolver()), + }; + McpServerTool tool = McpServerTool.Create( + () => new RequiredNullableOutput("42", note), + new() { Name = "tool", UseStructuredContent = true, SerializerOptions = serializerOptions }); + var request = CreateRequestContextWithProtocolVersion(protocolVersion); + + var result = await tool.InvokeAsync(request, TestContext.Current.CancellationToken); + + Assert.NotNull(tool.ProtocolTool.OutputSchema); + Assert.NotNull(result.StructuredContent); + Assert.Equal(note is null ? JsonValueKind.Null : JsonValueKind.String, result.StructuredContent.Value.GetProperty("note").ValueKind); + AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema.Value, result.StructuredContent); + } + + [Theory] + [InlineData(null, Sep2106ProtocolVersion)] + [InlineData("note", Sep2106ProtocolVersion)] + [InlineData(null, "2025-11-25")] + [InlineData("note", "2025-11-25")] + public async Task StructuredOutput_Enabled_RequiredNullablePropertiesInClass_MatchesOutputSchema(string? note, string protocolVersion) + { + JsonSerializerOptions serializerOptions = new(McpJsonUtilities.DefaultOptions) + { + TypeInfoResolver = JsonTypeInfoResolver.Combine( + McpJsonUtilities.DefaultOptions.TypeInfoResolver, + new DefaultJsonTypeInfoResolver()), + }; + McpServerTool tool = McpServerTool.Create( + () => new RequiredNullablePropertiesOutput { Id = "42", Note = note }, + new() { Name = "tool", UseStructuredContent = true, SerializerOptions = serializerOptions }); + var request = CreateRequestContextWithProtocolVersion(protocolVersion); + + var result = await tool.InvokeAsync(request, TestContext.Current.CancellationToken); + + Assert.NotNull(tool.ProtocolTool.OutputSchema); + Assert.NotNull(result.StructuredContent); + Assert.Equal(note is null ? JsonValueKind.Null : JsonValueKind.String, result.StructuredContent.Value.GetProperty("note").ValueKind); + AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema.Value, result.StructuredContent); + } + + [Fact] + public async Task StructuredOutput_Enabled_ExplicitSchema_OmitsOptionalNullProperty() + { + JsonElement outputSchema = JsonDocument.Parse("""{"type":"object","properties":{"id":{"type":"string"},"note":{"type":"string"}},"required":["id"]}""").RootElement; + JsonSerializerOptions options = new(McpJsonUtilities.DefaultOptions) + { + TypeInfoResolver = JsonTypeInfoResolver.Combine(McpJsonUtilities.DefaultOptions.TypeInfoResolver, new DefaultJsonTypeInfoResolver()), + }; + McpServerTool tool = McpServerTool.Create( + () => new ExplicitSchemaOutput { Id = "42", Note = null }, + new() { Name = "tool", UseStructuredContent = true, OutputSchema = outputSchema, SerializerOptions = options }); + + var result = await tool.InvokeAsync(CreateRequestContextWithProtocolVersion(Sep2106ProtocolVersion), TestContext.Current.CancellationToken); + + Assert.NotNull(result.StructuredContent); + Assert.False(result.StructuredContent.Value.TryGetProperty("note", out _)); + AssertMatchesJsonSchema(outputSchema, result.StructuredContent); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + public async Task StructuredOutput_Enabled_ExplicitSchema_EmitsRequiredNullableValueWithIgnorePolicies(string? note) + { + JsonElement outputSchema = JsonDocument.Parse("""{"type":"object","properties":{"id":{"type":"string"},"defaultedNote":{"type":["string","null"]},"ignoredNote":{"type":["string","null"]}},"required":["id","defaultedNote","ignoredNote"]}""").RootElement; + JsonSerializerOptions options = new(McpJsonUtilities.DefaultOptions) + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingDefault, + TypeInfoResolver = JsonTypeInfoResolver.Combine(McpJsonUtilities.DefaultOptions.TypeInfoResolver, new DefaultJsonTypeInfoResolver()), + }; + McpServerTool tool = McpServerTool.Create( + () => new ExplicitSchemaOutputWithMemberIgnore { Id = "42", DefaultedNote = note, IgnoredNote = note }, + new() { Name = "tool", UseStructuredContent = true, OutputSchema = outputSchema, SerializerOptions = options }); + + var result = await tool.InvokeAsync(CreateRequestContextWithProtocolVersion(Sep2106ProtocolVersion), TestContext.Current.CancellationToken); + + Assert.NotNull(result.StructuredContent); + foreach (string propertyName in new[] { "defaultedNote", "ignoredNote" }) + { + JsonElement noteElement = result.StructuredContent.Value.GetProperty(propertyName); + Assert.Equal(note is null ? JsonValueKind.Null : JsonValueKind.String, noteElement.ValueKind); + Assert.Equal(note, noteElement.GetString()); + } + + AssertMatchesJsonSchema(outputSchema, result.StructuredContent); + } + + [Fact] + public async Task StructuredOutput_Enabled_ExplicitSchema_PreservesRequiredDefaultValue() + { + JsonElement outputSchema = JsonDocument.Parse("""{"type":"object","properties":{"id":{"type":"string"},"count":{"type":["integer","null"]}},"required":["id","count"]}""").RootElement; + JsonSerializerOptions options = new(McpJsonUtilities.DefaultOptions) + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingDefault, + TypeInfoResolver = JsonTypeInfoResolver.Combine(McpJsonUtilities.DefaultOptions.TypeInfoResolver, new DefaultJsonTypeInfoResolver()), + }; + McpServerTool tool = McpServerTool.Create( + () => new ExplicitSchemaOutputWithDefaultValue { Id = "42", Count = 0 }, + new() { Name = "tool", UseStructuredContent = true, OutputSchema = outputSchema, SerializerOptions = options }); + + var result = await tool.InvokeAsync(CreateRequestContextWithProtocolVersion(Sep2106ProtocolVersion), TestContext.Current.CancellationToken); + + Assert.NotNull(result.StructuredContent); + Assert.Equal(0, result.StructuredContent.Value.GetProperty("count").GetInt32()); + AssertMatchesJsonSchema(outputSchema, result.StructuredContent); + } + + [Fact] + public async Task StructuredOutput_Enabled_ExplicitSchema_PreservesMemberConverterForRequiredDefault() + { + JsonElement outputSchema = JsonDocument.Parse("""{"type":"object","properties":{"id":{"type":"string"},"count":{"type":"string"}},"required":["id","count"]}""").RootElement; + JsonSerializerOptions options = new(McpJsonUtilities.DefaultOptions) + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingDefault, + TypeInfoResolver = JsonTypeInfoResolver.Combine(McpJsonUtilities.DefaultOptions.TypeInfoResolver, new DefaultJsonTypeInfoResolver()), + }; + McpServerTool tool = McpServerTool.Create( + () => new ExplicitSchemaOutputWithMemberConverter { Id = "42", Count = 0 }, + new() { Name = "tool", UseStructuredContent = true, OutputSchema = outputSchema, SerializerOptions = options }); + + var result = await tool.InvokeAsync(CreateRequestContextWithProtocolVersion(Sep2106ProtocolVersion), TestContext.Current.CancellationToken); + + Assert.NotNull(result.StructuredContent); + Assert.Equal("0", result.StructuredContent.Value.GetProperty("count").GetString()); + AssertMatchesJsonSchema(outputSchema, result.StructuredContent); + } + + [Fact] + public async Task StructuredOutput_Enabled_ExplicitSchema_RecoversRequiredNullWhenCompleteSnapshotFails() + { + JsonElement outputSchema = JsonDocument.Parse(""" + {"type":"object","properties":{"note":{"type":["string","null"]},"status":{"type":"string"}},"required":["note"]} + """).RootElement; + JsonSerializerOptions options = new(McpJsonUtilities.DefaultOptions) + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingDefault, + TypeInfoResolver = JsonTypeInfoResolver.Combine(McpJsonUtilities.DefaultOptions.TypeInfoResolver, new DefaultJsonTypeInfoResolver()), + }; + options.Converters.Add(new JsonStringEnumConverter(allowIntegerValues: false)); + McpServerTool tool = McpServerTool.Create( + () => new ExplicitSchemaOutputWithUndefinedEnumDefault { Note = null }, + new() { Name = "tool", UseStructuredContent = true, OutputSchema = outputSchema, SerializerOptions = options }); + + var result = await tool.InvokeAsync(CreateRequestContextWithProtocolVersion(Sep2106ProtocolVersion), TestContext.Current.CancellationToken); + + Assert.NotNull(result.StructuredContent); + Assert.Equal(JsonValueKind.Null, result.StructuredContent.Value.GetProperty("note").ValueKind); + Assert.False(result.StructuredContent.Value.TryGetProperty("status", out _)); + AssertMatchesJsonSchema(outputSchema, result.StructuredContent); + } + + [Fact] + public async Task StructuredOutput_Enabled_ExplicitSchema_RejectingEnumDoesNotPreventUnionRepair() + { + JsonElement outputSchema = JsonDocument.Parse(""" + {"type":"object","properties":{"note":{"type":["string","null"]},"status":{"type":"string"}},"anyOf":[{"required":["status"]},{"required":["note"]}]} + """).RootElement; + JsonSerializerOptions options = new(McpJsonUtilities.DefaultOptions) + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingDefault, + TypeInfoResolver = JsonTypeInfoResolver.Combine(McpJsonUtilities.DefaultOptions.TypeInfoResolver, new DefaultJsonTypeInfoResolver()), + }; + options.Converters.Add(new JsonStringEnumConverter(allowIntegerValues: false)); + McpServerTool tool = McpServerTool.Create( + () => new ExplicitSchemaOutputWithUndefinedEnumDefault(), + new() { Name = "tool", UseStructuredContent = true, OutputSchema = outputSchema, SerializerOptions = options }); + + var result = await tool.InvokeAsync(CreateRequestContextWithProtocolVersion(Sep2106ProtocolVersion), TestContext.Current.CancellationToken); + + Assert.NotNull(result.StructuredContent); + Assert.Equal(JsonValueKind.Null, result.StructuredContent.Value.GetProperty("note").ValueKind); + AssertMatchesJsonSchema(outputSchema, result.StructuredContent); + } + + [Fact] + public async Task StructuredOutput_Enabled_ExplicitSchema_RecoversStructDictionaryValueWithEnumConverter() + { + JsonElement outputSchema = JsonDocument.Parse(""" + {"type":"object","properties":{"items":{"type":"object","additionalProperties":{"type":"object","properties":{"count":{"type":"integer"},"status":{"type":"string"}},"required":["count","status"]}}},"required":["items"]} + """).RootElement; + JsonSerializerOptions options = new(McpJsonUtilities.DefaultOptions) + { + TypeInfoResolver = JsonTypeInfoResolver.Combine(McpJsonUtilities.DefaultOptions.TypeInfoResolver, new DefaultJsonTypeInfoResolver()), + }; + options.Converters.Add(new JsonStringEnumConverter(allowIntegerValues: false)); + McpServerTool tool = McpServerTool.Create( + () => new StructDictionaryOutput { Items = new() { [42] = new() { Status = ExplicitSchemaStatus.Ready } } }, + new() { Name = "tool", UseStructuredContent = true, OutputSchema = outputSchema, SerializerOptions = options }); + + var result = await tool.InvokeAsync(CreateRequestContextWithProtocolVersion(Sep2106ProtocolVersion), TestContext.Current.CancellationToken); + + Assert.NotNull(result.StructuredContent); + Assert.Equal(0, result.StructuredContent.Value.GetProperty("items").GetProperty("42").GetProperty("count").GetInt32()); + AssertMatchesJsonSchema(outputSchema, result.StructuredContent); + } + + [Fact] + public async Task StructuredOutput_Enabled_ExplicitSchema_TraversesDictionaryValuesAndReferences() + { + JsonElement outputSchema = JsonDocument.Parse(""" + { + "$defs": { + "entry": { + "type": "object", + "properties": { + "id": { "type": "string" }, + "note": { "type": ["string", "null"] } + }, + "required": ["id", "note"] + } + }, + "type": "object", + "additionalProperties": { "$ref": "#/$defs/entry" } + } + """).RootElement; + JsonSerializerOptions options = new(McpJsonUtilities.DefaultOptions) + { + TypeInfoResolver = JsonTypeInfoResolver.Combine(McpJsonUtilities.DefaultOptions.TypeInfoResolver, new DefaultJsonTypeInfoResolver()), + }; + McpServerTool tool = McpServerTool.Create( + () => new Dictionary { ["entry"] = new() { Id = "42", Note = null } }, + new() { Name = "tool", UseStructuredContent = true, OutputSchema = outputSchema, SerializerOptions = options }); + + var result = await tool.InvokeAsync(CreateRequestContextWithProtocolVersion(Sep2106ProtocolVersion), TestContext.Current.CancellationToken); + + Assert.NotNull(result.StructuredContent); + Assert.Equal(JsonValueKind.Null, result.StructuredContent.Value.GetProperty("entry").GetProperty("note").ValueKind); + AssertMatchesJsonSchema(outputSchema, result.StructuredContent); + } + + [Fact] + public async Task StructuredOutput_Enabled_ExplicitSchema_TraversesRecursiveReferences() + { + JsonElement outputSchema = JsonDocument.Parse(""" + { + "$defs": { + "node": { + "type": "object", + "properties": { + "id": { "type": "string" }, + "note": { "type": ["string", "null"] }, + "next": { "$ref": "#/$defs/node" } + }, + "required": ["id", "note"] + } + }, + "$ref": "#/$defs/node" + } + """).RootElement; + JsonSerializerOptions options = new(McpJsonUtilities.DefaultOptions) + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + TypeInfoResolver = JsonTypeInfoResolver.Combine(McpJsonUtilities.DefaultOptions.TypeInfoResolver, new DefaultJsonTypeInfoResolver()), + }; + McpServerTool tool = McpServerTool.Create( + () => new RecursiveStructuredOutput { Id = "root", Note = null, Next = new() { Id = "child", Note = null } }, + new() { Name = "tool", UseStructuredContent = true, OutputSchema = outputSchema, SerializerOptions = options }); + + var result = await tool.InvokeAsync(CreateRequestContextWithProtocolVersion(Sep2106ProtocolVersion), TestContext.Current.CancellationToken); + + Assert.NotNull(result.StructuredContent); + Assert.Equal(JsonValueKind.Null, result.StructuredContent.Value.GetProperty("note").ValueKind); + Assert.Equal(JsonValueKind.Null, result.StructuredContent.Value.GetProperty("next").GetProperty("note").ValueKind); + AssertMatchesJsonSchema(outputSchema, result.StructuredContent); + } + + [Fact] + public async Task StructuredOutput_Enabled_ExplicitSchema_TraversesAllOfRequirements() + { + JsonElement outputSchema = JsonDocument.Parse(""" + { + "type": "object", + "properties": { "id": { "type": "string" } }, + "allOf": [ + { + "properties": { "note": { "type": ["string", "null"] } }, + "required": ["note"] + } + ], + "required": ["id"] + } + """).RootElement; + JsonSerializerOptions options = new(McpJsonUtilities.DefaultOptions) + { + TypeInfoResolver = JsonTypeInfoResolver.Combine(McpJsonUtilities.DefaultOptions.TypeInfoResolver, new DefaultJsonTypeInfoResolver()), + }; + McpServerTool tool = McpServerTool.Create( + () => new ExplicitSchemaOutput { Id = "42", Note = null }, + new() { Name = "tool", UseStructuredContent = true, OutputSchema = outputSchema, SerializerOptions = options }); + + var result = await tool.InvokeAsync(CreateRequestContextWithProtocolVersion(Sep2106ProtocolVersion), TestContext.Current.CancellationToken); + + Assert.NotNull(result.StructuredContent); + Assert.Equal(JsonValueKind.Null, result.StructuredContent.Value.GetProperty("note").ValueKind); + AssertMatchesJsonSchema(outputSchema, result.StructuredContent); + } + + [Fact] + public async Task StructuredOutput_Enabled_ExplicitSchema_UsesPrefixItemInsteadOfTrailingItemsSchema() + { + JsonElement outputSchema = JsonDocument.Parse(""" + { + "type": "array", + "prefixItems": [ + { + "type": "object", + "properties": { "id": { "type": "string" } }, + "required": ["id"], + "additionalProperties": false + } + ], + "items": { + "type": "object", + "properties": { + "id": { "type": "string" }, + "note": { "type": ["string", "null"] } + }, + "required": ["id", "note"] + } + } + """).RootElement; + JsonSerializerOptions options = new(McpJsonUtilities.DefaultOptions) + { + TypeInfoResolver = JsonTypeInfoResolver.Combine(McpJsonUtilities.DefaultOptions.TypeInfoResolver, new DefaultJsonTypeInfoResolver()), + }; + McpServerTool tool = McpServerTool.Create( + () => new[] { new ExplicitSchemaOutput { Id = "42", Note = null } }, + new() { Name = "tool", UseStructuredContent = true, OutputSchema = outputSchema, SerializerOptions = options }); + + var result = await tool.InvokeAsync(CreateRequestContextWithProtocolVersion(Sep2106ProtocolVersion), TestContext.Current.CancellationToken); + + Assert.NotNull(result.StructuredContent); + Assert.False(result.StructuredContent.Value[0].TryGetProperty("note", out _)); + AssertMatchesJsonSchema(outputSchema, result.StructuredContent); + } + + [Theory] + [InlineData("{\"type\":\"object\",\"properties\":{\"id\":{\"type\":\"string\"},\"note\":{}},\"required\":[\"id\",\"note\"]}")] + [InlineData("{\"type\":\"object\",\"properties\":{\"id\":{\"type\":\"string\"},\"note\":{\"const\":null}},\"required\":[\"id\",\"note\"]}")] + public async Task StructuredOutput_Enabled_ExplicitSchema_RecognizesPermissiveNullSchemas(string schema) + { + JsonElement outputSchema = JsonDocument.Parse(schema).RootElement; + JsonSerializerOptions options = new(McpJsonUtilities.DefaultOptions) + { + TypeInfoResolver = JsonTypeInfoResolver.Combine(McpJsonUtilities.DefaultOptions.TypeInfoResolver, new DefaultJsonTypeInfoResolver()), + }; + McpServerTool tool = McpServerTool.Create( + () => new ExplicitSchemaOutput { Id = "42", Note = null }, + new() { Name = "tool", UseStructuredContent = true, OutputSchema = outputSchema, SerializerOptions = options }); + + var result = await tool.InvokeAsync(CreateRequestContextWithProtocolVersion(Sep2106ProtocolVersion), TestContext.Current.CancellationToken); + + Assert.NotNull(result.StructuredContent); + Assert.Equal(JsonValueKind.Null, result.StructuredContent.Value.GetProperty("note").ValueKind); + AssertMatchesJsonSchema(outputSchema, result.StructuredContent); + } + + [Fact] + public async Task StructuredOutput_Enabled_ExplicitSchema_CombinesSplitRequiredAndPropertySchemas() + { + JsonElement outputSchema = JsonDocument.Parse(""" + { + "type": "object", + "properties": { "id": { "type": "string" }, "note": { "type": ["string", "null"] } }, + "allOf": [{ "required": ["note"] }], + "required": ["id"] + } + """).RootElement; + JsonSerializerOptions options = new(McpJsonUtilities.DefaultOptions) + { + TypeInfoResolver = JsonTypeInfoResolver.Combine(McpJsonUtilities.DefaultOptions.TypeInfoResolver, new DefaultJsonTypeInfoResolver()), + }; + McpServerTool tool = McpServerTool.Create( + () => new ExplicitSchemaOutput { Id = "42", Note = null }, + new() { Name = "tool", UseStructuredContent = true, OutputSchema = outputSchema, SerializerOptions = options }); + + var result = await tool.InvokeAsync(CreateRequestContextWithProtocolVersion(Sep2106ProtocolVersion), TestContext.Current.CancellationToken); + + Assert.NotNull(result.StructuredContent); + Assert.Equal(JsonValueKind.Null, result.StructuredContent.Value.GetProperty("note").ValueKind); + AssertMatchesJsonSchema(outputSchema, result.StructuredContent); + } + + [Fact] + public async Task StructuredOutput_Enabled_ExplicitSchema_CombinesReferenceRequirementsWithSiblingProperties() + { + JsonElement outputSchema = JsonDocument.Parse(""" + { + "$defs": { "requiresNote": { "type": "object", "required": ["note"] } }, + "$ref": "#/$defs/requiresNote", + "type": "object", + "properties": { "id": { "type": "string" }, "note": { "type": ["string", "null"] } }, + "required": ["id"] + } + """).RootElement; + JsonSerializerOptions options = new(McpJsonUtilities.DefaultOptions) + { + TypeInfoResolver = JsonTypeInfoResolver.Combine(McpJsonUtilities.DefaultOptions.TypeInfoResolver, new DefaultJsonTypeInfoResolver()), + }; + McpServerTool tool = McpServerTool.Create( + () => new ExplicitSchemaOutput { Id = "42", Note = null }, + new() { Name = "tool", UseStructuredContent = true, OutputSchema = outputSchema, SerializerOptions = options }); + + var result = await tool.InvokeAsync(CreateRequestContextWithProtocolVersion(Sep2106ProtocolVersion), TestContext.Current.CancellationToken); + + Assert.NotNull(result.StructuredContent); + Assert.Equal(JsonValueKind.Null, result.StructuredContent.Value.GetProperty("note").ValueKind); + AssertMatchesJsonSchema(outputSchema, result.StructuredContent); + } + + [Fact] + public async Task StructuredOutput_Enabled_ExplicitSchema_RepairsMemberLevelDefaultIgnore() + { + JsonElement outputSchema = JsonDocument.Parse("""{"type":"object","properties":{"id":{"type":"string"},"count":{"type":["integer","null"]}},"required":["id","count"]}""").RootElement; + JsonSerializerOptions options = new(McpJsonUtilities.DefaultOptions) + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + TypeInfoResolver = JsonTypeInfoResolver.Combine(McpJsonUtilities.DefaultOptions.TypeInfoResolver, new DefaultJsonTypeInfoResolver()), + }; + McpServerTool tool = McpServerTool.Create( + () => new ExplicitSchemaOutputWithMemberDefault { Id = "42", Count = 0 }, + new() { Name = "tool", UseStructuredContent = true, OutputSchema = outputSchema, SerializerOptions = options }); + + var result = await tool.InvokeAsync(CreateRequestContextWithProtocolVersion(Sep2106ProtocolVersion), TestContext.Current.CancellationToken); + + Assert.NotNull(result.StructuredContent); + Assert.Equal(0, result.StructuredContent.Value.GetProperty("count").GetInt32()); + AssertMatchesJsonSchema(outputSchema, result.StructuredContent); + } + + [Fact] + public async Task StructuredOutput_Enabled_ExplicitSchema_PreservesAlreadyValidAnyOfBranch() + { + JsonElement outputSchema = JsonDocument.Parse(""" + { + "type": "object", + "anyOf": [ + { + "properties": { + "count": { "type": "integer", "minimum": 1 }, + "child": { "type": "object", "properties": { "id": { "type": "string" }, "note": { "type": ["string", "null"] } }, "required": ["id", "note"] } + }, + "required": ["count", "child"] + }, + { + "properties": { "count": { "const": 0 }, "child": { "type": "object", "properties": { "id": { "type": "string" } }, "required": ["id"], "additionalProperties": false } }, + "required": ["count", "child"] + } + ] + } + """).RootElement; + JsonSerializerOptions options = new(McpJsonUtilities.DefaultOptions) + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingDefault, + TypeInfoResolver = JsonTypeInfoResolver.Combine(McpJsonUtilities.DefaultOptions.TypeInfoResolver, new DefaultJsonTypeInfoResolver()), + }; + McpServerTool tool = McpServerTool.Create( + () => new AnyOfStructuredOutput { Count = 0, Child = new() { Id = "42", Note = null } }, + new() { Name = "tool", UseStructuredContent = true, OutputSchema = outputSchema, SerializerOptions = options }); + + var result = await tool.InvokeAsync(CreateRequestContextWithProtocolVersion(Sep2106ProtocolVersion), TestContext.Current.CancellationToken); + + Assert.NotNull(result.StructuredContent); + Assert.Equal(0, result.StructuredContent.Value.GetProperty("count").GetInt32()); + Assert.False(result.StructuredContent.Value.GetProperty("child").TryGetProperty("note", out _)); + AssertMatchesJsonSchema(outputSchema, result.StructuredContent); + } + + [Fact] + public async Task StructuredOutput_Enabled_ExplicitSchema_RepairsLargeIntegerBranch() + { + JsonElement outputSchema = JsonDocument.Parse(""" + { + "anyOf": [ + { "properties": { "value": { "type": "integer" }, "note": { "type": ["string", "null"] } }, "required": ["value", "note"] }, + { "type": "null" } + ] + } + """).RootElement; + JsonSerializerOptions options = new(McpJsonUtilities.DefaultOptions) + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + TypeInfoResolver = JsonTypeInfoResolver.Combine(McpJsonUtilities.DefaultOptions.TypeInfoResolver, new DefaultJsonTypeInfoResolver()), + }; + McpServerTool tool = McpServerTool.Create( + () => new LargeIntegerStructuredOutput { Value = ulong.MaxValue, Note = null }, + new() { Name = "tool", UseStructuredContent = true, OutputSchema = outputSchema, SerializerOptions = options }); + + var result = await tool.InvokeAsync(CreateRequestContextWithProtocolVersion(Sep2106ProtocolVersion), TestContext.Current.CancellationToken); + + Assert.NotNull(result.StructuredContent); + Assert.Equal(ulong.MaxValue, result.StructuredContent.Value.GetProperty("value").GetUInt64()); + Assert.Equal(JsonValueKind.Null, result.StructuredContent.Value.GetProperty("note").ValueKind); + AssertMatchesJsonSchema(outputSchema, result.StructuredContent); + } + + [Fact] + public async Task StructuredOutput_Enabled_ExplicitSchema_RepairsMathematicallyIntegralDecimal() + { + JsonElement outputSchema = JsonDocument.Parse(""" + { + "anyOf": [ + { "properties": { "value": { "type": "integer" }, "note": { "type": ["string", "null"] } }, "required": ["value", "note"] }, + { "type": "null" } + ] + } + """).RootElement; + using JsonDocument integerDocument = JsonDocument.Parse("1.0"); + JsonElement integerValue = integerDocument.RootElement.Clone(); + JsonSerializerOptions options = new(McpJsonUtilities.DefaultOptions) + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + TypeInfoResolver = JsonTypeInfoResolver.Combine(McpJsonUtilities.DefaultOptions.TypeInfoResolver, new DefaultJsonTypeInfoResolver()), + }; + McpServerTool tool = McpServerTool.Create( + () => new MathematicalIntegerStructuredOutput { Value = integerValue, Note = null }, + new() { Name = "tool", UseStructuredContent = true, OutputSchema = outputSchema, SerializerOptions = options }); + + var result = await tool.InvokeAsync(CreateRequestContextWithProtocolVersion(Sep2106ProtocolVersion), TestContext.Current.CancellationToken); + + Assert.NotNull(result.StructuredContent); + Assert.Equal(1, result.StructuredContent.Value.GetProperty("value").GetDouble()); + Assert.Equal(JsonValueKind.Null, result.StructuredContent.Value.GetProperty("note").ValueKind); + AssertMatchesJsonSchema(outputSchema, result.StructuredContent); + } + + [Fact] + public async Task StructuredOutput_Enabled_ExplicitSchema_UsesStructuralConstEquality() + { + JsonElement outputSchema = JsonDocument.Parse(""" + { + "type": "object", + "anyOf": [ + { "properties": { "tag": { "const": { "b": 2, "a": 1 } }, "note": { "type": ["string", "null"] } }, "required": ["tag", "note"] }, + { "properties": { "tag": { "const": { "different": true } } }, "required": ["tag"] } + ] + } + """).RootElement; + JsonSerializerOptions options = new(McpJsonUtilities.DefaultOptions) + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + TypeInfoResolver = JsonTypeInfoResolver.Combine(McpJsonUtilities.DefaultOptions.TypeInfoResolver, new DefaultJsonTypeInfoResolver()), + }; + McpServerTool tool = McpServerTool.Create( + () => new StructuralConstOutput { Tag = new JsonObject { ["a"] = 1, ["b"] = 2 }, Note = null }, + new() { Name = "tool", UseStructuredContent = true, OutputSchema = outputSchema, SerializerOptions = options }); + + var result = await tool.InvokeAsync(CreateRequestContextWithProtocolVersion(Sep2106ProtocolVersion), TestContext.Current.CancellationToken); + + Assert.NotNull(result.StructuredContent); + Assert.Equal(JsonValueKind.Null, result.StructuredContent.Value.GetProperty("note").ValueKind); + AssertMatchesJsonSchema(outputSchema, result.StructuredContent); + } + + [Fact] + public async Task StructuredOutput_Enabled_ExplicitSchema_UsesStructuralEnumEquality() + { + JsonElement outputSchema = JsonDocument.Parse(""" + { + "type": "object", + "anyOf": [ + { "properties": { "tag": { "enum": [{ "b": 2, "a": 1 }] }, "note": { "type": ["string", "null"] } }, "required": ["tag", "note"] }, + { "properties": { "tag": { "enum": [{ "different": true }] } }, "required": ["tag"] } + ] + } + """).RootElement; + JsonSerializerOptions options = new(McpJsonUtilities.DefaultOptions) + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + TypeInfoResolver = JsonTypeInfoResolver.Combine(McpJsonUtilities.DefaultOptions.TypeInfoResolver, new DefaultJsonTypeInfoResolver()), + }; + McpServerTool tool = McpServerTool.Create( + () => new StructuralConstOutput { Tag = new JsonObject { ["a"] = 1, ["b"] = 2 }, Note = null }, + new() { Name = "tool", UseStructuredContent = true, OutputSchema = outputSchema, SerializerOptions = options }); + + var result = await tool.InvokeAsync(CreateRequestContextWithProtocolVersion(Sep2106ProtocolVersion), TestContext.Current.CancellationToken); + + Assert.NotNull(result.StructuredContent); + Assert.Equal(JsonValueKind.Null, result.StructuredContent.Value.GetProperty("note").ValueKind); + AssertMatchesJsonSchema(outputSchema, result.StructuredContent); + } + + [Fact] + public async Task StructuredOutput_Enabled_ExplicitSchema_UsesJsonNumericEquality() + { + JsonElement outputSchema = JsonDocument.Parse(""" + { + "type": "object", + "anyOf": [ + { "properties": { "count": { "const": 1.0 }, "note": { "type": ["string", "null"] } }, "required": ["count", "note"] }, + { "properties": { "count": { "const": 2 } }, "required": ["count"] } + ] + } + """).RootElement; + JsonSerializerOptions options = new(McpJsonUtilities.DefaultOptions) + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + TypeInfoResolver = JsonTypeInfoResolver.Combine(McpJsonUtilities.DefaultOptions.TypeInfoResolver, new DefaultJsonTypeInfoResolver()), + }; + McpServerTool tool = McpServerTool.Create( + () => new NumericConstOutput { Count = 1, Note = null }, + new() { Name = "tool", UseStructuredContent = true, OutputSchema = outputSchema, SerializerOptions = options }); + + var result = await tool.InvokeAsync(CreateRequestContextWithProtocolVersion(Sep2106ProtocolVersion), TestContext.Current.CancellationToken); + + Assert.NotNull(result.StructuredContent); + Assert.Equal(JsonValueKind.Null, result.StructuredContent.Value.GetProperty("note").ValueKind); + AssertMatchesJsonSchema(outputSchema, result.StructuredContent); + } + + [Fact] + public async Task StructuredOutput_Enabled_ExplicitSchema_HandlesNotConditionalForNull() + { + JsonElement outputSchema = JsonDocument.Parse(""" + { + "type": "object", + "properties": { "id": { "type": "string" }, "note": { "if": { "not": { "type": "null" } }, "then": { "type": "string" }, "else": { "type": "null" } } }, + "required": ["id", "note"] + } + """).RootElement; + JsonSerializerOptions options = new(McpJsonUtilities.DefaultOptions) + { + TypeInfoResolver = JsonTypeInfoResolver.Combine(McpJsonUtilities.DefaultOptions.TypeInfoResolver, new DefaultJsonTypeInfoResolver()), + }; + McpServerTool tool = McpServerTool.Create( + () => new ExplicitSchemaOutput { Id = "42", Note = null }, + new() { Name = "tool", UseStructuredContent = true, OutputSchema = outputSchema, SerializerOptions = options }); + + var result = await tool.InvokeAsync(CreateRequestContextWithProtocolVersion(Sep2106ProtocolVersion), TestContext.Current.CancellationToken); + + Assert.NotNull(result.StructuredContent); + Assert.Equal(JsonValueKind.Null, result.StructuredContent.Value.GetProperty("note").ValueKind); + AssertMatchesJsonSchema(outputSchema, result.StructuredContent); + } + + [Fact] + public async Task StructuredOutput_Enabled_ExplicitSchema_DoesNotRecoverThroughFilteredExtensionCollision() + { + DefaultJsonTypeInfoResolver resolver = new(); + resolver.Modifiers.Add(typeInfo => + { + if (typeInfo.Type == typeof(FilteredExtensionCollision)) + { + typeInfo.Properties.Single(property => property.Name == "child").ShouldSerialize = (_, _) => false; + } + }); + JsonSerializerOptions options = new(McpJsonUtilities.DefaultOptions) + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingDefault, + TypeInfoResolver = resolver, + }; + McpServerTool tool = CreateStructuredOutputTestTool(new FilteredExtensionCollision(), """ + {"properties":{"child":{"required":["count"]}}} + """, options); + JsonElement result = await InvokeStructuredOutputTestTool(tool); + Assert.False(result.GetProperty("child").TryGetProperty("count", out _)); + } + + private sealed class FilteredExtensionCollision + { + public FilteredExtensionChild Child { get; } = new(); + + [JsonExtensionData] + public Dictionary Extension { get; } = new() + { + ["child"] = JsonDocument.Parse("{}").RootElement, + }; + } + + private sealed class FilteredExtensionChild + { + public int Count { get; } = 42; + } + + [Fact] + public async Task StructuredOutput_Enabled_ExplicitSchema_DoesNotRestoreCallerFilteredProperty() + { + JsonElement outputSchema = JsonDocument.Parse("""{"type":"object","properties":{"secret":{"type":"string"}},"required":["secret"]}""").RootElement; + DefaultJsonTypeInfoResolver resolver = new(); + resolver.Modifiers.Add(typeInfo => + { + if (typeInfo.Type == typeof(ExplicitSchemaFilteredOutput)) + { + typeInfo.Properties.Single(property => property.Name == "secret").ShouldSerialize = (_, _) => false; + } + }); + JsonSerializerOptions options = new(McpJsonUtilities.DefaultOptions) + { + TypeInfoResolver = JsonTypeInfoResolver.Combine(resolver, new DefaultJsonTypeInfoResolver()), + }; + McpServerTool tool = McpServerTool.Create( + () => new ExplicitSchemaFilteredOutput { Secret = "do-not-publish" }, + new() { Name = "tool", UseStructuredContent = true, OutputSchema = outputSchema, SerializerOptions = options }); + + var result = await tool.InvokeAsync(CreateRequestContextWithProtocolVersion(Sep2106ProtocolVersion), TestContext.Current.CancellationToken); + + Assert.NotNull(result.StructuredContent); + Assert.False(result.StructuredContent.Value.TryGetProperty("secret", out _)); + } + + [Fact] + public async Task StructuredOutput_Enabled_ExplicitSchema_RespectsCallerFilterForMemberDefault() + { + JsonElement outputSchema = JsonDocument.Parse("""{"type":"object","properties":{"count":{"type":"integer"},"nullableCount":{"type":"integer"}},"required":["count","nullableCount"]}""").RootElement; + DefaultJsonTypeInfoResolver resolver = new(); + resolver.Modifiers.Add(typeInfo => + { + if (typeInfo.Type == typeof(ExplicitSchemaFilteredDefaultOutput)) + { + foreach (JsonPropertyInfo property in typeInfo.Properties) + { + property.ShouldSerialize = (_, _) => false; + } + } + }); + JsonSerializerOptions options = new(McpJsonUtilities.DefaultOptions) + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingDefault, + TypeInfoResolver = JsonTypeInfoResolver.Combine(resolver, new DefaultJsonTypeInfoResolver()), + }; + McpServerTool tool = McpServerTool.Create( + () => new ExplicitSchemaFilteredDefaultOutput(), + new() { Name = "tool", UseStructuredContent = true, OutputSchema = outputSchema, SerializerOptions = options }); + + var result = await tool.InvokeAsync(CreateRequestContextWithProtocolVersion(Sep2106ProtocolVersion), TestContext.Current.CancellationToken); + + Assert.NotNull(result.StructuredContent); + Assert.False(result.StructuredContent.Value.TryGetProperty("count", out _)); + Assert.False(result.StructuredContent.Value.TryGetProperty("nullableCount", out _)); + } + + [Fact] + public async Task StructuredOutput_Enabled_ExplicitSchema_DoesNotSerializeAlreadyValidOutputAgain() + { + int getterCalls = 0; + JsonElement outputSchema = JsonDocument.Parse("""{"type":"object","properties":{"id":{"type":"string"}},"required":["id"]}""").RootElement; + JsonSerializerOptions options = new(McpJsonUtilities.DefaultOptions) + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + TypeInfoResolver = JsonTypeInfoResolver.Combine(McpJsonUtilities.DefaultOptions.TypeInfoResolver, new DefaultJsonTypeInfoResolver()), + }; + McpServerTool tool = McpServerTool.Create( + () => new GetterCountedStructuredOutput(() => getterCalls++), + new() { Name = "tool", UseStructuredContent = true, OutputSchema = outputSchema, SerializerOptions = options }); + + var result = await tool.InvokeAsync(CreateRequestContextWithProtocolVersion(Sep2106ProtocolVersion), TestContext.Current.CancellationToken); + + Assert.NotNull(result.StructuredContent); + Assert.Equal("42", result.StructuredContent.Value.GetProperty("id").GetString()); + // The result is serialized once for structured content and once for text content. + Assert.Equal(2, getterCalls); + } + + [Fact] + public async Task StructuredOutput_Enabled_ExplicitSchema_DoesNotLookUpValuesPresentInCompleteSnapshot() + { + const int itemCount = 100; + JsonElement outputSchema = JsonDocument.Parse(""" + {"type":"object","properties":{"items":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"note":{"type":["string","null"]}},"required":["id","note"]}}},"required":["items"]} + """).RootElement; + JsonSerializerOptions options = new(McpJsonUtilities.DefaultOptions) + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + TypeInfoResolver = JsonTypeInfoResolver.Combine(McpJsonUtilities.DefaultOptions.TypeInfoResolver, new DefaultJsonTypeInfoResolver()), + }; + GetterCountedCollectionStructuredOutput output = new( + Enumerable.Range(0, itemCount).Select(index => new ExplicitSchemaCollectionItem { Id = index.ToString() }).ToList()); + McpServerTool tool = McpServerTool.Create( + () => output, + new() { Name = "tool", UseStructuredContent = true, OutputSchema = outputSchema, SerializerOptions = options }); + + var result = await tool.InvokeAsync(CreateRequestContextWithProtocolVersion(Sep2106ProtocolVersion), TestContext.Current.CancellationToken); + + Assert.NotNull(result.StructuredContent); + Assert.Equal(itemCount, result.StructuredContent.Value.GetProperty("items").GetArrayLength()); + AssertMatchesJsonSchema(outputSchema, result.StructuredContent); + // Structured output, text output, and the complete-value snapshot each access the collection once. + Assert.Equal(3, output.ItemsGetterCalls); + } + + [Fact] + public async Task StructuredOutput_Enabled_ExplicitSchema_RepairsNestedDefaultValueInPasses() + { + JsonElement outputSchema = JsonDocument.Parse(""" + {"type":"object","properties":{"child":{"type":"object","properties":{"note":{"type":["string","null"]}},"required":["note"]}},"required":["child"]} + """).RootElement; + JsonSerializerOptions options = new(McpJsonUtilities.DefaultOptions) + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingDefault, + TypeInfoResolver = JsonTypeInfoResolver.Combine(McpJsonUtilities.DefaultOptions.TypeInfoResolver, new DefaultJsonTypeInfoResolver()), + }; + McpServerTool tool = McpServerTool.Create( + () => new NestedDefaultedStructuredOutput { Child = default }, + new() { Name = "tool", UseStructuredContent = true, OutputSchema = outputSchema, SerializerOptions = options }); + + var result = await tool.InvokeAsync(CreateRequestContextWithProtocolVersion(Sep2106ProtocolVersion), TestContext.Current.CancellationToken); + + Assert.NotNull(result.StructuredContent); + Assert.Equal(JsonValueKind.Null, result.StructuredContent.Value.GetProperty("child").GetProperty("note").ValueKind); + AssertMatchesJsonSchema(outputSchema, result.StructuredContent); + } + + [Fact] + public async Task StructuredOutput_Enabled_ExplicitSchema_RepairsRequiredPropertyFromDependentSchema() + { + JsonElement outputSchema = JsonDocument.Parse(""" + {"type":"object","properties":{"id":{"type":"string"},"note":{"type":["string","null"]}},"dependentSchemas":{"id":{"required":["note"]}}} + """).RootElement; + JsonSerializerOptions options = new(McpJsonUtilities.DefaultOptions) + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + TypeInfoResolver = JsonTypeInfoResolver.Combine(McpJsonUtilities.DefaultOptions.TypeInfoResolver, new DefaultJsonTypeInfoResolver()), + }; + McpServerTool tool = McpServerTool.Create( + () => new ExplicitSchemaOutput { Id = "42", Note = null }, + new() { Name = "tool", UseStructuredContent = true, OutputSchema = outputSchema, SerializerOptions = options }); + + var result = await tool.InvokeAsync(CreateRequestContextWithProtocolVersion(Sep2106ProtocolVersion), TestContext.Current.CancellationToken); + + Assert.NotNull(result.StructuredContent); + Assert.Equal(JsonValueKind.Null, result.StructuredContent.Value.GetProperty("note").ValueKind); + AssertMatchesJsonSchema(outputSchema, result.StructuredContent); + } + + [Fact] + public async Task StructuredOutput_Enabled_ExplicitSchema_RepairsRequiredPropertyFromDependentRequired() + { + JsonElement outputSchema = JsonDocument.Parse(""" + {"type":"object","properties":{"id":{"type":"string"},"note":{"type":["string","null"]}},"required":["id"],"dependentRequired":{"id":["note"]}} + """).RootElement; + JsonSerializerOptions options = new(McpJsonUtilities.DefaultOptions) + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + TypeInfoResolver = JsonTypeInfoResolver.Combine(McpJsonUtilities.DefaultOptions.TypeInfoResolver, new DefaultJsonTypeInfoResolver()), + }; + McpServerTool tool = McpServerTool.Create( + () => new ExplicitSchemaOutput { Id = "42", Note = null }, + new() { Name = "tool", UseStructuredContent = true, OutputSchema = outputSchema, SerializerOptions = options }); + + var result = await tool.InvokeAsync(CreateRequestContextWithProtocolVersion(Sep2106ProtocolVersion), TestContext.Current.CancellationToken); + + Assert.NotNull(result.StructuredContent); + Assert.Equal(JsonValueKind.Null, result.StructuredContent.Value.GetProperty("note").ValueKind); + AssertMatchesJsonSchema(outputSchema, result.StructuredContent); + } + + [Fact] + public async Task StructuredOutput_Enabled_ExplicitSchema_TraversesMatchingPatternForExplicitProperty() + { + JsonElement outputSchema = JsonDocument.Parse(""" + {"type":"object","properties":{"child":{"type":"object","properties":{"id":{"type":"string"}},"required":["id"]}},"patternProperties":{"^child$":{"type":"object","properties":{"note":{"type":["string","null"]}},"required":["note"]}}} + """).RootElement; + JsonSerializerOptions options = new(McpJsonUtilities.DefaultOptions) + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + TypeInfoResolver = JsonTypeInfoResolver.Combine(McpJsonUtilities.DefaultOptions.TypeInfoResolver, new DefaultJsonTypeInfoResolver()), + }; + McpServerTool tool = McpServerTool.Create( + () => new ExplicitSchemaPatternChildOutput { Child = new() { Id = "42", Note = null } }, + new() { Name = "tool", UseStructuredContent = true, OutputSchema = outputSchema, SerializerOptions = options }); + + var result = await tool.InvokeAsync(CreateRequestContextWithProtocolVersion(Sep2106ProtocolVersion), TestContext.Current.CancellationToken); + + Assert.NotNull(result.StructuredContent); + Assert.Equal(JsonValueKind.Null, result.StructuredContent.Value.GetProperty("child").GetProperty("note").ValueKind); + AssertMatchesJsonSchema(outputSchema, result.StructuredContent); + } + + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(2)] + [InlineData(3)] + [InlineData(4)] + [InlineData(5)] + [InlineData(6)] + [InlineData(7)] + [InlineData(8)] + [InlineData(9)] + public async Task StructuredOutput_Enabled_ExplicitSchema_SelectsAValidBranchFromBroadUnion(int tag) + { + string branchSchemas = string.Join(",", Enumerable.Range(0, 10).Select(index => + $$$"""{"type":"object","properties":{"tag":{"const":"{{{index}}}"},"note{{{index}}}":{"type":["string","null"]}},"required":["tag","note{{{index}}}"],"additionalProperties":false}""")); + using JsonDocument schemaDocument = JsonDocument.Parse($$"""{"type":"object","oneOf":[{{branchSchemas}}]}"""); + JsonElement outputSchema = schemaDocument.RootElement; + JsonSerializerOptions options = new(McpJsonUtilities.DefaultOptions) + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + TypeInfoResolver = JsonTypeInfoResolver.Combine(McpJsonUtilities.DefaultOptions.TypeInfoResolver, new DefaultJsonTypeInfoResolver()), + }; + McpServerTool tool = McpServerTool.Create( + () => new TenBranchStructuredOutput { Tag = tag.ToString(System.Globalization.CultureInfo.InvariantCulture) }, + new() { Name = "tool", UseStructuredContent = true, OutputSchema = outputSchema, SerializerOptions = options }); + + var result = await tool.InvokeAsync(CreateRequestContextWithProtocolVersion(Sep2106ProtocolVersion), TestContext.Current.CancellationToken); + + Assert.NotNull(result.StructuredContent); + Assert.Equal(JsonValueKind.Null, result.StructuredContent.Value.GetProperty($"note{tag}").ValueKind); + AssertMatchesJsonSchema(outputSchema, result.StructuredContent); + } + + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(2)] + public async Task StructuredOutput_Enabled_ExplicitSchema_SelectsBranchWithTwoRequiredMembers(int tag) + { + string branches = string.Join(",", Enumerable.Range(0, 3).Select(index => + $$$"""{"type":"object","properties":{"tag":{"const":"{{{index}}}"},"note{{{index * 2}}}":{"type":["string","null"]},"note{{{index * 2 + 1}}}":{"type":["string","null"]}},"required":["tag","note{{{index * 2}}}","note{{{index * 2 + 1}}}"],"additionalProperties":false}""")); + using JsonDocument document = JsonDocument.Parse($$"""{"type":"object","oneOf":[{{branches}}]}"""); + JsonElement outputSchema = document.RootElement; + JsonSerializerOptions options = new(McpJsonUtilities.DefaultOptions) + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + TypeInfoResolver = JsonTypeInfoResolver.Combine(McpJsonUtilities.DefaultOptions.TypeInfoResolver, new DefaultJsonTypeInfoResolver()), + }; + McpServerTool tool = McpServerTool.Create( + () => new TenBranchStructuredOutput { Tag = tag.ToString(System.Globalization.CultureInfo.InvariantCulture) }, + new() { Name = "tool", UseStructuredContent = true, OutputSchema = outputSchema, SerializerOptions = options }); + + var result = await tool.InvokeAsync(CreateRequestContextWithProtocolVersion(Sep2106ProtocolVersion), TestContext.Current.CancellationToken); + + Assert.NotNull(result.StructuredContent); + AssertMatchesJsonSchema(outputSchema, result.StructuredContent); + Assert.Equal(JsonValueKind.Null, result.StructuredContent.Value.GetProperty($"note{tag * 2}").ValueKind); + Assert.Equal(JsonValueKind.Null, result.StructuredContent.Value.GetProperty($"note{tag * 2 + 1}").ValueKind); + } + + [Fact] + public async Task StructuredOutput_Enabled_ExplicitSchema_DoesNotInventOneOfProperty() + { + JsonElement outputSchema = JsonDocument.Parse(""" + {"type":"object","oneOf":[{"properties":{"note":{"type":["string","null"]}},"required":["note"]},{"properties":{"other":{"type":["string","null"]}},"required":["other"]}]} + """).RootElement; + JsonSerializerOptions options = new(McpJsonUtilities.DefaultOptions) + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + TypeInfoResolver = JsonTypeInfoResolver.Combine(McpJsonUtilities.DefaultOptions.TypeInfoResolver, new DefaultJsonTypeInfoResolver()), + }; + McpServerTool tool = McpServerTool.Create( + () => new ExplicitSchemaOneOfSourceOnlyOutput { Id = "42", Note = null }, + new() { Name = "tool", UseStructuredContent = true, OutputSchema = outputSchema, SerializerOptions = options }); + + var result = await tool.InvokeAsync(CreateRequestContextWithProtocolVersion(Sep2106ProtocolVersion), TestContext.Current.CancellationToken); + + Assert.NotNull(result.StructuredContent); + Assert.Equal(JsonValueKind.Null, result.StructuredContent.Value.GetProperty("note").ValueKind); + Assert.False(result.StructuredContent.Value.TryGetProperty("other", out _)); + AssertMatchesJsonSchema(outputSchema, result.StructuredContent); + } + + [Fact] + public async Task StructuredOutput_Enabled_ExplicitSchema_RepairsNestedOneOfWithoutMixingBranches() + { + JsonElement outputSchema = JsonDocument.Parse(""" + { + "oneOf": [ + {"type":"object","properties":{"first":{"type":"object","properties":{"note":{"type":["string","null"]}},"required":["note"],"additionalProperties":false}},"required":["first"],"additionalProperties":false}, + {"type":"object","properties":{"second":{"type":"object","properties":{"note":{"type":["string","null"]}},"required":["note"],"additionalProperties":false}},"required":["second"],"additionalProperties":false} + ] + } + """).RootElement; + JsonSerializerOptions options = new(McpJsonUtilities.DefaultOptions) + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingDefault, + TypeInfoResolver = JsonTypeInfoResolver.Combine(McpJsonUtilities.DefaultOptions.TypeInfoResolver, new DefaultJsonTypeInfoResolver()), + }; + McpServerTool tool = McpServerTool.Create( + () => new NestedOneOfRecoveryOutput(), + new() { Name = "tool", UseStructuredContent = true, OutputSchema = outputSchema, SerializerOptions = options }); + + var result = await tool.InvokeAsync(CreateRequestContextWithProtocolVersion(Sep2106ProtocolVersion), TestContext.Current.CancellationToken); + + Assert.NotNull(result.StructuredContent); + Assert.Equal(JsonValueKind.Null, result.StructuredContent.Value.GetProperty("first").GetProperty("note").ValueKind); + Assert.False(result.StructuredContent.Value.TryGetProperty("second", out _)); + AssertMatchesJsonSchema(outputSchema, result.StructuredContent); + } + + [Fact] + public async Task StructuredOutput_Enabled_ExplicitSchema_RepairsMatchingOneOfBranchWithoutOtherCandidates() + { + JsonElement outputSchema = JsonDocument.Parse(""" + { + "type": "object", + "oneOf": [ + { + "properties": { + "tag": { "const": "0" }, + "child": { + "type": "object", + "properties": { "note": { "type": ["string", "null"] } }, + "required": ["note"] + } + }, + "required": ["tag", "child"], + "additionalProperties": false + }, + { + "properties": { + "tag": { "const": "1" }, + "other": { "type": ["string", "null"] } + }, + "required": ["tag", "other"], + "additionalProperties": false + } + ] + } + """).RootElement; + JsonSerializerOptions options = new(McpJsonUtilities.DefaultOptions) + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingDefault, + TypeInfoResolver = JsonTypeInfoResolver.Combine(McpJsonUtilities.DefaultOptions.TypeInfoResolver, new DefaultJsonTypeInfoResolver()), + }; + McpServerTool tool = McpServerTool.Create( + () => new BranchSpecificOneOfRecoveryOutput { Tag = "0", Child = default, Other = null }, + new() { Name = "tool", UseStructuredContent = true, OutputSchema = outputSchema, SerializerOptions = options }); + + var result = await tool.InvokeAsync(CreateRequestContextWithProtocolVersion(Sep2106ProtocolVersion), TestContext.Current.CancellationToken); + + Assert.NotNull(result.StructuredContent); + Assert.Equal("0", result.StructuredContent.Value.GetProperty("tag").GetString()); + Assert.Equal(JsonValueKind.Null, result.StructuredContent.Value.GetProperty("child").GetProperty("note").ValueKind); + Assert.False(result.StructuredContent.Value.TryGetProperty("other", out _)); + AssertMatchesJsonSchema(outputSchema, result.StructuredContent); + } + + [Fact] + public async Task StructuredOutput_Enabled_ExplicitSchema_RecoversIntegerDictionaryKey() + { + JsonElement outputSchema = JsonDocument.Parse(""" + {"type":"object","properties":{"items":{"type":"object","properties":{"42":{"type":"object","properties":{"count":{"type":"integer"}},"required":["count"]}},"required":["42"]}},"required":["items"]} + """).RootElement; + JsonSerializerOptions options = new(McpJsonUtilities.DefaultOptions) + { + TypeInfoResolver = JsonTypeInfoResolver.Combine(McpJsonUtilities.DefaultOptions.TypeInfoResolver, new DefaultJsonTypeInfoResolver()), + }; + McpServerTool tool = McpServerTool.Create( + () => new IntegerKeyDictionaryOutput { Items = new() { [42] = new() { Count = 0 } } }, + new() { Name = "tool", UseStructuredContent = true, OutputSchema = outputSchema, SerializerOptions = options }); + + var result = await tool.InvokeAsync(CreateRequestContextWithProtocolVersion(Sep2106ProtocolVersion), TestContext.Current.CancellationToken); + + Assert.NotNull(result.StructuredContent); + Assert.Equal(0, result.StructuredContent.Value.GetProperty("items").GetProperty("42").GetProperty("count").GetInt32()); + AssertMatchesJsonSchema(outputSchema, result.StructuredContent); + } + + [Fact] + public async Task StructuredOutput_Enabled_ExplicitSchema_PreservesPropertyNumberHandlingOnRecovery() + { + JsonElement outputSchema = JsonDocument.Parse("""{"type":"object","properties":{"count":{"type":"string"}},"required":["count"]}""").RootElement; + JsonSerializerOptions options = new(McpJsonUtilities.DefaultOptions) + { + TypeInfoResolver = JsonTypeInfoResolver.Combine(McpJsonUtilities.DefaultOptions.TypeInfoResolver, new DefaultJsonTypeInfoResolver()), + }; + McpServerTool tool = McpServerTool.Create( + () => new ExplicitSchemaOutputWithNumberHandling { Count = 0 }, + new() { Name = "tool", UseStructuredContent = true, OutputSchema = outputSchema, SerializerOptions = options }); + + var result = await tool.InvokeAsync(CreateRequestContextWithProtocolVersion(Sep2106ProtocolVersion), TestContext.Current.CancellationToken); + + Assert.NotNull(result.StructuredContent); + Assert.Equal("0", result.StructuredContent.Value.GetProperty("count").GetString()); + AssertMatchesJsonSchema(outputSchema, result.StructuredContent); + } + + [Fact] + public async Task StructuredOutput_Enabled_ExplicitSchema_RefreshesChangedSchema() + { + McpServerTool tool = CreateStructuredOutputTestTool( + new ExplicitSchemaOutput { Id = "42" }, """{"$id":"https://example.test/mutable","$anchor":"entry","type":"object"}"""); + await InvokeStructuredOutputTestTool(tool); + + tool.ProtocolTool.OutputSchema = JsonDocument.Parse( + """{"$id":"https://example.test/mutable","$anchor":"entry","type":"object","required":["note"],"properties":{"note":{"type":"null"}}}""").RootElement; + JsonElement result = await InvokeStructuredOutputTestTool(tool); + Assert.Equal(JsonValueKind.Null, result.GetProperty("note").ValueKind); + AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema.Value, result); + + tool.ProtocolTool.OutputSchema = JsonDocument.Parse( + """{"$id":"https://example.test/mutable","$anchor":"entry","type":"object","properties":{"note":false}}""").RootElement; + result = await InvokeStructuredOutputTestTool(tool); + Assert.False(result.TryGetProperty("note", out _)); + AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema.Value, result); + } + + [Theory] + [InlineData(1, 1)] + [InlineData(2, 2)] + [InlineData(1, 3)] + public async Task StructuredOutput_Enabled_ExplicitSchema_RepairsContainsMembers(int minimum, int maximum) + { + string schema = $$$""" + {"type":"array","contains":{"type":"object","required":["note"],"properties":{"note":{"type":"null"} } },"minContains":{{{minimum}}},"maxContains":{{{maximum}}}} + """; + McpServerTool tool = CreateStructuredOutputTestTool( + Enumerable.Range(0, 3).Select(index => new ExplicitSchemaOutput { Id = index.ToString() }).ToArray(), + schema); + JsonElement result = await InvokeStructuredOutputTestTool(tool); + AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result); + Assert.InRange(result.EnumerateArray().Count(item => item.TryGetProperty("note", out _)), minimum, maximum); + } + + [Fact] + public async Task StructuredOutput_Enabled_ExplicitSchema_PreservesConverterProducedNull() + { + McpServerTool tool = CreateStructuredOutputTestTool( + new DefaultRequestIdOutput(), + """{"type":"object","required":["id"],"properties":{"id":{"type":"null"}}}"""); + JsonElement result = await InvokeStructuredOutputTestTool(tool); + Assert.Equal(JsonValueKind.Null, result.GetProperty("id").ValueKind); + AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task StructuredOutput_Enabled_ExplicitSchema_RecoversReadOnlyDictionaryValue(bool customKeys) + { + var output = new ReadOnlyDictionary( + new Dictionary + { + [1] = new() { Count = 5, Status = ExplicitSchemaStatus.Ready }, + [42] = new() { Count = 0, Status = ExplicitSchemaStatus.Ready }, + }); + JsonSerializerOptions options = CreateStructuredOutputTestOptions(); + options.Converters.Insert(0, new JsonStringEnumConverter(allowIntegerValues: false)); + if (customKeys) + { + options.Converters.Add(new FormattedIntegerKeyConverter()); + } + + McpServerTool tool = CreateStructuredOutputTestTool( + output, + """{"type":"object","additionalProperties":{"type":"object","required":["count"],"properties":{"count":{"type":"integer"}}}}""", + options); + JsonElement result = await InvokeStructuredOutputTestTool(tool); + Assert.Equal(5, result.GetProperty(customKeys ? "key-1" : "1").GetProperty("count").GetInt32()); + Assert.Equal(0, result.GetProperty(customKeys ? "key-42" : "42").GetProperty("count").GetInt32()); + AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result); + } + + [Fact] + public async Task StructuredOutput_Enabled_ExplicitSchema_PreservesJsonElementMemberConverter() + { + McpServerTool tool = CreateStructuredOutputTestTool( + new DefaultJsonElementOutput(), + """{"type":"object","required":["value"],"properties":{"value":{"const":"converted"}}}"""); + JsonElement result = await InvokeStructuredOutputTestTool(tool); + Assert.Equal("converted", result.GetProperty("value").GetString()); + AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result); + } + + [Fact] + public async Task StructuredOutput_Enabled_ExplicitSchema_PrioritizesNestedRepairs() + { + string properties = string.Join(",", Enumerable.Range(0, 24).Select(index => + $$$""" "child{{{index}}}":{"type":"object","required":["note"],"properties":{"note":{"type":"null"} } }""")); + string required = string.Join(",", Enumerable.Range(0, 24).Select(index => $"\"child{index}\"")); + McpServerTool tool = CreateStructuredOutputTestTool( + new ManyNestedDefaultOutputs(), + $$"""{"type":"object","properties":{ {{properties}} },"required":[{{required}}]}""", + CreateStructuredOutputTestOptions(JsonIgnoreCondition.WhenWritingDefault)); + JsonElement result = await InvokeStructuredOutputTestTool(tool); + AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result); + for (int i = 0; i < 24; i++) + { + Assert.Equal(JsonValueKind.Null, result.GetProperty($"child{i}").GetProperty("note").ValueKind); + } + } + + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(2)] + [InlineData(3)] + [InlineData(4)] + [InlineData(5)] + [InlineData(6)] + [InlineData(7)] + public async Task StructuredOutput_Enabled_ExplicitSchema_PrioritizesBranchCandidateGroups(int tag) + { + string branches = string.Join(",", Enumerable.Range(0, 8).Select(index => + $$$""" + {"type":"object","properties":{"tag":{"const":"{{{index}}}"},"note{{{index * 3}}}":{"type":"null"},"note{{{index * 3 + 1}}}":{"type":"null"},"note{{{index * 3 + 2}}}":{"type":"null"}},"required":["tag","note{{{index * 3}}}","note{{{index * 3 + 1}}}","note{{{index * 3 + 2}}}"],"additionalProperties":false} + """)); + McpServerTool tool = CreateStructuredOutputTestTool( + new ManyBranchStructuredOutput { Tag = tag.ToString(System.Globalization.CultureInfo.InvariantCulture) }, + $$"""{"type":"object","oneOf":[{{branches}}]}"""); + JsonElement result = await InvokeStructuredOutputTestTool(tool); + AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result); + Assert.Equal(4, result.EnumerateObject().Count()); + for (int i = 0; i < 3; i++) + { + Assert.Equal(JsonValueKind.Null, result.GetProperty($"note{tag * 3 + i}").ValueKind); + } + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task StructuredOutput_Enabled_ExplicitSchema_RepairsUnevaluatedMembers(bool isArray) + { + object output = isArray + ? new[] { new ExplicitSchemaOutput { Id = "42" }, new ExplicitSchemaOutput { Id = "43" } } + : new Dictionary { ["evaluated"] = new() { Id = "42" }, ["other"] = new() { Id = "43" } }; + string schema = isArray + ? """{"type":"array","prefixItems":[{"type":"object","properties":{"note":false}}],"unevaluatedItems":{"type":"object","required":["note"],"properties":{"note":{"type":"null"}}}}""" + : """{"type":"object","properties":{"evaluated":{"type":"object","properties":{"note":false}}},"unevaluatedProperties":{"type":"object","required":["note"],"properties":{"note":{"type":"null"}}}}"""; + McpServerTool tool = CreateStructuredOutputTestTool(output, schema); + JsonElement result = await InvokeStructuredOutputTestTool(tool); + AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result); + JsonElement evaluated = isArray ? result[0] : result.GetProperty("evaluated"); + JsonElement unevaluated = isArray ? result[1] : result.GetProperty("other"); + Assert.False(evaluated.TryGetProperty("note", out _)); + Assert.Equal(JsonValueKind.Null, unevaluated.GetProperty("note").ValueKind); + } + + [Theory] + [InlineData("$ref", "$anchor")] + [InlineData("$ref", "$dynamicAnchor")] + [InlineData("$dynamicRef", "$dynamicAnchor")] + public async Task StructuredOutput_Enabled_ExplicitSchema_ResolvesNamedAnchor(string referenceKeyword, string anchorKeyword) + { + McpServerTool tool = CreateStructuredOutputTestTool( + new ExplicitSchemaOutput { Id = "42" }, + $$$"""{"type":"object","$defs":{"entry":{"{{{anchorKeyword}}}":"entry","type":"object","required":["note"]}},"{{{referenceKeyword}}}":"#entry"}"""); + JsonElement result = await InvokeStructuredOutputTestTool(tool); + Assert.Equal(JsonValueKind.Null, result.GetProperty("note").ValueKind); + AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task StructuredOutput_Enabled_ExplicitSchema_RespectsAnchorResourceBoundaries(bool nested) + { + string schema = nested + ? """{"$id":"https://example.test/root","type":"object","properties":{"child":{"$id":"child","$defs":{"entry":{"$anchor":"entry","required":["note"]}},"$ref":"#entry"}},"$defs":{"entry":{"$anchor":"entry","required":["other"]}}}""" + : """{"$id":"https://example.test/root","type":"object","$defs":{"foreign":{"$id":"foreign","$anchor":"entry","required":["other"]},"local":{"$anchor":"entry","required":["note"]}},"$ref":"#entry"}"""; + object output = nested + ? new ExplicitSchemaPatternChildOutput { Child = new() { Id = "42" } } + : new ExplicitSchemaOutput { Id = "42" }; + McpServerTool tool = CreateStructuredOutputTestTool(output, schema); + JsonElement result = await InvokeStructuredOutputTestTool(tool); + AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result); + Assert.Equal(JsonValueKind.Null, (nested ? result.GetProperty("child") : result).GetProperty("note").ValueKind); + } + + [Theory] + [InlineData("#entry")] + [InlineData("base#entry")] + [InlineData("https://example.test/base#entry")] + public async Task StructuredOutput_Enabled_ExplicitSchema_ResolvesDynamicAnchorInCallingScope(string reference) + { + McpServerTool tool = CreateStructuredOutputTestTool( + new DynamicReferenceOutput { Child = new() { Id = "42" } }, + $$$""" + { + "$id":"https://example.test/root", + "$dynamicAnchor":"entry", + "$schema":"https://json-schema.org/draft/2020-12/schema", + "$ref":"#/$defs/base", + "required":["note"], + "$defs":{ + "base":{ + "$id":"base", + "$dynamicAnchor":"entry", + "type":"object", + "properties":{"child":{"$dynamicRef":"{{{reference}}}"}} + } + } + } + """); + JsonElement result = await InvokeStructuredOutputTestTool(tool); + AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result); + Assert.Equal(JsonValueKind.Null, result.GetProperty("note").ValueKind); + Assert.Equal(JsonValueKind.Null, result.GetProperty("child").GetProperty("note").ValueKind); + } + + [Fact] + public async Task StructuredOutput_Enabled_ExplicitSchema_DoesNotRecoverFailedNullConversion() + { + McpServerTool tool = CreateStructuredOutputTestTool( + new RejectedNullOutput(), + """{"type":"object","required":["note"],"properties":{"note":{"type":"null"}}}"""); + JsonElement result = await InvokeStructuredOutputTestTool(tool); + Assert.False(result.TryGetProperty("note", out _)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task StructuredOutput_Enabled_ExplicitSchema_PreservesReadOnlyExclusions(bool isField) + { + JsonSerializerOptions options = CreateStructuredOutputTestOptions(JsonIgnoreCondition.WhenWritingDefault); + options.IgnoreReadOnlyProperties = !isField; + options.IgnoreReadOnlyFields = isField; + options.IncludeFields = isField; + object output = isField ? new ReadOnlyFieldOutput() : new ReadOnlyPropertyOutput(); + McpServerTool tool = CreateStructuredOutputTestTool(output, """{"type":"object","required":["count"]}""", options); + JsonElement result = await InvokeStructuredOutputTestTool(tool); + Assert.False(result.TryGetProperty("count", out _)); + } + + public static IEnumerable ReadOnlyRecoveryInputs => + Enumerable.Range(0, 32).Select(bits => new object[] + { + (bits & 1) != 0, (bits & 2) != 0, (bits & 4) != 0, + (bits & 8) != 0, (bits & 16) != 0, + }); + + [Theory] + [MemberData(nameof(ReadOnlyRecoveryInputs))] + public async Task StructuredOutput_Enabled_ExplicitSchema_PreservesReadOnlyMemberKindWithoutAttributes( + bool sourceGenerated, bool isField, bool ignoreProperties, bool ignoreFields, bool ignoreDefault) + { + JsonSerializerOptions options = CreateStructuredOutputTestOptions( + ignoreDefault ? JsonIgnoreCondition.WhenWritingDefault : JsonIgnoreCondition.WhenWritingNull); + options.IncludeFields = true; + options.IgnoreReadOnlyProperties = ignoreProperties; + options.IgnoreReadOnlyFields = ignoreFields; + IJsonTypeInfoResolver resolver = sourceGenerated ? ReadOnlyRecoveryJsonContext.Default : new DefaultJsonTypeInfoResolver(); + bool excluded = isField ? ignoreFields : ignoreProperties; + options.TypeInfoResolver = resolver.WithAddedModifier(typeInfo => + { + foreach (JsonPropertyInfo property in typeInfo.Properties) + { + property.AttributeProvider = null; + property.Name = "renamed_" + property.Name; + if (excluded && property.Get is not null) + { + property.Get = static _ => throw new InvalidOperationException("Excluded getter was invoked."); + } + } + }); + + object output = isField ? new ReadOnlyRecoveryField() : new ReadOnlyRecoveryProperty(); + string schema = ignoreDefault + ? """{"required":["renamed_note","renamed_count"]}""" + : """{"required":["renamed_note"]}"""; + McpServerTool tool = CreateStructuredOutputTestTool(output, schema, options); + JsonElement result = await InvokeStructuredOutputTestTool(tool); + + if (excluded) + { + Assert.Empty(result.EnumerateObject()); + } + else + { + AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result); + Assert.Equal(JsonValueKind.Null, result.GetProperty("renamed_note").ValueKind); + Assert.Equal(0, result.GetProperty("renamed_count").GetInt32()); + } + } + + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public async Task StructuredOutput_Enabled_ExplicitSchema_DoesNotReadExcludedConvertedCollection( + bool sourceGenerated, bool removeAttributes) + { + JsonSerializerOptions options = CreateStructuredOutputTestOptions(); + options.IgnoreReadOnlyProperties = true; + IJsonTypeInfoResolver resolver = sourceGenerated ? ReadOnlyRecoveryJsonContext.Default : new DefaultJsonTypeInfoResolver(); + options.TypeInfoResolver = resolver.WithAddedModifier(typeInfo => + { + if (removeAttributes) + { + foreach (JsonPropertyInfo property in typeInfo.Properties) + { + property.AttributeProvider = null; + } + } + }); + McpServerTool tool = CreateStructuredOutputTestTool(new ReadOnlyConvertedCollectionOutput(), + """{"anyOf":[{"required":["values"]},{"required":["note"]}]}""", options); + + JsonElement result = await InvokeStructuredOutputTestTool(tool); + + AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result); + Assert.Equal(JsonValueKind.Null, result.GetProperty("note").ValueKind); + Assert.False(result.TryGetProperty("values", out _)); + } + + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public async Task StructuredOutput_Enabled_ExplicitSchema_RecoversWithinIncludedReadOnlyCollections( + bool sourceGenerated, bool isField) + { + JsonSerializerOptions options = CreateStructuredOutputTestOptions(); + options.IncludeFields = true; + options.IgnoreReadOnlyProperties = true; + options.IgnoreReadOnlyFields = true; + IJsonTypeInfoResolver resolver = sourceGenerated ? ReadOnlyRecoveryJsonContext.Default : new DefaultJsonTypeInfoResolver(); + options.TypeInfoResolver = resolver.WithAddedModifier(typeInfo => + { + foreach (JsonPropertyInfo property in typeInfo.Properties) + { + property.AttributeProvider = null; + } + }); + object output = isField ? new ReadOnlyRecoveryCollectionField() : new ReadOnlyRecoveryCollectionProperty(); + McpServerTool tool = CreateStructuredOutputTestTool(output, + """{"required":["children"],"properties":{"children":{"items":{"required":["id","note"]}}}}""", options); + + JsonElement result = await InvokeStructuredOutputTestTool(tool); + + AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result); + Assert.Equal("42", result.GetProperty("children")[0].GetProperty("id").GetString()); + Assert.Equal(JsonValueKind.Null, result.GetProperty("children")[0].GetProperty("note").ValueKind); + } + + [Theory] + [InlineData(5, false, false)] + [InlineData(24, false, true)] + [InlineData(129, false, true)] + [InlineData(5, true, true)] + [InlineData(24, true, false)] + public async Task StructuredOutput_Enabled_ExplicitSchema_CombinesIndependentBranchRepairs(int count, bool dictionary, bool mixed) + { + IndependentUnionOutput[] items = Enumerable.Range(0, count) + .Select(index => new IndependentUnionOutput { Tag = mixed ? (index % 2).ToString() : "0" }).ToArray(); + object output = dictionary + ? items.Select((item, index) => (item, index)).ToDictionary(pair => $"item/{pair.index}~", pair => pair.item) + : items; + string container = dictionary ? "\"type\":\"object\",\"additionalProperties\"" : "\"type\":\"array\",\"items\""; + McpServerTool tool = CreateStructuredOutputTestTool(output, $$$""" + { + {{{container}}}:{"$ref":"#/$defs/entry"}, + "$defs":{"entry":{"oneOf":[ + {"type":"object","properties":{"tag":{"const":"0"},"note0":{"type":"null"}},"required":["tag","note0"],"additionalProperties":false}, + {"type":"object","properties":{"tag":{"const":"1"},"note1":{"type":"null"}},"required":["tag","note1"],"additionalProperties":false} + ]}} + } + """); + JsonElement result = await InvokeStructuredOutputTestTool(tool); + AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result); + IEnumerable repaired = dictionary + ? result.EnumerateObject().Select(property => property.Value) + : result.EnumerateArray(); + foreach (JsonElement item in repaired) + { + string tag = item.GetProperty("tag").GetString()!; + Assert.Equal(JsonValueKind.Null, item.GetProperty("note" + tag).ValueKind); + Assert.False(item.TryGetProperty(tag == "0" ? "note1" : "note0", out _)); + } + } + + [Fact] + public async Task StructuredOutput_Enabled_ExplicitSchema_DistinguishesUnevaluatedSchemaFragments() + { + var output = new { Child = new { X = (string?)null, Y = (string?)null } }; + McpServerTool tool = CreateStructuredOutputTestTool(output, """ + {"allOf":[ + {"properties":{"child":true},"unevaluatedProperties":{"required":["x"]}}, + {"unevaluatedProperties":{"required":["y"]}} + ]} + """); + JsonElement result = await InvokeStructuredOutputTestTool(tool); + AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result); + JsonElement child = result.GetProperty("child"); + Assert.Equal(JsonValueKind.Null, child.GetProperty("y").ValueKind); + Assert.False(child.TryGetProperty("x", out _)); + } + + [Fact] + public async Task StructuredOutput_Enabled_ExplicitSchema_DoesNotRecoverSourceGeneratedIterator() + { + static IEnumerable Items() + { + yield return new ExplicitSchemaOutput { Id = "42" }; + } + + JsonSerializerOptions options = new(McpJsonUtilities.DefaultOptions) + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + TypeInfoResolver = JsonTypeInfoResolver.Combine(JsonContext2.Default, McpJsonUtilities.DefaultOptions.TypeInfoResolver), + }; + McpServerTool tool = CreateStructuredOutputTestTool(Items(), """ + {"type":"array","items":{"required":["note"],"properties":{"note":{"type":"null"}}}} + """, options); + JsonElement result = await InvokeStructuredOutputTestTool(tool); + Assert.False(result[0].TryGetProperty("note", out _)); + } + + [Fact] + public async Task StructuredOutput_Enabled_ExplicitSchema_RespectsEnclosingAssertionsForRepeatedBranches() + { + var output = Enumerable.Range(0, 129).Select(_ => new { A = (string?)null, B = (string?)null }).ToArray(); + McpServerTool tool = CreateStructuredOutputTestTool(output, """ + {"type":"array","items":{"properties":{"a":true},"additionalProperties":false,"anyOf":[{"required":["a"]},{"required":["b"]}]}} + """); + JsonElement result = await InvokeStructuredOutputTestTool(tool); + AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result); + foreach (JsonElement item in result.EnumerateArray()) + { + Assert.Equal(JsonValueKind.Null, item.GetProperty("a").ValueKind); + Assert.False(item.TryGetProperty("b", out _)); + } + } + + [Fact] + public async Task StructuredOutput_Enabled_ExplicitSchema_DistinguishesRequiredPathsContainingNull() + { + Dictionary output = new() + { + ["a"] = new { }, + ["a\0b"] = new { c = (string?)null }, + }; + McpServerTool tool = CreateStructuredOutputTestTool(output, """ + {"anyOf":[{"properties":{"a":{"required":["b\u0000c"]}}},{"properties":{"a\u0000b":{"required":["c"]}}}]} + """); + JsonElement result = await InvokeStructuredOutputTestTool(tool); + Assert.Equal(JsonValueKind.Null, result.GetProperty("a\0b").GetProperty("c").ValueKind); + } + + [Fact] + public async Task StructuredOutput_Enabled_ExplicitSchema_ComposesNestedBranchRepairs() + { + McpServerTool tool = CreateStructuredOutputTestTool( + new { Tag = "outer", Note = (string?)null, Items = Enumerable.Range(0, 5).Select(_ => new IndependentUnionOutput()).ToArray() }, + """ + { + "oneOf":[ + {"required":["tag","note","items"],"properties":{ + "tag":{"const":"outer"},"note":{"type":"null"}, + "items":{"type":"array","items":{"oneOf":[ + {"properties":{"tag":{"const":"0"},"note0":{"type":"null"}},"required":["tag","note0"],"additionalProperties":false}, + {"properties":{"tag":{"const":"1"},"note1":{"type":"null"}},"required":["tag","note1"],"additionalProperties":false} + ]}} + },"additionalProperties":false}, + {"properties":{"tag":{"const":"other"}},"required":["tag","other"]} + ] + } + """); + JsonElement result = await InvokeStructuredOutputTestTool(tool); + AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result); + Assert.Equal(JsonValueKind.Null, result.GetProperty("note").ValueKind); + } + + [Fact] + public async Task StructuredOutput_Enabled_ExplicitSchema_ComposesBranchesWithLocalReferences() + { + McpServerTool tool = CreateStructuredOutputTestTool( + Enumerable.Range(0, 5).Select(_ => new IndependentUnionOutput()).ToArray(), + """ + { + "items":{"oneOf":[ + {"$ref":"#/$defs/zero","properties":{"tag":{"const":"0"},"note0":{"type":"null"}},"additionalProperties":false}, + {"$ref":"#/$defs/one","properties":{"tag":{"const":"1"},"note1":{"type":"null"}},"additionalProperties":false} + ]}, + "$defs":{"zero":{"required":["tag","note0"]},"one":{"required":["tag","note1"]}} + } + """); + JsonElement result = await InvokeStructuredOutputTestTool(tool); + AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result); + } + + [Theory] + [InlineData("https://example.test/root")] + [InlineData("urn:example:root")] + public async Task StructuredOutput_Enabled_ExplicitSchema_ComposesBranchesAtEscapedSchemaPaths(string id) + { + McpServerTool tool = CreateStructuredOutputTestTool( + new Dictionary + { + ["entry/%2F#~"] = Enumerable.Range(0, 5).Select(_ => new IndependentUnionOutput()).ToArray(), + }, + $$$""" + { + "$id":"{{{id}}}", + "properties":{"entry/%2F#~":{"items":{"oneOf":[ + {"properties":{"tag":{"const":"0"},"note0":{"type":"null"}},"required":["tag","note0"],"additionalProperties":false}, + {"properties":{"tag":{"const":"1"},"note1":{"type":"null"}},"required":["tag","note1"],"additionalProperties":false} + ]} } } + } + """); + JsonElement result = await InvokeStructuredOutputTestTool(tool); + AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result); + } + + [Theory] + [InlineData(5)] + [InlineData(129)] + public async Task StructuredOutput_Enabled_ExplicitSchema_ComposesBranchesWithOmittedDescendants(int count) + { + McpServerTool tool = CreateStructuredOutputTestTool( + Enumerable.Range(0, count).Select(_ => new NestedIndependentUnionOutput()).ToArray(), + """ + { + "items":{"oneOf":[ + {"properties":{"tag":{"const":"0"},"child0":{"required":["note"]}},"required":["tag","child0"],"additionalProperties":false}, + {"properties":{"tag":{"const":"1"},"child1":{"required":["note"]}},"required":["tag","child1"],"additionalProperties":false} + ]} + } + """, + CreateStructuredOutputTestOptions(JsonIgnoreCondition.WhenWritingDefault)); + JsonElement result = await InvokeStructuredOutputTestTool(tool); + AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result); + } + + [Theory] + [InlineData("expando")] + [InlineData("generic-string")] + [InlineData("generic-integer")] + public async Task StructuredOutput_Enabled_ExplicitSchema_RecoversGenericOnlyDictionaryValue(string kind) + { + object output; + JsonSerializerOptions options = CreateStructuredOutputTestOptions(); + if (kind == "expando") + { + ExpandoObject entries = new(); + ((IDictionary)entries)["first"] = new StructDictionaryChild { Count = 5 }; + ((IDictionary)entries)["entry/~"] = new StructDictionaryChild(); + output = entries; + } + else if (kind == "generic-string") + { + output = new GenericOnlyDictionary( + new Dictionary { ["first"] = new() { Count = 5 }, ["entry/~"] = default }); + options.DictionaryKeyPolicy = JsonNamingPolicy.SnakeCaseUpper; + } + else + { + output = new GenericOnlyDictionary( + new Dictionary { [1] = new() { Count = 5 }, [42] = default }); + options.Converters.Add(new FormattedIntegerKeyConverter()); + } + + McpServerTool tool = CreateStructuredOutputTestTool( + output, """{"type":"object","additionalProperties":{"type":"object","required":["count"],"properties":{"count":{"type":"integer"}}}}""", options); + JsonElement result = await InvokeStructuredOutputTestTool(tool); + AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result); + Assert.Equal([0, 5], result.EnumerateObject().Select(property => property.Value.GetProperty("count").GetInt32()).OrderBy(value => value)); + } + + [Fact] + public async Task StructuredOutput_Enabled_ExplicitSchema_DoesNotInvokeExcludedGetter() + { + JsonSerializerOptions options = CreateStructuredOutputTestOptions(); + options.IgnoreReadOnlyProperties = true; + McpServerTool tool = CreateStructuredOutputTestTool( + new ExcludedThrowingGetterOutput(), + """{"type":"object","anyOf":[{"required":["count"]},{"required":["note"]}]}""", options); + JsonElement result = await InvokeStructuredOutputTestTool(tool); + AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result); + Assert.Equal(JsonValueKind.Null, result.GetProperty("note").ValueKind); + Assert.False(result.TryGetProperty("count", out _)); + } + + [Fact] + public async Task StructuredOutput_Enabled_ExplicitSchema_RepairsIndependentArrayUnions() + { + const string schema = """{"type":"array","items":{"oneOf":[{"type":"object","properties":{"tag":{"const":"0"},"note0":{"type":"null"}},"required":["tag","note0"],"additionalProperties":false},{"type":"object","properties":{"tag":{"const":"1"},"note1":{"type":"null"}},"required":["tag","note1"],"additionalProperties":false}]}}"""; + McpServerTool tool = CreateStructuredOutputTestTool(Enumerable.Range(0, 5).Select(_ => new ManyBranchStructuredOutput { Tag = "0" }).ToArray(), schema); + JsonElement result = await InvokeStructuredOutputTestTool(tool); + AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result); + Assert.All(result.EnumerateArray(), item => Assert.Equal(JsonValueKind.Null, item.GetProperty("note0").ValueKind)); + } + + [Fact] + public async Task StructuredOutput_Enabled_ExplicitSchema_RecoversExpandoMember() + { + var output = new System.Dynamic.ExpandoObject(); + ((IDictionary)output)["child"] = new ExplicitSchemaOutputWithMemberIgnore { Id = "42" }; + McpServerTool tool = CreateStructuredOutputTestTool(output, """{"type":"object","properties":{"child":{"type":"object","required":["ignoredNote"],"properties":{"ignoredNote":{"type":"null"}}}}}"""); + JsonElement result = await InvokeStructuredOutputTestTool(tool); + AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result); + Assert.Equal(JsonValueKind.Null, result.GetProperty("child").GetProperty("ignoredNote").ValueKind); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task StructuredOutput_Enabled_ExplicitSchema_RecoversExplicitlyIncludedReadOnlyMember(bool sourceGenerated) + { + JsonSerializerOptions options = CreateStructuredOutputTestOptions(); + options.IgnoreReadOnlyProperties = true; + if (sourceGenerated) + { + options.TypeInfoResolver = JsonContext2.Default; + } + + McpServerTool tool = CreateStructuredOutputTestTool( + new ExplicitlyIncludedReadOnlyOutput(), """{"required":["count"]}""", options); + JsonElement result = await InvokeStructuredOutputTestTool(tool); + AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result); + Assert.Equal(0, result.GetProperty("count").GetInt32()); + } + + [Theory] + [InlineData("entry")] + [InlineData("https://example.test/entry")] + [InlineData("entry#value")] + [InlineData("entry#/$defs/a~1b~0c")] + public async Task StructuredOutput_Enabled_ExplicitSchema_ResolvesEmbeddedResourceReferences(string reference) + { + McpServerTool tool = CreateStructuredOutputTestTool( + new DefaultRequestIdOutput(), + $$$""" + { + "$id":"https://example.test/root", + "$defs":{"entry":{ + "$id":"entry","$anchor":"value","required":["id"], + "$defs":{"a/b~c":{"required":["id"]}} + }}, + "$ref":"{{{reference}}}" + } + """); + JsonElement result = await InvokeStructuredOutputTestTool(tool); + AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result); + Assert.Equal(JsonValueKind.Null, result.GetProperty("id").ValueKind); + } + + [Theory] + [InlineData("entry")] + [InlineData("entry#value")] + public async Task StructuredOutput_Enabled_ExplicitSchema_ResolvesResourceInPropertyNames(string reference) + { + McpServerTool tool = CreateStructuredOutputTestTool( + new DefaultRequestIdOutput(), + $$$""" + { + "$id":"https://example.test/root", + "propertyNames":{"$id":"entry","$anchor":"value","required":["id"]}, + "$ref":"{{{reference}}}" + } + """); + JsonElement result = await InvokeStructuredOutputTestTool(tool); + AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result); + Assert.Equal(JsonValueKind.Null, result.GetProperty("id").ValueKind); + } + + [Theory] + [InlineData("nested/entry")] + [InlineData("#/$defs/entry/$defs/shape")] + public async Task StructuredOutput_Enabled_ExplicitSchema_ResolvesReferencesInEmbeddedBaseUri(string reference) + { + McpServerTool tool = CreateStructuredOutputTestTool( + new DefaultRequestIdOutput(), + $$$""" + { + "$id":"https://example.test/base/root", + "$defs":{ + "entry":{"$id":"nested/entry","$defs":{"shape":{"$ref":"../shared"}},"$ref":"#/$defs/shape"}, + "shared":{"$id":"shared","required":["id"]} + }, + "$ref":"{{{reference}}}" + } + """); + JsonElement result = await InvokeStructuredOutputTestTool(tool); + AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result); + Assert.Equal(JsonValueKind.Null, result.GetProperty("id").ValueKind); + } + + [Theory] + [InlineData("""{"required":"note"}""")] + [InlineData("""{"$ref":"#/$defs/missing"}""")] + public async Task StructuredOutput_Enabled_ExplicitSchema_PreservesOutputWhenSchemaCannotBeEvaluated(string schema) + { + McpServerTool tool = CreateStructuredOutputTestTool(new DefaultRequestIdOutput(), schema); + JsonElement result = await InvokeStructuredOutputTestTool(tool); + Assert.False(result.TryGetProperty("id", out _)); + } + + private sealed class IndependentUnionOutput + { + public string Tag { get; init; } = "0"; + public string? Note0 { get; init; } + public string? Note1 { get; init; } + } + + private sealed class NestedIndependentUnionOutput + { + public string Tag { get; init; } = "0"; + public NestedDefaultedStructuredChild Child0 { get; init; } + public NestedDefaultedStructuredChild Child1 { get; init; } + } + + private sealed class ExcludedThrowingGetterOutput + { + public int Count => throw new InvalidOperationException("Excluded getter was invoked."); + public string? Note { get; init; } + } + + private sealed class ExplicitlyIncludedReadOnlyOutput + { + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + public int Count => 0; + } + + private sealed class GenericOnlyDictionary(IReadOnlyDictionary entries) : IReadOnlyDictionary + { + public TValue this[TKey key] => entries[key]; + public IEnumerable Keys => entries.Keys; + public IEnumerable Values => entries.Values; + public int Count => entries.Count; + public bool ContainsKey(TKey key) => entries.ContainsKey(key); + public bool TryGetValue(TKey key, out TValue value) => entries.TryGetValue(key, out value!); + public IEnumerator> GetEnumerator() => entries.GetEnumerator(); + System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => GetEnumerator(); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task StructuredOutput_Enabled_ExplicitSchema_DoesNotReadIgnoredGetter(bool noAttributeProvider) + { + JsonSerializerOptions options = CreateStructuredOutputTestOptions(); + options.IgnoreReadOnlyProperties = true; + if (noAttributeProvider) + { + var resolver = new DefaultJsonTypeInfoResolver(); + resolver.Modifiers.Add(typeInfo => + { + if (typeInfo.Type == typeof(ThrowingGetterOutput)) + { + foreach (JsonPropertyInfo property in typeInfo.Properties) + { + property.AttributeProvider = null; + } + } + }); + options.TypeInfoResolver = resolver; + } + McpServerTool tool = CreateStructuredOutputTestTool(new ThrowingGetterOutput(), """{"anyOf":[{"required":["count"]},{"required":["note"],"properties":{"note":{"type":"null"}}}]}""", options); + JsonElement result = await InvokeStructuredOutputTestTool(tool); + AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result); + Assert.Equal(JsonValueKind.Null, result.GetProperty("note").ValueKind); + } + + [Fact] + public async Task StructuredOutput_Enabled_ExplicitSchema_ResolvesEmbeddedResource() + { + McpServerTool tool = CreateStructuredOutputTestTool(new ExplicitSchemaOutput { Id = "42" }, """{"$id":"https://example.test/root","default":{"$id":"entry"},"$defs":{"entry":{"$id":"entry","type":"object","required":["note"],"properties":{"note":{"type":"null"}}}},"$ref":"entry"}"""); + JsonElement result = await InvokeStructuredOutputTestTool(tool); + AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result); + Assert.Equal(JsonValueKind.Null, result.GetProperty("note").ValueKind); + } + + private sealed class ThrowingGetterOutput + { + public int Count => throw new InvalidOperationException("Excluded getter was invoked"); + public string? Note { get; init; } + } + + [Theory] + [InlineData("property", false)] + [InlineData("list", false)] + [InlineData("dictionary", false)] + [InlineData("property", true)] + [InlineData("list", true)] + [InlineData("dictionary", true)] + [InlineData("object", false)] + public async Task StructuredOutput_Enabled_ExplicitSchema_UsesActiveSerializationContract(string shape, bool polymorphic) + { + JsonSerializerOptions options = CreateStructuredOutputTestOptions(JsonIgnoreCondition.WhenWritingDefault); + DefaultJsonTypeInfoResolver resolver = new(); + resolver.Modifiers.Add(typeInfo => + { + if (typeInfo.Type == typeof(RecoveryBase)) + { + typeInfo.Properties.Single(property => property.Name == "flag").ShouldSerialize = static (_, _) => false; + if (polymorphic) + { + typeInfo.PolymorphismOptions = new() + { + DerivedTypes = { new JsonDerivedType(typeof(RecoveryDerived), "derived") }, + }; + } + } + }); + options.TypeInfoResolver = resolver; + RecoveryDerived child = new(); + object output = shape switch + { + "list" => new List { child }, + "dictionary" => new Dictionary { ["child"] = child }, + "object" => new { Child = (object)child }, + _ => new RecoveryContainer { Child = child }, + }; + string schema = shape == "list" + ? """{"items":{"required":["flag"]}}""" + : """{"properties":{"child":{"required":["flag"]}}}"""; + McpServerTool tool = CreateStructuredOutputTestTool(output, schema, options); + JsonElement result = await InvokeStructuredOutputTestTool(tool); + JsonElement entry = shape == "list" ? result[0] : result.GetProperty("child"); + Assert.Equal(polymorphic || shape == "object", entry.TryGetProperty("flag", out _)); + if (polymorphic || shape == "object") + { + Assert.False(entry.GetProperty("flag").GetBoolean()); + AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result); + } + } + + [Fact] + public async Task StructuredOutput_Enabled_ExplicitSchema_DoesNotSerializeUnrelatedOmittedMembers() + { + McpServerTool tool = CreateStructuredOutputTestTool( + new OptionalThrowingConverterOutput(), + """{"required":["note"],"properties":{"note":{"type":"null"}}}"""); + JsonElement result = await InvokeStructuredOutputTestTool(tool); + Assert.Equal(JsonValueKind.Null, result.GetProperty("note").ValueKind); + Assert.False(result.TryGetProperty("optional", out _)); + } + + [Theory] + [InlineData("argument")] + [InlineData("format")] + [InlineData("application")] + public async Task StructuredOutput_Enabled_ExplicitSchema_PreservesOutputWhenRequiredNullConverterThrows(string failure) + { + JsonSerializerOptions options = CreateStructuredOutputTestOptions(); + options.Converters.Insert(0, new RecoveryRejectNullConverter { Failure = failure }); + McpServerTool tool = CreateStructuredOutputTestTool( + new OptionalThrowingConverterOutput(), """{"required":["note"]}""", options); + JsonElement result = await InvokeStructuredOutputTestTool(tool); + Assert.False(result.TryGetProperty("note", out _)); + } + + [Fact] + public async Task StructuredOutput_Enabled_ExplicitSchema_PreservesConverterCancellation() + { + JsonSerializerOptions options = CreateStructuredOutputTestOptions(); + options.Converters.Insert(0, new RecoveryRejectNullConverter { Failure = "cancel" }); + McpServerTool tool = CreateStructuredOutputTestTool( + new OptionalThrowingConverterOutput(), """{"required":["note"]}""", options); + await Assert.ThrowsAnyAsync(() => InvokeStructuredOutputTestTool(tool)); + } + + [Fact] + public async Task StructuredOutput_Enabled_ExplicitSchema_PreservesNullableAnnotationPolicy() + { + JsonSerializerOptions options = CreateStructuredOutputTestOptions(); + options.RespectNullableAnnotations = true; + McpServerTool tool = CreateStructuredOutputTestTool( + new RecoveryNonNullableOutput(), """{"required":["note"]}""", options); + JsonElement result = await InvokeStructuredOutputTestTool(tool); + Assert.False(result.TryGetProperty("note", out _)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task StructuredOutput_Enabled_ExplicitSchema_PreservesContainingNumberHandling(bool propertyOverride) + { + object output = propertyOverride ? new RecoveryStrictNumberOutput() : new RecoveryStringNumberOutput(); + string schema = propertyOverride + ? """{"required":["count"],"properties":{"count":{"const":0}}}""" + : """{"required":["count"],"properties":{"count":{"const":"0"}}}"""; + McpServerTool tool = CreateStructuredOutputTestTool(output, schema); + JsonElement result = await InvokeStructuredOutputTestTool(tool); + AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result); + } + + [Fact] + public async Task StructuredOutput_Enabled_ExplicitSchema_DoesNotInheritNumberHandlingIntoNestedObjects() + { + McpServerTool tool = CreateStructuredOutputTestTool( + new RecoveryNestedNumberOutput(), + """{"required":["child"],"properties":{"child":{"properties":{"count":{"const":0}}}}}"""); + JsonElement result = await InvokeStructuredOutputTestTool(tool); + AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task StructuredOutput_Enabled_ExplicitSchema_DoesNotOverrideCustomConverterNumberHandling(bool propertyConverter) + { + JsonSerializerOptions options = CreateStructuredOutputTestOptions(); + options.NumberHandling = JsonNumberHandling.Strict; + if (!propertyConverter) + { + options.Converters.Insert(0, new RecoveryNumberPolicyConverter()); + } + + object output = propertyConverter ? new RecoveryCustomNumberOutput() : new RecoveryStringNumberOutput(); + McpServerTool tool = CreateStructuredOutputTestTool( + output, """{"required":["count"],"properties":{"count":{"const":true}}}""", options); + JsonElement result = await InvokeStructuredOutputTestTool(tool); + AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result); + } + + [Fact] + public async Task StructuredOutput_Enabled_ExplicitSchema_RecoversFlattenedExtensionData() + { + McpServerTool tool = CreateStructuredOutputTestTool( + new RecoveryExtensionOutput { Data = new() { ["child/~"] = new NestedDefaultedStructuredChild() } }, + """{"required":["child/~"],"properties":{"child/~":{"required":["note"]}}}"""); + JsonElement result = await InvokeStructuredOutputTestTool(tool); + AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result); + Assert.Equal(JsonValueKind.Null, result.GetProperty("child/~").GetProperty("note").ValueKind); + Assert.False(result.TryGetProperty("data", out _)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task StructuredOutput_Enabled_ExplicitSchema_DoesNotRepeatedlyEnumerateSource(bool indexed) + { + const int count = 256; + RecoveryCountingCollection source = indexed ? new RecoveryCountingList(count) : new(count); + McpServerTool tool = CreateStructuredOutputTestTool( + source, """{"items":{"required":["note"]}}"""); + JsonElement result = await InvokeStructuredOutputTestTool(tool); + AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result); + Assert.Equal(count, result.GetArrayLength()); + Assert.All(result.EnumerateArray(), item => Assert.Equal(JsonValueKind.Null, item.GetProperty("note").ValueKind)); + Assert.True(source.EnumeratedItems <= count * 4, $"Enumerated {source.EnumeratedItems} source items."); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task StructuredOutput_Enabled_ExplicitSchema_RecoversGenericListElements(bool mutable) + { + IEnumerable source = mutable ? new GenericMutableRecoveryList() : new GenericRecoveryList(); + McpServerTool tool = CreateStructuredOutputTestTool(source, + """{"items":{"required":["count"]}}""", CreateStructuredOutputTestOptions(JsonIgnoreCondition.WhenWritingDefault)); + JsonElement result = await InvokeStructuredOutputTestTool(tool); + Assert.Equal(0, result[0].GetProperty("count").GetInt32()); + Assert.Equal(5, result[1].GetProperty("count").GetInt32()); + } + + [Theory] + [InlineData("linked", false)] + [InlineData("linked", true)] + [InlineData("queue", false)] + [InlineData("queue", true)] + [InlineData("set", false)] + [InlineData("set", true)] + public async Task StructuredOutput_Enabled_ExplicitSchema_NonIndexedCollectionsRequireMaterialization( + string shape, bool materialize) + { + ExplicitSchemaOutput[] items = [new() { Id = "42" }]; + IEnumerable collection = shape switch + { + "linked" => new LinkedList(items), + "queue" => new Queue(items), + _ => new HashSet(items), + }; + McpServerTool tool = CreateStructuredOutputTestTool( + materialize ? collection.ToArray() : collection, + """{"type":"array","items":{"required":["id","note"]}}"""); + + JsonElement result = await InvokeStructuredOutputTestTool(tool); + + Assert.Equal("42", result[0].GetProperty("id").GetString()); + Assert.Equal(materialize, result[0].TryGetProperty("note", out JsonElement note)); + if (materialize) + { + Assert.Equal(JsonValueKind.Null, note.ValueKind); + AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result); + } + } + + private sealed class GenericMutableRecoveryList : IList + { + private readonly List _items = + [new() { Id = "a", Count = 0 }, new() { Id = "b", Count = 5 }]; + ExplicitSchemaOutputWithDefaultValue IList.this[int index] { get => _items[index]; set => throw new NotSupportedException(); } + public int Count => _items.Count; + public bool IsReadOnly => true; + public IEnumerator GetEnumerator() => _items.GetEnumerator(); + System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => GetEnumerator(); + public int IndexOf(ExplicitSchemaOutputWithDefaultValue item) => _items.IndexOf(item); + public bool Contains(ExplicitSchemaOutputWithDefaultValue item) => _items.Contains(item); + public void CopyTo(ExplicitSchemaOutputWithDefaultValue[] array, int arrayIndex) => _items.CopyTo(array, arrayIndex); + public void Add(ExplicitSchemaOutputWithDefaultValue item) => throw new NotSupportedException(); + public void Clear() => throw new NotSupportedException(); + public void Insert(int index, ExplicitSchemaOutputWithDefaultValue item) => throw new NotSupportedException(); + public bool Remove(ExplicitSchemaOutputWithDefaultValue item) => throw new NotSupportedException(); + public void RemoveAt(int index) => throw new NotSupportedException(); + } + + private sealed class GenericRecoveryList : IReadOnlyList + { + private readonly ExplicitSchemaOutputWithDefaultValue[] _items = + [new() { Id = "a", Count = 0 }, new() { Id = "b", Count = 5 }]; + public int Count => _items.Length; + public ExplicitSchemaOutputWithDefaultValue this[int index] => _items[index]; + public IEnumerator GetEnumerator() => ((IEnumerable)_items).GetEnumerator(); + System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => GetEnumerator(); + } + + [Fact] + public async Task StructuredOutput_Enabled_ExplicitSchema_DoesNotRecoverReorderedEnumerable() + { + ReorderedRecoveryEnumerable source = new(); + JsonSerializerOptions options = CreateStructuredOutputTestOptions(JsonIgnoreCondition.WhenWritingDefault); + McpServerTool tool = CreateStructuredOutputTestTool(source, + """{"items":{"required":["count"]}}""", options); + + JsonElement result = await InvokeStructuredOutputTestTool(tool); + + Assert.Equal("a", result[0].GetProperty("id").GetString()); + Assert.False(result[0].TryGetProperty("count", out _)); + Assert.Equal("b", result[1].GetProperty("id").GetString()); + Assert.Equal(5, result[1].GetProperty("count").GetInt32()); + Assert.Equal(2, source.EnumerationCount); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task StructuredOutput_Enabled_ExplicitSchema_RecoversConditionalMetadata(bool sourceGenerated) + { + JsonSerializerOptions options = CreateStructuredOutputTestOptions(JsonIgnoreCondition.Never); + options.IgnoreReadOnlyProperties = true; + options.TypeInfoResolver = sourceGenerated ? JsonContext2.Default : new DefaultJsonTypeInfoResolver(); + McpServerTool tool = CreateStructuredOutputTestTool( + new RecoveryConditionalOutput(), """{"required":["note","count","readOnlyCount"]}""", options); + JsonElement result = await InvokeStructuredOutputTestTool(tool); + AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result); + Assert.Equal(JsonValueKind.Null, result.GetProperty("note").ValueKind); + Assert.Equal(0, result.GetProperty("count").GetInt32()); + Assert.Equal(0, result.GetProperty("readOnlyCount").GetInt32()); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task StructuredOutput_Enabled_ExplicitSchema_DoesNotRecoverWriteExcludedMetadata(bool sourceGenerated) + { + JsonSerializerOptions options = CreateStructuredOutputTestOptions(); + IJsonTypeInfoResolver resolver = sourceGenerated ? JsonContext2.Default : new DefaultJsonTypeInfoResolver(); + options.TypeInfoResolver = resolver.WithAddedModifier(typeInfo => + { + foreach (JsonPropertyInfo property in typeInfo.Properties) + { + property.AttributeProvider = null; + } + }); + McpServerTool tool = CreateStructuredOutputTestTool( + new RecoveryConditionalOutput(), """{"required":["excluded"]}""", options); + JsonElement result = await InvokeStructuredOutputTestTool(tool); + Assert.False(result.TryGetProperty("excluded", out _)); + } + + [Fact] + public async Task StructuredOutput_Enabled_ExplicitSchema_DoesNotRecoverOutsideDeclaredInterface() + { + McpServerTool tool = CreateStructuredOutputTestTool( + new { Child = (IRecoveryContract)new RecoveryDerived() }, + """{"properties":{"child":{"required":["flag"]}}}""", + CreateStructuredOutputTestOptions(JsonIgnoreCondition.WhenWritingDefault)); + JsonElement result = await InvokeStructuredOutputTestTool(tool); + Assert.False(result.GetProperty("child").TryGetProperty("flag", out _)); + } + + [Theory] + [InlineData("property")] + [InlineData("list")] + [InlineData("dictionary")] + public async Task StructuredOutput_Enabled_ExplicitSchema_RecoversWithinNullableStructContract(string shape) + { + NestedDefaultedStructuredChild? child = new NestedDefaultedStructuredChild(); + object output = shape switch + { + "list" => new List { child }, + "dictionary" => new Dictionary { ["child"] = child }, + _ => new { Child = child }, + }; + string schema = shape == "list" + ? """{"items":{"required":["note"]}}""" + : """{"properties":{"child":{"required":["note"]}}}"""; + McpServerTool tool = CreateStructuredOutputTestTool(output, schema); + JsonElement result = await InvokeStructuredOutputTestTool(tool); + AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task StructuredOutput_Enabled_ExplicitSchema_UsesSerializerDefaultComparison(bool globalCondition) + { + JsonSerializerOptions options = CreateStructuredOutputTestOptions( + globalCondition ? JsonIgnoreCondition.WhenWritingDefault : JsonIgnoreCondition.WhenWritingNull); + object output = globalCondition ? new RecoveryGlobalEquatableOutput() : new RecoveryEquatableOutput(); + McpServerTool tool = CreateStructuredOutputTestTool( + output, """{"required":["child"],"properties":{"child":{"required":["note"]}}}""", options); + JsonElement result = await InvokeStructuredOutputTestTool(tool); + AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result); + } + + [Theory] + [InlineData(JsonUnknownDerivedTypeHandling.FallBackToBaseType, false)] + [InlineData(JsonUnknownDerivedTypeHandling.FallBackToNearestAncestor, true)] + public async Task StructuredOutput_Enabled_ExplicitSchema_UsesPolymorphicFallbackContract( + JsonUnknownDerivedTypeHandling fallback, bool expectedFlag) + { + JsonSerializerOptions options = CreateStructuredOutputTestOptions(JsonIgnoreCondition.WhenWritingDefault); + options.TypeInfoResolver = new DefaultJsonTypeInfoResolver + { + Modifiers = + { + typeInfo => + { + if (typeInfo.Type == typeof(RecoveryBase)) + { + typeInfo.Properties.Single(property => property.Name == "flag").ShouldSerialize = static (_, _) => false; + typeInfo.PolymorphismOptions = new() + { + UnknownDerivedTypeHandling = fallback, + DerivedTypes = { new JsonDerivedType(typeof(RecoveryDerived), "derived") }, + }; + } + }, + }, + }; + McpServerTool tool = CreateStructuredOutputTestTool( + new RecoveryContainer { Child = new RecoveryGrandchild() }, + """{"properties":{"child":{"required":["flag"]}}}""", options); + JsonElement result = await InvokeStructuredOutputTestTool(tool); + Assert.Equal(expectedFlag, result.GetProperty("child").TryGetProperty("flag", out _)); + if (expectedFlag) + { + AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result); + } + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task StructuredOutput_Enabled_ExplicitSchema_DoesNotTraverseOpaqueConverter(bool propertyConverter) + { + object output = propertyConverter + ? new RecoveryOpaquePropertyOutput() + : new { Child = new RecoveryOpaqueTypeOutput() }; + McpServerTool tool = CreateStructuredOutputTestTool( + output, """{"properties":{"child":{"required":["flag"]}}}"""); + JsonElement result = await InvokeStructuredOutputTestTool(tool); + Assert.False(result.GetProperty("child").TryGetProperty("flag", out _)); + Assert.Equal("visible", result.GetProperty("child").GetProperty("id").GetString()); + } + + private interface IRecoveryContract; + + private class RecoveryBase + { + public bool Flag { get; init; } + } + + private class RecoveryDerived : RecoveryBase, IRecoveryContract; + + private sealed class RecoveryGrandchild : RecoveryDerived; + + private sealed class RecoveryContainer + { + public RecoveryBase Child { get; init; } = new RecoveryDerived(); + } + + private sealed class OptionalThrowingConverterOutput + { + public string? Note { get; init; } + + [JsonConverter(typeof(RecoveryRejectNullConverter))] + public string? Optional { get; init; } + } + + private sealed class RecoveryNonNullableOutput + { + public string Note { get; init; } = null!; + } + + private sealed class RecoveryRejectNullConverter : JsonConverter + { + public string Failure { get; init; } = "argument"; + public override bool HandleNull => true; + public override string Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => + throw new NotSupportedException(); + public override void Write(Utf8JsonWriter writer, string value, JsonSerializerOptions options) + { + if (value is not null) + { + writer.WriteStringValue(value); + return; + } + + throw Failure switch + { + "format" => new FormatException("Null is not supported."), + "application" => new ApplicationException("Null is not supported."), + "cancel" => new OperationCanceledException(), + _ => new ArgumentNullException(nameof(value)), + }; + } + } + + [JsonNumberHandling(JsonNumberHandling.WriteAsString)] + private sealed class RecoveryStringNumberOutput + { + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + public int Count { get; init; } + } + + [JsonNumberHandling(JsonNumberHandling.WriteAsString)] + private sealed class RecoveryStrictNumberOutput + { + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + [JsonNumberHandling(JsonNumberHandling.Strict)] + public int Count { get; init; } + } + + [JsonNumberHandling(JsonNumberHandling.WriteAsString)] + private sealed class RecoveryNestedNumberOutput + { + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + public RecoveryNumberChild Child { get; init; } + } + + private struct RecoveryNumberChild + { + public int Count { get; init; } + } + + [JsonNumberHandling(JsonNumberHandling.WriteAsString)] + private sealed class RecoveryCustomNumberOutput + { + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + [JsonConverter(typeof(RecoveryNumberPolicyConverter))] + public int Count { get; init; } + } + + private sealed class RecoveryNumberPolicyConverter : JsonConverter + { + public override int Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => + throw new NotSupportedException(); + public override void Write(Utf8JsonWriter writer, int value, JsonSerializerOptions options) => + writer.WriteBooleanValue(options.NumberHandling == JsonNumberHandling.Strict); + } + + private sealed class RecoveryExtensionOutput + { + [JsonExtensionData] + public Dictionary Data { get; init; } = []; + } + + private sealed class RecoveryConditionalOutput + { + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Note { get; init; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + public int Count { get; init; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + public int ReadOnlyCount => 0; + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWriting)] + public string? Excluded { get; init; } + } + + private sealed class RecoveryEquatableOutput + { + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + public RecoveryEquatableChild Child { get; init; } = new(1); + } + + private sealed class RecoveryGlobalEquatableOutput + { + public RecoveryEquatableChild Child { get; init; } = new(1); + } + + private readonly struct RecoveryEquatableChild(int ignored) : IEquatable + { + [JsonIgnore] + public int Ignored { get; } = ignored; + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Note { get; init; } + + public bool Equals(RecoveryEquatableChild other) => Note == other.Note; + } + + private class RecoveryOpaqueChild + { + public bool Flag => throw new InvalidOperationException("The converter hides this getter."); + } + + private sealed class RecoveryOpaquePropertyOutput + { + [JsonConverter(typeof(RecoveryOpaqueConverter))] + public RecoveryOpaqueChild Child { get; init; } = new(); + } + + [JsonConverter(typeof(RecoveryOpaqueConverter))] + private sealed class RecoveryOpaqueTypeOutput : RecoveryOpaqueChild; + + private sealed class RecoveryOpaqueConverter : JsonConverter + { + public override bool CanConvert(Type typeToConvert) => typeof(RecoveryOpaqueChild).IsAssignableFrom(typeToConvert); + public override RecoveryOpaqueChild Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => + throw new NotSupportedException(); + public override void Write(Utf8JsonWriter writer, RecoveryOpaqueChild value, JsonSerializerOptions options) + { + writer.WriteStartObject(); + writer.WriteString("id", "visible"); + writer.WriteEndObject(); + } + } + + private sealed class ReorderedRecoveryEnumerable : IEnumerable + { + public int EnumerationCount { get; private set; } + + public IEnumerator GetEnumerator() + { + EnumerationCount++; + var first = new ExplicitSchemaOutputWithDefaultValue { Id = "a", Count = 0 }; + var second = new ExplicitSchemaOutputWithDefaultValue { Id = "b", Count = 5 }; + return (EnumerationCount == 1 ? new[] { first, second } : new[] { second, first }).AsEnumerable().GetEnumerator(); + } + + System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => GetEnumerator(); + } + + private class RecoveryCountingCollection(int count) : IReadOnlyList + { + public int EnumeratedItems { get; private set; } + public int Count => count; + public NestedDefaultedStructuredChild this[int index] => default; + public IEnumerator GetEnumerator() + { + for (int i = 0; i < count; i++) + { + EnumeratedItems++; + yield return default; + } + } + System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => GetEnumerator(); + } + + private sealed class RecoveryCountingList(int count) : RecoveryCountingCollection(count), System.Collections.IList + { + object? System.Collections.IList.this[int index] { get => this[index]; set => throw new NotSupportedException(); } + public bool IsReadOnly => true; + public bool IsFixedSize => true; + public bool IsSynchronized => false; + public object SyncRoot => this; + public int Add(object? value) => throw new NotSupportedException(); + public void Clear() => throw new NotSupportedException(); + public bool Contains(object? value) => throw new NotSupportedException(); + public int IndexOf(object? value) => throw new NotSupportedException(); + public void Insert(int index, object? value) => throw new NotSupportedException(); + public void Remove(object? value) => throw new NotSupportedException(); + public void RemoveAt(int index) => throw new NotSupportedException(); + public void CopyTo(Array array, int index) => throw new NotSupportedException(); + } + + private static JsonSerializerOptions CreateStructuredOutputTestOptions(JsonIgnoreCondition condition = JsonIgnoreCondition.WhenWritingNull) => + new(McpJsonUtilities.DefaultOptions) + { + DefaultIgnoreCondition = condition, + TypeInfoResolver = JsonTypeInfoResolver.Combine(McpJsonUtilities.DefaultOptions.TypeInfoResolver, new DefaultJsonTypeInfoResolver()), + }; + + private static McpServerTool CreateStructuredOutputTestTool(T value, string schema, JsonSerializerOptions? options = null) => + McpServerTool.Create(() => value, new() + { + Name = "tool", + UseStructuredContent = true, + OutputSchema = JsonDocument.Parse(schema).RootElement, + SerializerOptions = options ?? CreateStructuredOutputTestOptions(), + }); + + private static async Task InvokeStructuredOutputTestTool(McpServerTool tool) + { + var result = await tool.InvokeAsync(CreateRequestContextWithProtocolVersion(Sep2106ProtocolVersion), TestContext.Current.CancellationToken); + Assert.NotEqual(true, result.IsError); + Assert.NotNull(result.StructuredContent); + return result.StructuredContent.Value; + } + [Fact] public async Task StructuredOutput_Enabled_VoidReturningTools_ReturnsExpectedSchema() { @@ -1103,7 +3599,7 @@ public object InstanceMethod() private static void AssertMatchesJsonSchema(JsonElement schemaDoc, JsonElement? value) { - JsonSchema schema = JsonSerializer.Deserialize(schemaDoc, JsonContext2.Default.JsonSchema)!; + JsonSchema schema = JsonSchema.Build(schemaDoc, new BuildOptions { SchemaRegistry = new SchemaRegistry() }); EvaluationOptions options = new() { OutputFormat = OutputFormat.List }; EvaluationResults results = schema.Evaluate(value!.Value, options); if (!results.IsValid) @@ -1126,6 +3622,464 @@ Instance JSON document does not match the specified schema. record Person(string Name, int Age); + private sealed record RequiredNullableOutput(string Id, string? Note); + + private sealed class RequiredNullablePropertiesOutput + { + public required string Id { get; init; } + + public required string? Note { get; init; } + } + + private sealed class ExplicitSchemaOutput + { + public required string Id { get; init; } + + public string? Note { get; init; } + } + + private sealed class ExplicitSchemaOutputWithMemberIgnore + { + public required string Id { get; init; } + + public string? DefaultedNote { get; init; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? IgnoredNote { get; init; } + } + + private sealed class ExplicitSchemaOutputWithDefaultValue + { + public required string Id { get; init; } + + public int Count { get; init; } + } + + private sealed class ExplicitSchemaOutputWithMemberDefault + { + public required string Id { get; init; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + public int Count { get; init; } + } + + private sealed class DefaultRequestIdOutput + { + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + public RequestId Id { get; init; } + } + + private sealed class DynamicReferenceOutput + { + public ExplicitSchemaOutput? Child { get; init; } + + public string? Note { get; init; } + } + + private sealed class FormattedIntegerKeyConverter : JsonConverter + { + public override int Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => + reader.GetInt32(); + + public override void Write(Utf8JsonWriter writer, int value, JsonSerializerOptions options) => + writer.WriteNumberValue(value); + + public override int ReadAsPropertyName(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => + int.Parse(reader.GetString()!.Substring(4), System.Globalization.CultureInfo.InvariantCulture); + + public override void WriteAsPropertyName(Utf8JsonWriter writer, int value, JsonSerializerOptions options) => + writer.WritePropertyName("key-" + value.ToString(System.Globalization.CultureInfo.InvariantCulture)); + } + + private sealed class DefaultJsonElementOutput + { + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + [JsonConverter(typeof(DefaultJsonElementConverter))] + public JsonElement Value { get; init; } + } + + private sealed class DefaultJsonElementConverter : JsonConverter + { + public override JsonElement Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => + throw new NotSupportedException(); + + public override void Write(Utf8JsonWriter writer, JsonElement value, JsonSerializerOptions options) => + writer.WriteStringValue("converted"); + } + + private sealed class RejectedNullOutput + { + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonConverter(typeof(RejectedNullConverter))] + public string? Note { get; init; } + } + + private sealed class RejectedNullConverter : JsonConverter + { + public override bool HandleNull => true; + + public override string Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => + throw new NotSupportedException(); + + public override void Write(Utf8JsonWriter writer, string value, JsonSerializerOptions options) => + throw new JsonException("Null cannot be serialized."); + } + + private sealed class ReadOnlyPropertyOutput + { + public int Count => 0; + } + + private sealed class ReadOnlyFieldOutput + { + public readonly int Count = 0; + } + + private sealed class ReadOnlyRecoveryProperty + { + public string? Note => null; + public int Count => 0; + } + + private sealed class ReadOnlyRecoveryField + { + public readonly string? Note = null; + public readonly int Count = 0; + } + + private sealed class ReadOnlyConvertedCollectionOutput + { + [JsonConverter(typeof(ReadOnlyRecoveryListConverter))] + public List Values => throw new InvalidOperationException("Excluded getter was invoked."); + public string? Note { get; set; } + } + + private sealed class ReadOnlyRecoveryCollectionProperty + { + public List Children { get; } = [new() { Id = "42" }]; + } + + private sealed class ReadOnlyRecoveryCollectionField + { + public readonly List Children = [new() { Id = "42" }]; + } + + private sealed class ReadOnlyRecoveryListConverter : JsonConverter> + { + public override List Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => + throw new NotSupportedException(); + + public override void Write(Utf8JsonWriter writer, List value, JsonSerializerOptions options) => + throw new InvalidOperationException("Excluded converter was invoked."); + } + + [JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, IncludeFields = true)] + [JsonSerializable(typeof(ReadOnlyRecoveryProperty))] + [JsonSerializable(typeof(ReadOnlyRecoveryField))] + [JsonSerializable(typeof(ReadOnlyConvertedCollectionOutput))] + [JsonSerializable(typeof(ReadOnlyRecoveryCollectionProperty))] + [JsonSerializable(typeof(ReadOnlyRecoveryCollectionField))] + private partial class ReadOnlyRecoveryJsonContext : JsonSerializerContext; + + private sealed class ManyNestedDefaultOutputs + { + public NestedDefaultedStructuredChild Child0 { get; init; } + public NestedDefaultedStructuredChild Child1 { get; init; } + public NestedDefaultedStructuredChild Child2 { get; init; } + public NestedDefaultedStructuredChild Child3 { get; init; } + public NestedDefaultedStructuredChild Child4 { get; init; } + public NestedDefaultedStructuredChild Child5 { get; init; } + public NestedDefaultedStructuredChild Child6 { get; init; } + public NestedDefaultedStructuredChild Child7 { get; init; } + public NestedDefaultedStructuredChild Child8 { get; init; } + public NestedDefaultedStructuredChild Child9 { get; init; } + public NestedDefaultedStructuredChild Child10 { get; init; } + public NestedDefaultedStructuredChild Child11 { get; init; } + public NestedDefaultedStructuredChild Child12 { get; init; } + public NestedDefaultedStructuredChild Child13 { get; init; } + public NestedDefaultedStructuredChild Child14 { get; init; } + public NestedDefaultedStructuredChild Child15 { get; init; } + public NestedDefaultedStructuredChild Child16 { get; init; } + public NestedDefaultedStructuredChild Child17 { get; init; } + public NestedDefaultedStructuredChild Child18 { get; init; } + public NestedDefaultedStructuredChild Child19 { get; init; } + public NestedDefaultedStructuredChild Child20 { get; init; } + public NestedDefaultedStructuredChild Child21 { get; init; } + public NestedDefaultedStructuredChild Child22 { get; init; } + public NestedDefaultedStructuredChild Child23 { get; init; } + } + + private sealed class ManyBranchStructuredOutput + { + public required string Tag { get; init; } + public string? Note0 { get; init; } + public string? Note1 { get; init; } + public string? Note2 { get; init; } + public string? Note3 { get; init; } + public string? Note4 { get; init; } + public string? Note5 { get; init; } + public string? Note6 { get; init; } + public string? Note7 { get; init; } + public string? Note8 { get; init; } + public string? Note9 { get; init; } + public string? Note10 { get; init; } + public string? Note11 { get; init; } + public string? Note12 { get; init; } + public string? Note13 { get; init; } + public string? Note14 { get; init; } + public string? Note15 { get; init; } + public string? Note16 { get; init; } + public string? Note17 { get; init; } + public string? Note18 { get; init; } + public string? Note19 { get; init; } + public string? Note20 { get; init; } + public string? Note21 { get; init; } + public string? Note22 { get; init; } + public string? Note23 { get; init; } + } + + private sealed class ExplicitSchemaOutputWithMemberConverter + { + public required string Id { get; init; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + [JsonConverter(typeof(IntAsStringJsonConverter))] + public int Count { get; init; } + } + + private sealed class ExplicitSchemaOutputWithUndefinedEnumDefault + { + public string? Note { get; init; } + + public ExplicitSchemaStatus Status { get; init; } + } + + private sealed class StructDictionaryOutput + { + public required Dictionary Items { get; init; } + } + + private struct StructDictionaryChild + { + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + public int Count { get; set; } + + public ExplicitSchemaStatus Status { get; set; } + } + + private enum ExplicitSchemaStatus + { + Ready = 1, + } + + private sealed class IntAsStringJsonConverter : JsonConverter + { + public override int Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => reader.GetInt32(); + + public override void Write(Utf8JsonWriter writer, int value, JsonSerializerOptions options) => + writer.WriteStringValue(value.ToString(System.Globalization.CultureInfo.InvariantCulture)); + } + + private sealed class AnyOfStructuredOutput + { + public int Count { get; init; } + + public required AnyOfStructuredChild Child { get; init; } + } + + private sealed class AnyOfStructuredChild + { + public required string Id { get; init; } + + public string? Note { get; init; } + } + + private sealed class LargeIntegerStructuredOutput + { + public ulong Value { get; init; } + + public string? Note { get; init; } + } + + private sealed class MathematicalIntegerStructuredOutput + { + public JsonElement Value { get; init; } + + public string? Note { get; init; } + } + + private sealed class StructuralConstOutput + { + public required JsonObject Tag { get; init; } + + public string? Note { get; init; } + } + + private sealed class NumericConstOutput + { + public int Count { get; init; } + + public string? Note { get; init; } + } + + private sealed class ExplicitSchemaFilteredOutput + { + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + public required string Secret { get; init; } + } + + private sealed class ExplicitSchemaFilteredDefaultOutput + { + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + public int Count { get; init; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + public int? NullableCount { get; init; } = 0; + } + + private sealed class GetterCountedStructuredOutput(Action onGet) + { + public string Id + { + get + { + onGet(); + return "42"; + } + } + } + + private sealed class GetterCountedCollectionStructuredOutput(List items) + { + public int ItemsGetterCalls { get; private set; } + + public List Items + { + get + { + ItemsGetterCalls++; + return items; + } + } + } + + private sealed class ExplicitSchemaCollectionItem + { + public required string Id { get; init; } + + public string? Note { get; init; } + } + + private struct NestedDefaultedStructuredChild + { + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Note { get; init; } + } + + private struct NestedOneOfRecoveryChild + { + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Note { get; init; } + } + + private sealed class NestedOneOfRecoveryOutput + { + public NestedOneOfRecoveryChild First { get; init; } + + public NestedOneOfRecoveryChild Second { get; init; } + } + + private sealed class BranchSpecificOneOfRecoveryOutput + { + public required string Tag { get; init; } + + public BranchSpecificOneOfRecoveryChild Child { get; init; } + + public string? Other { get; init; } + } + + private struct BranchSpecificOneOfRecoveryChild + { + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Note { get; init; } + } + + private sealed class NestedDefaultedStructuredOutput + { + public NestedDefaultedStructuredChild Child { get; init; } + } + + private sealed class ExplicitSchemaPatternChildOutput + { + public required ExplicitSchemaPatternChild Child { get; init; } + } + + private sealed class ExplicitSchemaPatternChild + { + public required string Id { get; init; } + + public string? Note { get; init; } + } + + private sealed class TenBranchStructuredOutput + { + public required string Tag { get; init; } + + public string? Note0 { get; init; } + + public string? Note1 { get; init; } + + public string? Note2 { get; init; } + + public string? Note3 { get; init; } + + public string? Note4 { get; init; } + + public string? Note5 { get; init; } + + public string? Note6 { get; init; } + + public string? Note7 { get; init; } + + public string? Note8 { get; init; } + + public string? Note9 { get; init; } + } + + private sealed class ExplicitSchemaOneOfSourceOnlyOutput + { + public required string Id { get; init; } + + public string? Note { get; init; } + } + + private sealed class IntegerKeyDictionaryOutput + { + public required Dictionary Items { get; init; } + } + + private sealed class IntegerKeyDictionaryChild + { + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + public int Count { get; init; } + } + + private sealed class ExplicitSchemaOutputWithNumberHandling + { + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + [JsonNumberHandling(JsonNumberHandling.WriteAsString)] + public int Count { get; init; } + } + + private sealed class RecursiveStructuredOutput + { + public required string Id { get; init; } + + public string? Note { get; init; } + + public RecursiveStructuredOutput? Next { get; init; } + } + private static JsonSerializerOptions CreateSerializerOptionsWithPerson() { JsonSerializerOptions options = new(McpJsonUtilities.DefaultOptions); @@ -1308,6 +4262,9 @@ public async Task EnablePollingAsync_ThrowsInvalidOperationException_WhenTranspo [JsonSerializable(typeof(DateTimeOffset?))] [JsonSerializable(typeof(Person))] [JsonSerializable(typeof(WeatherRequest))] + [JsonSerializable(typeof(ExplicitlyIncludedReadOnlyOutput))] + [JsonSerializable(typeof(RecoveryConditionalOutput))] + [JsonSerializable(typeof(IEnumerable))] partial class JsonContext2 : JsonSerializerContext; // ===== x-mcp-header tests ===== diff --git a/tests/ModelContextProtocol.Tests/Server/McpSkillDirectorySelectionTests.cs b/tests/ModelContextProtocol.Tests/Server/McpSkillDirectorySelectionTests.cs index 62c960662..977c26d36 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpSkillDirectorySelectionTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpSkillDirectorySelectionTests.cs @@ -1,12 +1,10 @@ using ModelContextProtocol.Extensions.Skills; using System.Runtime.InteropServices; -using System.Text.Json.Nodes; namespace ModelContextProtocol.Tests.Server; public class McpSkillDirectorySelectionTests : IDisposable { - private const string SkillUri = "skill://selected/SKILL.md"; private const string Markdown = "---\nname: selected\ndescription: d\n---\n"; private readonly string _root = Path.Combine(Path.GetTempPath(), "mcp-selection-" + Guid.NewGuid().ToString("N")); @@ -19,34 +17,28 @@ public McpSkillDirectorySelectionTests() File.WriteAllText(Path.Combine(_root, ".git", "config"), "synthetic-config"); } - [Theory] - [InlineData(0)] - [InlineData(1)] - [InlineData(2)] - public void ExistingDirectoryOverloadsExcludeDotEntries(int overload) + [Fact] + public void DirectoryAuthoringExcludesDotEntriesByDefault() { using var held = new FileStream(Path.Combine(_root, ".git", "config"), FileMode.Open, FileAccess.Read, FileShare.None); - var skill = Create(overload, includeHidden: false); + var skill = Create(includeHidden: false); Assert.Equal(2, skill.Resources.Count); - Assert.DoesNotContain(skill.ProtocolSkill.Resources.Resources!, resource => resource.Uri.Contains("/.")); + Assert.DoesNotContain(skill.Skill.ProtocolSkill!.Resources.Entries!, resource => resource.Uri.Contains("/.")); Assert.Equal( - skill.ProtocolSkill.Resources.Resources!.Select(resource => resource.Uri), + skill.Skill.ProtocolSkill!.Resources.Entries!.Select(resource => resource.Uri), skill.Resources.Select(resource => resource.ProtocolResource!.Uri)); } - [Theory] - [InlineData(0)] - [InlineData(1)] - [InlineData(2)] - public void HiddenEntriesCanBeIncludedExplicitly(int overload) + [Fact] + public void HiddenEntriesCanBeIncludedExplicitly() { - var skill = Create(overload, includeHidden: true); + var skill = Create(includeHidden: true); Assert.Equal(4, skill.Resources.Count); - Assert.Contains(skill.ProtocolSkill.Resources.Resources!, resource => resource.Uri.EndsWith("/.env", StringComparison.Ordinal)); - Assert.Contains(skill.ProtocolSkill.Resources.Resources!, resource => resource.Uri.EndsWith("/.git/config", StringComparison.Ordinal)); + Assert.Contains(skill.Skill.ProtocolSkill!.Resources.Entries!, resource => resource.Uri.EndsWith("/.env", StringComparison.Ordinal)); + Assert.Contains(skill.Skill.ProtocolSkill!.Resources.Entries!, resource => resource.Uri.EndsWith("/.git/config", StringComparison.Ordinal)); } [Theory] @@ -59,49 +51,35 @@ public void AttributeMarkedFilesAreExcludedUnlessExplicitlyIncluded(FileAttribut File.WriteAllText(path, "synthetic"); File.SetAttributes(path, File.GetAttributes(path) | attribute); - Assert.DoesNotContain(Create(0, false).Resources, resource => resource.ProtocolResource!.Uri.EndsWith("/marked.txt", StringComparison.Ordinal)); - Assert.Contains(Create(0, true).Resources, resource => resource.ProtocolResource!.Uri.EndsWith("/marked.txt", StringComparison.Ordinal)); + Assert.DoesNotContain(Create(false).Resources, resource => resource.ProtocolResource!.Uri.EndsWith("/marked.txt", StringComparison.Ordinal)); + Assert.Contains(Create(true).Resources, resource => resource.ProtocolResource!.Uri.EndsWith("/marked.txt", StringComparison.Ordinal)); } [Fact] - public void ExcludedFilesAreNotReadOrCountedAgainstThePublishedSizeLimit() + public void ExcludedFilesAreNotReadEvenWhenTheyAreLargeAndLocked() { - using (var file = new FileStream(Path.Combine(_root, ".large-cache"), FileMode.Create)) - { - file.SetLength(SkillsProtocol.MaxTotalSizeBytes + 1); - } + using var file = new FileStream(Path.Combine(_root, ".large-cache"), FileMode.Create, FileAccess.ReadWrite, FileShare.None); + file.SetLength(16 * 1024 * 1024 + 1); - Assert.Equal(2, Create(0, false).Resources.Count); - Assert.Throws(() => Create(0, true)); + Assert.Equal(2, Create(false).Resources.Count); + Assert.Throws(() => Create(true)); } [Fact] public void ExplicitFileAuthoringRetainsTheCallersFileSelection() { - var skill = McpServerSkill.Create( + var skill = McpServerSkillPackage.Create( [ McpServerSkillFile.FromText("SKILL.md", Markdown), McpServerSkillFile.FromText(".env", "synthetic-explicit-value"), ]); Assert.Equal(2, skill.Resources.Count); - Assert.Contains(skill.ProtocolSkill.Resources.Resources!, resource => resource.Uri.EndsWith("/.env", StringComparison.Ordinal)); + Assert.Contains(skill.Skill.ProtocolSkill!.Resources.Entries!, resource => resource.Uri.EndsWith("/.env", StringComparison.Ordinal)); } - private McpServerSkill Create(int overload, bool includeHidden) - { - var metadata = new JsonObject { ["name"] = "selected", ["description"] = "d" }; - return (overload, includeHidden) switch - { - (0, false) => McpServerSkill.CreateFromDirectory(_root), - (1, false) => McpServerSkill.CreateFromDirectory(SkillUri, _root), - (2, false) => McpServerSkill.CreateFromDirectory(SkillUri, metadata, _root), - (0, true) => McpServerSkill.CreateFromDirectory(_root, includeHiddenAndSystemFiles: true), - (1, true) => McpServerSkill.CreateFromDirectory(SkillUri, _root, includeHiddenAndSystemFiles: true), - (2, true) => McpServerSkill.CreateFromDirectory(SkillUri, metadata, _root, includeHiddenAndSystemFiles: true), - _ => throw new ArgumentOutOfRangeException(nameof(overload)), - }; - } + private McpServerSkillPackage Create(bool includeHidden) => + McpServerSkillPackage.CreateFromDirectory(_root, new McpServerSkillPackageCreateOptions { IncludeHiddenAndSystemFiles = includeHidden }); public void Dispose() => Directory.Delete(_root, recursive: true); } diff --git a/tests/ModelContextProtocol.Tests/Server/McpSkillsOptionsTests.cs b/tests/ModelContextProtocol.Tests/Server/McpSkillsOptionsTests.cs new file mode 100644 index 000000000..1e15f3733 --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Server/McpSkillsOptionsTests.cs @@ -0,0 +1,41 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using ModelContextProtocol.Extensions.Skills; + +namespace ModelContextProtocol.Tests.Server; + +public class McpSkillsOptionsTests +{ + [Fact] + public async Task ConfigurationIsDeferredServiceAwareAndOrdered_AndPreservesTheSuppliedCollection() + { + var services = new ServiceCollection(); + var order = new List(); + var collection = new McpServerSkillCollection(); + var skill = McpServerSkillPackage.Create( + [McpServerSkillFile.FromText("SKILL.md", "---\nname: alpha\ndescription: Alpha\n---\n")]).Skill; + services.AddSingleton(skill); + services.Configure(options => + { + order.Add("configure"); + options.SkillCollection = collection; + }); + + services.AddMcpServer().WithSkills(); + services.AddOptions().Configure((options, registered) => + { + order.Add("service"); + Assert.Same(skill, registered); + }).Validate(options => options.SkillCollection.Count == 1, "The registered skill is required."); + services.PostConfigure(options => order.Add("post")); + Assert.Empty(order); + await using var provider = services.BuildServiceProvider(new ServiceProviderOptions { ValidateOnBuild = true }); + Assert.Empty(order); + var options = provider.GetRequiredService>().Value; + Assert.Equal(["configure", "service", "post"], order); + Assert.Same(collection, options.SkillCollection); + Assert.Same(skill, Assert.Single(collection)); + _ = provider.GetRequiredService>().Value; + Assert.Equal(3, order.Count); + } +} diff --git a/tests/ModelContextProtocol.Tests/Server/SkillFrontmatterNodeClassificationTests.cs b/tests/ModelContextProtocol.Tests/Server/SkillFrontmatterNodeClassificationTests.cs index 660109598..1ba00633f 100644 --- a/tests/ModelContextProtocol.Tests/Server/SkillFrontmatterNodeClassificationTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/SkillFrontmatterNodeClassificationTests.cs @@ -20,15 +20,15 @@ public void FlowCollectionKeys_KeepTheExplicitFrontmatterFallback(string body, s McpServerSkill? skill = null; var parseException = Record.Exception(() => SkillFrontmatter.Parse(markdown)); - var automaticException = Record.Exception(() => McpServerSkill.Create(files)); - var explicitException = Record.Exception(() => skill = McpServerSkill.Create("skill://alpha/SKILL.md", supplied, files)); + var automaticException = Record.Exception(() => McpServerSkillPackage.Create(files).Skill); + var explicitException = Record.Exception(() => skill = McpServerSkillPackage.Create(files, new McpServerSkillPackageCreateOptions { Uri = "skill://alpha/SKILL.md", Frontmatter = supplied }).Skill); Assert.Equal("UnsupportedYamlException", parseException?.GetType().Name); Assert.Contains("flow collections are not supported as mapping keys", parseException!.Message); Assert.IsType(automaticException); Assert.Null(explicitException); Assert.NotNull(skill); - Assert.True(JsonNode.DeepEquals(supplied, skill.ProtocolSkill.Frontmatter)); + Assert.True(JsonNode.DeepEquals(supplied, skill.ProtocolSkill!.Frontmatter)); } [Theory] @@ -50,9 +50,9 @@ public void NextLineValuesAndQuotedKeys_RemainSupported(string body, string expe var files = new[] { McpServerSkillFile.FromText("SKILL.md", markdown) }; Assert.True(JsonNode.DeepEquals(expected, SkillFrontmatter.Parse(markdown))); - Assert.True(JsonNode.DeepEquals(expected, McpServerSkill.Create(files).ProtocolSkill.Frontmatter)); + Assert.True(JsonNode.DeepEquals(expected, McpServerSkillPackage.Create(files).Skill.ProtocolSkill!.Frontmatter)); Assert.True(JsonNode.DeepEquals(expected, - McpServerSkill.Create("skill://alpha/SKILL.md", expected, files).ProtocolSkill.Frontmatter)); + McpServerSkillPackage.Create(files, new McpServerSkillPackageCreateOptions { Uri = "skill://alpha/SKILL.md", Frontmatter = expected }).Skill.ProtocolSkill!.Frontmatter)); } [Theory] @@ -68,8 +68,8 @@ public void UnsupportedNextLineNodeProperties_KeepTheImportedRejection(string bo var files = new[] { McpServerSkillFile.FromText("SKILL.md", markdown) }; Assert.Throws(() => SkillFrontmatter.Parse(markdown)); - Assert.Throws(() => McpServerSkill.Create(files)); - Assert.Throws(() => McpServerSkill.Create("skill://alpha/SKILL.md", supplied, files)); + Assert.Throws(() => McpServerSkillPackage.Create(files).Skill); + Assert.Throws(() => McpServerSkillPackage.Create(files, new McpServerSkillPackageCreateOptions { Uri = "skill://alpha/SKILL.md", Frontmatter = supplied }).Skill); } [Theory] @@ -83,8 +83,8 @@ public void MalformedFlowValueSuffixes_DoNotBecomeUnsupportedKeys(string body, s var files = new[] { McpServerSkillFile.FromText("SKILL.md", markdown) }; Assert.Throws(() => SkillFrontmatter.Parse(markdown)); - Assert.Throws(() => McpServerSkill.Create(files)); - Assert.Throws(() => McpServerSkill.Create("skill://alpha/SKILL.md", supplied, files)); + Assert.Throws(() => McpServerSkillPackage.Create(files).Skill); + Assert.Throws(() => McpServerSkillPackage.Create(files, new McpServerSkillPackageCreateOptions { Uri = "skill://alpha/SKILL.md", Frontmatter = supplied }).Skill); } [Fact] @@ -101,14 +101,14 @@ public void ExplicitFlowKeyInTags_IsUnsupportedAndKeepsTheExplicitFrontmatterFal var files = new[] { McpServerSkillFile.FromText("SKILL.md", markdown) }; var parseException = Record.Exception(() => SkillFrontmatter.Parse(markdown)); - var automaticException = Assert.Throws(() => McpServerSkill.Create(files)); - var skill = McpServerSkill.Create("skill://alpha/SKILL.md", supplied, files); + var automaticException = Assert.Throws(() => McpServerSkillPackage.Create(files).Skill); + var skill = McpServerSkillPackage.Create(files, new McpServerSkillPackageCreateOptions { Uri = "skill://alpha/SKILL.md", Frontmatter = supplied }).Skill; Assert.Equal("UnsupportedYamlException", parseException?.GetType().Name); Assert.Contains("explicit mapping keys", parseException!.Message); Assert.Contains("does not support", automaticException.Message); Assert.Contains("explicit mapping keys", automaticException.Message); - Assert.True(JsonNode.DeepEquals(supplied, skill.ProtocolSkill.Frontmatter)); + Assert.True(JsonNode.DeepEquals(supplied, skill.ProtocolSkill!.Frontmatter)); } [Theory] @@ -150,13 +150,13 @@ public void CommentInsideAFlowSequence_IsUnsupportedAndKeepsTheExplicitFrontmatt var files = new[] { McpServerSkillFile.FromText("SKILL.md", markdown) }; var parseException = Record.Exception(() => SkillFrontmatter.Parse(markdown)); - var automaticException = Assert.Throws(() => McpServerSkill.Create(files)); - var skill = McpServerSkill.Create("skill://test/SKILL.md", supplied, files); + var automaticException = Assert.Throws(() => McpServerSkillPackage.Create(files).Skill); + var skill = McpServerSkillPackage.Create(files, new McpServerSkillPackageCreateOptions { Uri = "skill://test/SKILL.md", Frontmatter = supplied }).Skill; Assert.Equal("UnsupportedYamlException", parseException?.GetType().Name); Assert.Contains("multi-line flow collections", parseException!.Message); Assert.Contains("does not support", automaticException.Message); - Assert.True(JsonNode.DeepEquals(supplied, skill.ProtocolSkill.Frontmatter)); + Assert.True(JsonNode.DeepEquals(supplied, skill.ProtocolSkill!.Frontmatter)); } [Theory] @@ -224,9 +224,9 @@ public void IndentedRootMapping_IsReadForEveryAuthoringPath(string frontmatter, var files = new[] { McpServerSkillFile.FromText("SKILL.md", markdown) }; Assert.True(JsonNode.DeepEquals(expected, SkillFrontmatter.Parse(markdown))); - Assert.True(JsonNode.DeepEquals(expected, McpServerSkill.Create(files).ProtocolSkill.Frontmatter)); + Assert.True(JsonNode.DeepEquals(expected, McpServerSkillPackage.Create(files).Skill.ProtocolSkill!.Frontmatter)); Assert.True(JsonNode.DeepEquals(expected, - McpServerSkill.Create("skill://alpha/SKILL.md", expected, files).ProtocolSkill.Frontmatter)); + McpServerSkillPackage.Create(files, new McpServerSkillPackageCreateOptions { Uri = "skill://alpha/SKILL.md", Frontmatter = expected }).Skill.ProtocolSkill!.Frontmatter)); } [Theory] @@ -285,9 +285,9 @@ public void IndicatorFollowedByColon_IsAPlainFlowMappingKey(string body, string var files = new[] { McpServerSkillFile.FromText("SKILL.md", markdown) }; Assert.True(JsonNode.DeepEquals(expected, SkillFrontmatter.Parse(markdown))); - Assert.True(JsonNode.DeepEquals(expected, McpServerSkill.Create(files).ProtocolSkill.Frontmatter)); + Assert.True(JsonNode.DeepEquals(expected, McpServerSkillPackage.Create(files).Skill.ProtocolSkill!.Frontmatter)); Assert.True(JsonNode.DeepEquals(expected, - McpServerSkill.Create("skill://alpha/SKILL.md", expected, files).ProtocolSkill.Frontmatter)); + McpServerSkillPackage.Create(files, new McpServerSkillPackageCreateOptions { Uri = "skill://alpha/SKILL.md", Frontmatter = expected }).Skill.ProtocolSkill!.Frontmatter)); } [Theory] @@ -320,9 +320,9 @@ public void RootThatIsNotAMapping_IsRejectedAsNotAMappingForEveryAuthoringPath(s var files = new[] { McpServerSkillFile.FromText("SKILL.md", markdown) }; var parseException = Assert.ThrowsAny(() => SkillFrontmatter.Parse(markdown)); - var automaticException = Assert.Throws(() => McpServerSkill.Create(files)); + var automaticException = Assert.Throws(() => McpServerSkillPackage.Create(files).Skill); var explicitException = Assert.Throws( - () => McpServerSkill.Create("skill://alpha/SKILL.md", CreateFrontmatter("null"), files)); + () => McpServerSkillPackage.Create(files, new McpServerSkillPackageCreateOptions { Uri = "skill://alpha/SKILL.md", Frontmatter = CreateFrontmatter("null") }).Skill); Assert.NotEqual("UnsupportedYamlException", parseException.GetType().Name); Assert.Contains("must be a YAML mapping", parseException.Message); @@ -340,9 +340,9 @@ public void RootFlowMapping_IsReadForEveryAuthoringPath(string frontmatter) var files = new[] { McpServerSkillFile.FromText("SKILL.md", markdown) }; Assert.True(JsonNode.DeepEquals(expected, SkillFrontmatter.Parse(markdown))); - Assert.True(JsonNode.DeepEquals(expected, McpServerSkill.Create(files).ProtocolSkill.Frontmatter)); + Assert.True(JsonNode.DeepEquals(expected, McpServerSkillPackage.Create(files).Skill.ProtocolSkill!.Frontmatter)); Assert.True(JsonNode.DeepEquals(expected, - McpServerSkill.Create("skill://alpha/SKILL.md", expected, files).ProtocolSkill.Frontmatter)); + McpServerSkillPackage.Create(files, new McpServerSkillPackageCreateOptions { Uri = "skill://alpha/SKILL.md", Frontmatter = expected }).Skill.ProtocolSkill!.Frontmatter)); } [Theory] @@ -380,14 +380,14 @@ private void AssertUnsupportedMarkdownWithExplicitFallback(string markdown, Json McpServerSkill? skill = null; var parseException = Record.Exception(() => SkillFrontmatter.Parse(markdown)); - var automaticException = Record.Exception(() => McpServerSkill.Create(files)); - var explicitException = Record.Exception(() => skill = McpServerSkill.Create("skill://alpha/SKILL.md", supplied, files)); + var automaticException = Record.Exception(() => McpServerSkillPackage.Create(files).Skill); + var explicitException = Record.Exception(() => skill = McpServerSkillPackage.Create(files, new McpServerSkillPackageCreateOptions { Uri = "skill://alpha/SKILL.md", Frontmatter = supplied }).Skill); Assert.Equal("UnsupportedYamlException", parseException?.GetType().Name); Assert.Contains(messageFragment, parseException!.Message); Assert.IsType(automaticException); Assert.Null(explicitException); - Assert.True(JsonNode.DeepEquals(supplied, skill!.ProtocolSkill.Frontmatter)); + Assert.True(JsonNode.DeepEquals(supplied, skill!.ProtocolSkill!.Frontmatter)); } private void AssertMalformedForEveryAuthoringPath(string body) @@ -402,8 +402,8 @@ private void AssertMalformedMarkdownForEveryAuthoringPath(string markdown) var parseException = Assert.ThrowsAny(() => SkillFrontmatter.Parse(markdown)); Assert.NotEqual("UnsupportedYamlException", parseException.GetType().Name); - Assert.Throws(() => McpServerSkill.Create(files)); - var explicitException = Assert.Throws(() => McpServerSkill.Create("skill://alpha/SKILL.md", supplied, files)); + Assert.Throws(() => McpServerSkillPackage.Create(files).Skill); + var explicitException = Assert.Throws(() => McpServerSkillPackage.Create(files, new McpServerSkillPackageCreateOptions { Uri = "skill://alpha/SKILL.md", Frontmatter = supplied }).Skill); Assert.Contains("could not be read", explicitException.Message); } diff --git a/tests/ModelContextProtocol.Tests/Server/SkillFrontmatterRegressionTests.cs b/tests/ModelContextProtocol.Tests/Server/SkillFrontmatterRegressionTests.cs index 6a8c193e7..77c175323 100644 --- a/tests/ModelContextProtocol.Tests/Server/SkillFrontmatterRegressionTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/SkillFrontmatterRegressionTests.cs @@ -31,9 +31,9 @@ public void NestedFlowValuesAreRejectedInsteadOfPublishedAsPlainKeys(string yaml var error = Record.Exception(() => SkillFrontmatter.Parse(markdown)); Assert.Equal("UnsupportedYamlException", error?.GetType().Name); Assert.Contains("not supported", error!.Message); - Assert.Throws(() => McpServerSkill.Create(files)); - var supplied = McpServerSkill.Create("skill://alpha/SKILL.md", expected, files); - Assert.True(JsonNode.DeepEquals(expected, supplied.ProtocolSkill.Frontmatter)); + Assert.Throws(() => McpServerSkillPackage.Create(files).Skill); + var supplied = McpServerSkillPackage.Create(files, new McpServerSkillPackageCreateOptions { Uri = "skill://alpha/SKILL.md", Frontmatter = expected }).Skill; + Assert.True(JsonNode.DeepEquals(expected, supplied.ProtocolSkill!.Frontmatter)); } [Theory] @@ -47,8 +47,8 @@ public void FlowIndicatorsCannotBecomeUnquotedScalarContent(string yaml) var supplied = new JsonObject { ["name"] = "alpha", ["description"] = "d", ["extra"] = yaml }; Assert.Throws(() => SkillFrontmatter.Parse(markdown)); - Assert.Throws(() => McpServerSkill.Create(files)); - Assert.Throws(() => McpServerSkill.Create("skill://alpha/SKILL.md", supplied, files)); + Assert.Throws(() => McpServerSkillPackage.Create(files).Skill); + Assert.Throws(() => McpServerSkillPackage.Create(files, new McpServerSkillPackageCreateOptions { Uri = "skill://alpha/SKILL.md", Frontmatter = supplied }).Skill); } [Theory] @@ -277,8 +277,8 @@ public void UnseparatedTrailingComment_IsRejectedByParserAndBothAuthoringOverloa var files = new[] { McpServerSkillFile.FromText("SKILL.md", markdown) }; var parseException = Record.Exception(() => SkillFrontmatter.Parse(markdown)); - var automaticException = Record.Exception(() => McpServerSkill.Create(files)); - var explicitException = Record.Exception(() => McpServerSkill.Create("skill://comparison/SKILL.md", expected, files)); + var automaticException = Record.Exception(() => McpServerSkillPackage.Create(files).Skill); + var explicitException = Record.Exception(() => McpServerSkillPackage.Create(files, new McpServerSkillPackageCreateOptions { Uri = "skill://comparison/SKILL.md", Frontmatter = expected }).Skill); Assert.IsType(parseException); Assert.IsType(automaticException); @@ -320,8 +320,8 @@ public void NonFiniteScalar_CannotBeReplacedWithJsonNullThroughExplicitFrontmatt var exception = Assert.Throws(() => SkillFrontmatter.Parse(markdown)); Assert.Contains("cannot be represented in JSON", exception.Message); - Assert.Throws(() => McpServerSkill.Create(files)); - Assert.Throws(() => McpServerSkill.Create("skill://alpha/SKILL.md", supplied, files)); + Assert.Throws(() => McpServerSkillPackage.Create(files).Skill); + Assert.Throws(() => McpServerSkillPackage.Create(files, new McpServerSkillPackageCreateOptions { Uri = "skill://alpha/SKILL.md", Frontmatter = supplied }).Skill); } [Theory] @@ -382,7 +382,7 @@ public void RawCharacter_IsRejectedEvenAfterAnUnsupportedConstruct() var files = new[] { McpServerSkillFile.FromText("SKILL.md", markdown) }; var supplied = new JsonObject { ["name"] = "alpha", ["x"] = 1, ["description"] = "x\0y" }; - var exception = Assert.Throws(() => McpServerSkill.Create("skill://alpha/SKILL.md", supplied, files)); + var exception = Assert.Throws(() => McpServerSkillPackage.Create(files, new McpServerSkillPackageCreateOptions { Uri = "skill://alpha/SKILL.md", Frontmatter = supplied }).Skill); Assert.Contains("Line 4:", exception.Message); Assert.Contains("U+0000", exception.Message); @@ -444,12 +444,12 @@ private void AssertParsedAndPublished(string markdown, JsonObject expected) { var parsed = SkillFrontmatter.Parse(markdown); var files = new[] { McpServerSkillFile.FromText("SKILL.md", markdown) }; - var automatic = McpServerSkill.Create(files); - var explicitMetadata = McpServerSkill.Create(automatic.ProtocolSkill.Uri, expected, files); + var automatic = McpServerSkillPackage.Create(files).Skill; + var explicitMetadata = McpServerSkillPackage.Create(files, new McpServerSkillPackageCreateOptions { Uri = automatic.ProtocolSkill!.Uri, Frontmatter = expected }).Skill; Assert.True(JsonNode.DeepEquals(expected, parsed), $"Expected {expected}, parsed {parsed}."); - Assert.True(JsonNode.DeepEquals(expected, automatic.ProtocolSkill.Frontmatter)); - Assert.True(JsonNode.DeepEquals(expected, explicitMetadata.ProtocolSkill.Frontmatter)); + Assert.True(JsonNode.DeepEquals(expected, automatic.ProtocolSkill!.Frontmatter)); + Assert.True(JsonNode.DeepEquals(expected, explicitMetadata.ProtocolSkill!.Frontmatter)); } private string ParseValue(string literal) => @@ -466,8 +466,8 @@ private void AssertRawCharacterIsRejected(string entry, int codePoint) var supplied = new JsonObject { ["name"] = "alpha", ["description"] = "d" }; var parseException = Record.Exception(() => SkillFrontmatter.Parse(markdown)); - var automaticException = Record.Exception(() => McpServerSkill.Create(files)); - var explicitException = Record.Exception(() => McpServerSkill.Create("skill://alpha/SKILL.md", supplied, files)); + var automaticException = Record.Exception(() => McpServerSkillPackage.Create(files).Skill); + var explicitException = Record.Exception(() => McpServerSkillPackage.Create(files, new McpServerSkillPackageCreateOptions { Uri = "skill://alpha/SKILL.md", Frontmatter = supplied }).Skill); string character = $"U+{codePoint:X4}"; Assert.Contains(character, Assert.IsType(parseException).Message); @@ -488,8 +488,8 @@ private Exception AssertWideIntegerIsUnsupported(string literal) var supplied = new JsonObject { ["name"] = "alpha", ["description"] = "d", ["value"] = literal }; var parseException = Record.Exception(() => SkillFrontmatter.Parse(markdown)); - var automaticException = Assert.Throws(() => McpServerSkill.Create(files)); - var skill = McpServerSkill.Create("skill://alpha/SKILL.md", supplied, files); + var automaticException = Assert.Throws(() => McpServerSkillPackage.Create(files).Skill); + var skill = McpServerSkillPackage.Create(files, new McpServerSkillPackageCreateOptions { Uri = "skill://alpha/SKILL.md", Frontmatter = supplied }).Skill; Assert.Equal("UnsupportedYamlException", parseException?.GetType().Name); Assert.IsAssignableFrom(parseException); @@ -498,7 +498,7 @@ private Exception AssertWideIntegerIsUnsupported(string literal) Assert.Contains("full YAML parser", parseException.Message); Assert.Contains("does not support", automaticException.Message); Assert.Contains("wider than 64 bits", automaticException.Message); - Assert.True(JsonNode.DeepEquals(supplied, skill.ProtocolSkill.Frontmatter)); + Assert.True(JsonNode.DeepEquals(supplied, skill.ProtocolSkill!.Frontmatter)); return parseException; } } diff --git a/tests/ModelContextProtocol.Tests/Server/SkillFrontmatterTests.cs b/tests/ModelContextProtocol.Tests/Server/SkillFrontmatterTests.cs index 03d175710..4b392e96c 100644 --- a/tests/ModelContextProtocol.Tests/Server/SkillFrontmatterTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/SkillFrontmatterTests.cs @@ -475,11 +475,11 @@ public void RoundTripsThroughSkillEntryValidation() { const string Markdown = "---\nname: git-workflow\ndescription: Git conventions\n---\n\n# Git workflow\n"; - var skill = McpServerSkill.Create([McpServerSkillFile.FromText("SKILL.md", Markdown)]); + var skill = McpServerSkillPackage.Create([McpServerSkillFile.FromText("SKILL.md", Markdown)]).Skill; - Assert.Equal("skill://git-workflow/SKILL.md", skill.ProtocolSkill.Uri); - Assert.Equal("git-workflow", skill.ProtocolSkill.Name); - Assert.Equal("Git conventions", skill.ProtocolSkill.Description); + Assert.Equal("skill://git-workflow/SKILL.md", skill.ProtocolSkill!.Uri); + Assert.Equal("git-workflow", skill.ProtocolSkill!.Name); + Assert.Equal("Git conventions", skill.ProtocolSkill!.Description); } [Fact] diff --git a/tests/ModelContextProtocol.Tests/Server/SkillsCustomProtocolTests.cs b/tests/ModelContextProtocol.Tests/Server/SkillsCustomProtocolTests.cs new file mode 100644 index 000000000..a4bb3727d --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Server/SkillsCustomProtocolTests.cs @@ -0,0 +1,43 @@ +using ModelContextProtocol.Extensions.Skills; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Tests.Utils; + +namespace ModelContextProtocol.Tests.Server; + +public class SkillsCustomProtocolTests(ITestOutputHelper output) : ClientServerTestBase(output, startServer: false) +{ + [Fact] + public async Task LegacyStatefulStreamSupportsDirectSamplingAndElicitation() + { + McpServerBuilder.WithSkills(options => options.ListSkillsHandler = async (context, cancellationToken) => + { + await context.Server.SampleAsync(new CreateMessageRequestParams { Messages = [], MaxTokens = 10 }, cancellationToken); + await context.Server.ElicitAsync(new ElicitRequestParams { Message = "Confirm", RequestedSchema = new() }, cancellationToken); + return new ListSkillsResult(); + }); + + StartServer(); + int samples = 0, elicitations = 0; + var clientOptions = TestMcpClient.CreateOptions(); + clientOptions.ProtocolVersion = McpProtocolVersions.November2025ProtocolVersion; + clientOptions.Handlers.SamplingHandler = (_, _, _) => + { + samples++; + return new(new CreateMessageResult { Model = "test", Content = [] }); + }; + + clientOptions.Handlers.ElicitationHandler = (_, _) => + { + elicitations++; + return new(new ElicitResult { Action = "accept" }); + }; + + await using var client = await CreateMcpClientForServer(clientOptions); + var response = await client.SendRequestAsync(new JsonRpcRequest { Method = SkillsProtocol.MethodSkillsList }, + TestContext.Current.CancellationToken); + Assert.Equal(1, samples); + Assert.Equal(1, elicitations); + Assert.Null(response.Result!["inputRequests"]); + Assert.Null(response.Result["resultType"]); + } +} diff --git a/tests/ModelContextProtocol.Tests/Server/SkillsUriIdentityTests.cs b/tests/ModelContextProtocol.Tests/Server/SkillsUriIdentityTests.cs new file mode 100644 index 000000000..8c5dd4064 --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Server/SkillsUriIdentityTests.cs @@ -0,0 +1,95 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using ModelContextProtocol.Extensions.Skills; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; + +namespace ModelContextProtocol.Tests.Server; + +public class SkillsUriIdentityTests : ClientServerTestBase +{ + private const string Uri = "skill://catalog/alpha/SKILL.md"; + private readonly McpServerSkill _skill; + + public SkillsUriIdentityTests(ITestOutputHelper output) : base(output, startServer: false) + { + var package = McpServerSkillPackage.Create([McpServerSkillFile.FromText("SKILL.md", "---\nname: alpha\ndescription: Alpha\n---\n")], new McpServerSkillPackageCreateOptions { Uri = Uri }); + _skill = package.Skill; + McpServerBuilder.WithSkillPackages([package]); + StartServer(); + } + + [Theory] + [InlineData("SKILL://CATALOG/alpha/SKILL.md")] + [InlineData("skill://catalog/%61lpha/SKILL.md")] + public async Task EquivalentLookupAndVerifiedReadPreserveAuthoredText(string alias) + { + await using var client = await CreateMcpClientForServer(); + var entry = await client.GetSkillAsync(alias, cancellationToken: TestContext.Current.CancellationToken); + Assert.Equal(Uri, entry.Uri); + Assert.Equal(Uri, Assert.Single(entry.Resources.Entries!).Uri); + var read = await client.ReadSkillResourceAsync(entry, alias, cancellationToken: TestContext.Current.CancellationToken); + Assert.Equal(Uri, Assert.Single(read.Contents).Uri); + } + + [Theory] + [InlineData("skill://catalog/Alpha/SKILL.md")] + [InlineData("skill://other/alpha/SKILL.md")] + [InlineData("skill://catalog/alpha/SKILL.md#fragment")] + [InlineData("skill://catalog/alpha/SKILL.md?query")] + [InlineData("skill://catalog/other/../alpha/SKILL.md")] + public async Task DifferentOrUnsupportedIdentityIsNotAccepted(string uri) + { + await using var client = await CreateMcpClientForServer(); + await Assert.ThrowsAsync(async () => await client.GetSkillAsync(uri, cancellationToken: TestContext.Current.CancellationToken)); + await Assert.ThrowsAsync( + async () => await client.ReadSkillResourceAsync(_skill.ProtocolSkill!, uri, cancellationToken: TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task EncodedBracePackageCanBeRead_ButRawBracesAreNotAnAlias() + { + const string Encoded = "skill://catalog/%7Btenant%7D/alpha/SKILL.md"; + const string Raw = "skill://catalog/{tenant}/alpha/SKILL.md"; + var package = McpServerSkillPackage.Create( + [McpServerSkillFile.FromText("SKILL.md", "---\nname: alpha\ndescription: Alpha\n---\n")], + new() { Uri = Encoded }); + ServiceProvider.GetRequiredService>().Value.SkillCollection.Add(package.Skill); + foreach (var resource in package.Resources) + { + Server.ServerOptions.ResourceCollection!.Add(resource); + } + + await using var client = await CreateMcpClientForServer(); + var entry = await client.GetSkillAsync(Encoded, cancellationToken: TestContext.Current.CancellationToken); + Assert.Equal(Encoded, entry.Uri); + var read = await client.ReadSkillResourceAsync(entry, Encoded, cancellationToken: TestContext.Current.CancellationToken); + Assert.Equal(Encoded, Assert.Single(read.Contents).Uri); + + await Assert.ThrowsAsync( + async () => await client.GetSkillAsync(Raw, cancellationToken: TestContext.Current.CancellationToken)); + await Assert.ThrowsAsync( + async () => await client.ReadSkillResourceAsync(entry, Raw, cancellationToken: TestContext.Current.CancellationToken)); + } + + [Fact] + public void EquivalentManifestEntriesAreAmbiguous_AndAliasContentStillRequiresDigestAndSize() + { + var entry = _skill.ProtocolSkill!; + var expected = entry.Resources.Entries![0]; + var alias = new SkillResource { Uri = "SKILL://CATALOG/alpha/SKILL.md", Digest = expected.Digest, Size = expected.Size }; + var contents = new TextResourceContents + { + Uri = alias.Uri, Text = "---\nname: alpha\ndescription: Alpha\n---\n", + }; + + SkillVerifier.Verify(expected, contents); + contents.Text += "changed"; + Assert.Throws(() => SkillVerifier.Verify(expected, contents)); + entry.Resources = SkillResources.FromResources([expected, alias]); + var result = new ReadResourceResult { Contents = [contents] }; + Assert.Contains("ambiguous", Assert.Throws(() => SkillVerifier.Verify(entry, result)).Message); + Assert.Contains("ambiguous", Assert.Throws(() => SkillVerifier.Verify(entry, expected.Uri, result)).Message); + Assert.Throws(() => McpServerSkill.Create(entry)); + } +} diff --git a/tests/ModelContextProtocol.Tests/Transport/StdioClientTransportTests.cs b/tests/ModelContextProtocol.Tests/Transport/StdioClientTransportTests.cs index 5a3705c4e..88170c02e 100644 --- a/tests/ModelContextProtocol.Tests/Transport/StdioClientTransportTests.cs +++ b/tests/ModelContextProtocol.Tests/Transport/StdioClientTransportTests.cs @@ -13,6 +13,22 @@ public class StdioClientTransportTests(ITestOutputHelper testOutputHelper) : Log { public static bool IsStdErrCallbackSupported => !PlatformDetection.IsMonoRuntime; + [Fact(Skip = "Platform not supported by this test.", SkipUnless = nameof(IsStdErrCallbackSupported))] + public async Task ConnectAsync_ImmediatelyExitedProcesses_CompleteTheirReaders() + { + await Task.WhenAll(Enumerable.Range(0, 16).Select(async _ => + { + StdioClientTransport transport = PlatformDetection.IsWindows ? + new(new() { Command = "cmd", Arguments = ["/c", "exit /b 0"] }, LoggerFactory) : + new(new() { Command = "sh", Arguments = ["-c", "exit 0"] }, LoggerFactory); + + await using ITransport session = await transport.ConnectAsync(TestContext.Current.CancellationToken); + var exception = await Assert.ThrowsAsync( + () => session.MessageReader.Completion.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken)); + Assert.IsType(exception.Details); + })); + } + [Fact] public async Task DisposeAsync_ClosesServerStandardInputForGracefulExit() { @@ -118,39 +134,23 @@ public async Task CreateAsync_ValidProcessInvalidServer_Throws() await Assert.ThrowsAnyAsync(() => McpClient.CreateAsync(transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken)); } - [Fact(Skip= "Platform not supported by this test.", SkipUnless = nameof(IsStdErrCallbackSupported))] + [Fact(Skip = "Platform not supported by this test.", SkipUnless = nameof(IsStdErrCallbackSupported))] public async Task CreateAsync_ValidProcessInvalidServer_StdErrCallbackInvoked() { string id = Guid.NewGuid().ToString("N"); - int count = 0; - StringBuilder sb = new(); - Action stdErrCallback = line => - { - Assert.NotNull(line); - lock (sb) - { - sb.AppendLine(line); - count++; - } - }; + var stderr = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + Action stdErrCallback = line => stderr.TrySetResult(line); StdioClientTransport transport = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? new(new() { Command = "cmd", Arguments = ["/c", $"echo {id} >&2 & exit /b 1"], StandardErrorLines = stdErrCallback }, LoggerFactory) : new(new() { Command = "sh", Arguments = ["-c", $"echo {id} >&2; exit 1"], StandardErrorLines = stdErrCallback }, LoggerFactory); - await Assert.ThrowsAnyAsync(() => McpClient.CreateAsync(transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken)); - - // The stderr reading thread may not have delivered the callback yet - // after the IOException is thrown. Poll briefly for it to arrive. - var deadline = DateTime.UtcNow + TestConstants.DefaultTimeout; - while (Volatile.Read(ref count) == 0 && DateTime.UtcNow < deadline) - { - await Task.Delay(50, TestContext.Current.CancellationToken); - } + await Assert.ThrowsAnyAsync(() => McpClient.CreateAsync( + transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken)) + .WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken); - Assert.InRange(count, 1, int.MaxValue); - Assert.Contains(id, sb.ToString()); + Assert.Contains(id, await stderr.Task.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken)); } [Fact(Skip = "Platform not supported by this test.", SkipUnless = nameof(IsStdErrCallbackSupported))]