diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 50f2a4ad3..869db93be 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -56,7 +56,7 @@ The browser tests drive the admin in headless Chromium through Playwright: keybo To run the host, `dotnet run --project src/FluentCMS.Host` starts the web host on `http://localhost:5000` with a SQLite database in `fluentcms.db`, and `dotnet run --project src/FluentCMS.Host -- mcp` starts the stdio mode. Configuration comes from environment variables such as `Database__ConnectionString`, `Database__Provider` (`sqlite` or `postgresql`, inferred from the connection string when omitted), `JobQueue__WorkerCount`, `Content__DeletedRetention` (how long a deleted entry can be restored, `30.00:00:00` by default), `Entities__VerifyWithin` (how long a fact stays verified before it is listed as stale, `180.00:00:00` by default), `Delivery__PreviewTokenLifetime` (how long a preview token works unless it is revoked or its change set ends, `7.00:00:00` by default), `Delivery__RateLimit__PermitLimit` and `Delivery__RateLimit__Window` (how many delivery API requests one client address may make per window, 600 a minute by default), `ReverseProxy__KnownProxies__0` and `ReverseProxy__KnownNetworks__0` (the reverse proxies, by address such as `10.0.0.5` or network such as `10.0.0.0/8`, whose `X-Forwarded-For` names the client that rate limits count, read one hop deep; with none, the header is ignored), `Media__Storage` (the storage new uploads go to, `local` by default), `Media__LocalDisk__Root` (where the local storage keeps bytes, `media` under the content root by default) and `Media__MaxBytes` (the largest upload, 50 MB by default; over MCP on HTTP, the server's 30 MB request limit holds an upload to about 22 MB, since base64 grows a file by a third), and from an optional `appsettings.json` next to the binary. -Agents reach the MCP server (ADR-0007) through either mode. The web host serves streamable HTTP at `/mcp` in stateless mode: each request stands alone, no session id is issued, and a GET answers 405 because the server never opens a stream to the client. `fluentcms mcp` serves the same server over standard input and output to an agent that starts it as a local process, for example with `dotnet run --project src/FluentCMS.Host -- mcp` as its command. It reads the same settings as the web host, so pass it the web host's `Database__ConnectionString` and `DataProtection__KeysDirectory` to work on the same database and key ring. Relative paths resolve against the process's working directory, so give an agent absolute ones. Standard output carries only protocol messages, log lines go to standard error, and the process stops when its standard input closes. The stdio mode runs with the local user's database access, so it is meant for development and single-user setups. Clients on protocol revision 2026-07-28 connect with `server/discover`, earlier ones with `initialize`. Agents read content types, the schema document and change sets with the read-only tools, and propose changes with `open_change_set`, `create_content_type`, `revise_content_type` and `submit_change_set`. A write stores an unpublished version and its operation in the agent's change set together, and the version is not published until a person approves and publishes the set; no tool approves, rejects or publishes. Agents follow review with the read-only desk tools: `get_desk_counts`, `list_desk_queue` and `get_change_set_review`, which see only the change sets the agent opened. +Agents reach the MCP server (ADR-0007) through either mode. The web host serves streamable HTTP at `/mcp` in stateless mode: each request stands alone, no session id is issued, and a GET answers 405 because the server never opens a stream to the client. `fluentcms mcp` serves the same server over standard input and output to an agent that starts it as a local process, for example with `dotnet run --project src/FluentCMS.Host -- mcp` as its command. It reads the same settings as the web host, so pass it the web host's `Database__ConnectionString` and `DataProtection__KeysDirectory` to work on the same database and key ring. Relative paths resolve against the process's working directory, so give an agent absolute ones. Standard output carries only protocol messages, log lines go to standard error, and the process stops when its standard input closes. The stdio mode runs with the local user's database access, so it is meant for development and single-user setups. Clients on protocol revision 2026-07-28 connect with `server/discover`, earlier ones with `initialize`. Agents read content types, the schema document and change sets with the read-only tools, and propose changes with `open_change_set`, `create_content_type`, `revise_content_type` and `submit_change_set`. A write stores an unpublished version and its operation in the agent's change set together, and the version is not published until a person approves and publishes the set; no tool approves, rejects or publishes. Agents follow review with the read-only desk tools: `get_desk_counts`, `list_desk_queue` and `get_change_set_review`, which see only the change sets the agent opened. `bulk_update_entries` opens a change set for many entry or entity updates and returns its id and a job id at once; the job applies the updates in the background (see Background jobs), and `get_job` shows its progress and any item that failed. The set can't be submitted while the job is still filling it. Every MCP call runs as an agent and needs that agent's key, issued on `/agents/new`. Over HTTP the key goes in the `Authorization: Bearer ` header of each request; `/mcp` reads no other credential, so a signed-in person's admin cookie is ignored there. Over stdio the key goes in the `FLUENTCMS_AGENT_KEY` environment variable of the process, which an agent's MCP configuration can set; it is never read from a command-line argument, which other users can see in process listings. Both transports check the key on every request or message, so an expired or revoked key is refused from its next call on, with a message that says why. Never put a key in a URL: the server ignores one there, but ASP.NET Core logs request URLs once `Logging__LogLevel__Microsoft.AspNetCore` is below its shipped `Warning`. Every tool call is recorded in the `ToolCalls` table with the agent, the tool, the outcome and how long it took. An agent can add the model it runs on and the task it works on as strings in the call's `_meta`, under `fluentcms/model` and `fluentcms/task`, and the record keeps both. Arguments and results are not recorded; the audit log records every change a tool makes. diff --git a/src/FluentCMS.Core/Agents/AgentPermissions.cs b/src/FluentCMS.Core/Agents/AgentPermissions.cs index 1f1ee952a..eef42ebdf 100644 --- a/src/FluentCMS.Core/Agents/AgentPermissions.cs +++ b/src/FluentCMS.Core/Agents/AgentPermissions.cs @@ -173,6 +173,34 @@ await RecordAsync( + $"{cap} (items per change set). Submit this change set and add the rest to a new one."); } + public async Task CheckNewChangeSetSizeAsync(int items, CancellationToken cancellationToken = default) + { + if (currentInitiator.Current is SystemInitiator system) + { + return SystemRefused(system, "open a change set"); + } + + var agent = await AgentAsync(cancellationToken); + if (!agent.IsSuccess) + { + return Result.Failure(agent.Errors); + } + + if (agent.Value is not { } credential || items <= credential.Caps.ItemsPerChangeSet) + { + return Result.Success(); + } + + await RecordAsync( + new NewAuditEvent("agent.items_per_change_set_exceeded") { SubjectType = "agent_credential", SubjectId = credential.Id }, + cancellationToken); + + return Result.Failure( + AgentPermissionErrorCodes.CapExceeded, + $"{items} items are more than the credential '{credential.DisplayName}' allows in one change set: {credential.Caps.ItemsPerChangeSet} " + + "(items per change set). Split them over several calls of at most that many."); + } + public async Task CheckContentAsync( string contentType, AgentOperation operation, diff --git a/src/FluentCMS.Core/Agents/IAgentPermissions.cs b/src/FluentCMS.Core/Agents/IAgentPermissions.cs index 45f4af8a1..5dc41a070 100644 --- a/src/FluentCMS.Core/Agents/IAgentPermissions.cs +++ b/src/FluentCMS.Core/Agents/IAgentPermissions.cs @@ -24,6 +24,9 @@ public interface IAgentPermissions // or ahead of a write with the number of operations it will add. Task CheckChangeSetSizeAsync(ChangeSet changeSet, int adding = 0, CancellationToken cancellationToken = default); + // Ahead of opening a set for that many items at once, as a bulk write does, before the set exists. + Task CheckNewChangeSetSizeAsync(int items, CancellationToken cancellationToken = default); + // The operation on items of the content type (MCP-05). A write names the fields it changes, which a field list in the scope can // narrow; a read names none, so an agent reads whole entries of a type in its scope. Task CheckContentAsync( diff --git a/src/FluentCMS.Core/Builtin/Jobs/BulkEntryUpdates.cs b/src/FluentCMS.Core/Builtin/Jobs/BulkEntryUpdates.cs new file mode 100644 index 000000000..6321fe103 --- /dev/null +++ b/src/FluentCMS.Core/Builtin/Jobs/BulkEntryUpdates.cs @@ -0,0 +1,151 @@ +using System.Text.Json; +using FluentCMS.Core.ChangeSets; +using FluentCMS.Core.Content; +using FluentCMS.Core.Jobs; +using FluentCMS.Core.Persistence; +using FluentCMS.Core.Results; +using FluentCMS.Core.Schema; +using Microsoft.Extensions.DependencyInjection; + +namespace FluentCMS.Core.Builtin.Jobs; + +// The background half of bulk_update_entries (MCP-08): each item's new version and its operation in the change set commit together, +// one item at a time, as the agent that asked, so its permissions, scopes and caps apply to every item. An item that fails is +// recorded and the rest go on. An item already in the set is skipped, so a retried job repeats nothing. +public sealed class BulkEntryUpdates : IJobHandler +{ + public const string Name = "bulk.entry_updates"; + + private const int MaxFailuresListed = 20; + + // Short enough that the count and every listed item fit in the job's last error, which the queue keeps to 4000 characters. + private const int MaxReasonLength = 100; + + public string Type => Name; + + public static JsonElement Payload(Guid changeSetId, IReadOnlyList items) + { + return JsonSerializer.SerializeToElement(new Request(changeSetId, items), JsonSerializerOptions.Web); + } + + private static Request? Read(JsonElement payload) + { + try + { + return payload.ValueKind == JsonValueKind.Object + && payload.Deserialize(JsonSerializerOptions.Web) is { ChangeSetId: var id, Items: not null } request + && id != Guid.Empty + ? request + : null; + } + catch (JsonException) + { + return null; + } + } + + public async Task RunAsync(JobContext context, CancellationToken cancellationToken) + { + // Another attempt reads the same payload, so one this handler cannot read is a failure, not an exception to retry. + if (Read(context.Job.Payload) is not { } request) + { + return Result.Failure("payload_invalid", "A bulk update names its change set as changeSetId and its items as a list of id, baseVersion and fields."); + } + + var services = context.Services; + var changeSets = services.GetRequiredService(); + + var set = await changeSets.GetAsync(request.ChangeSetId, cancellationToken); + if (!set.IsSuccess) + { + return Result.Failure(set.Errors); + } + + var applied = set.Value.Operations.Select(operation => operation.ItemId).ToHashSet(); + var failures = new List(); + var total = request.Items.Count; + + for (var index = 0; index < total; index++) + { + var item = request.Items[index]; + if (applied.Add(item.Id)) + { + var updated = await ApplyAsync(services, request.ChangeSetId, item, cancellationToken); + if (!updated.IsSuccess && !await AppliedMeanwhileAsync(changeSets, request.ChangeSetId, item.Id, cancellationToken)) + { + // One entry per item, however many problems it has, so the counts and the list are of items. + var reason = string.Join(" ", updated.Errors.Select(error => error.Message)); + failures.Add(new Error(updated.Errors[0].Code, $"items[{index}] {item.Id}: {Shorten(reason)}")); + } + } + + var message = $"Updated {index + 1 - failures.Count} of {total}" + (failures.Count > 0 ? $"; {failures.Count} failed." : "."); + if (!await context.ReportProgressAsync(index + 1, total, message, cancellationToken)) + { + // Another worker has the job now and goes on from what is already in the set. + return Result.Success(); + } + } + + if (failures.Count == 0) + { + return Result.Success(); + } + + // The count first, so it is kept whatever follows. + var counted = $"{failures.Count} of {total} items failed" + (failures.Count > MaxFailuresListed ? $"; the first {MaxFailuresListed} are listed." : "."); + return Result.Failure([new Error("bulk_items_failed", counted), .. failures.Take(MaxFailuresListed)]); + } + + private static string Shorten(string reason) + { + return reason.Length <= MaxReasonLength ? reason : string.Concat(reason.AsSpan(0, MaxReasonLength - 1), "…"); + } + + // A worker that lost its lease can commit an item after this run read the set; the item is in the set, so it has not failed. + private static async Task AppliedMeanwhileAsync(IChangeSetDirectory changeSets, Guid changeSetId, Guid itemId, CancellationToken cancellationToken) + { + var set = await changeSets.GetAsync(changeSetId, cancellationToken); + return set.IsSuccess && set.Value.Operations.Any(operation => operation.ItemId == itemId); + } + + // The update and its operation commit together or not at all; leaving the unit of work uncommitted undoes both. + private static async Task ApplyAsync(IServiceProvider services, Guid changeSetId, BulkEntryUpdate item, CancellationToken cancellationToken) + { + await using var scope = await services.GetRequiredService().BeginAsync(cancellationToken); + + var entry = await services.GetRequiredService().UpdateAsync(item.Id, item.BaseVersion, item.Fields, cancellationToken); + if (!entry.IsSuccess) + { + return Result.Failure(entry.Errors); + } + + var type = await services.GetRequiredService().GetAsync(entry.Value.ContentType, cancellationToken); + if (!type.IsSuccess) + { + return Result.Failure(type.Errors); + } + + var kind = type.Value.Definition.IsEntity ? ItemKind.Entity : ItemKind.Entry; + var operation = entry.Value.PublishedVersion is { } published + ? ChangeOperation.Create(OperationKind.Update, kind, entry.Value.Id, published, entry.Value.Latest.Version) + : ChangeOperation.Create(OperationKind.Create, kind, entry.Value.Id, null, entry.Value.Latest.Version); + if (!operation.IsSuccess) + { + return Result.Failure(operation.Errors); + } + + var added = await services.GetRequiredService().AddOperationAsync(changeSetId, operation.Value, cancellationToken); + if (!added.IsSuccess) + { + return Result.Failure(added.Errors); + } + + await scope.CommitAsync(cancellationToken); + return Result.Success(); + } + + private sealed record Request(Guid ChangeSetId, IReadOnlyList Items); +} + +public sealed record BulkEntryUpdate(Guid Id, int BaseVersion, JsonElement Fields); diff --git a/src/FluentCMS.Core/Builtin/Tools/BulkTools.cs b/src/FluentCMS.Core/Builtin/Tools/BulkTools.cs new file mode 100644 index 000000000..4efc63a5f --- /dev/null +++ b/src/FluentCMS.Core/Builtin/Tools/BulkTools.cs @@ -0,0 +1,166 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using FluentCMS.Core.Agents; +using FluentCMS.Core.Builtin.Jobs; +using FluentCMS.Core.ChangeSets; +using FluentCMS.Core.Identity; +using FluentCMS.Core.Jobs; +using FluentCMS.Core.Persistence; +using FluentCMS.Core.Results; +using FluentCMS.Core.Sites; +using FluentCMS.Core.Tools; +using Microsoft.Extensions.DependencyInjection; + +namespace FluentCMS.Core.Builtin.Tools; + +// Bulk writes (MCP-08): the call opens a change set and enqueues the work in one unit of work, and returns both ids at once; the +// job applies the items in the background, and get_job shows how far it got. +public sealed class BulkTools : IToolProvider +{ + public const int MaxItems = 1000; + + private const string ItemsForm = "an array of {\"id\", \"base_version\", \"fields\"} objects, one per entry or entity"; + + public IReadOnlyList Tools { get; } = + [ + new AgentTool( + "bulk_update_entries", + "Propose updates of many entries at once", + "Proposes new fields for many entries or entities in one call: it opens a change set for them and returns its id and a " + + "job id at once, and the updates are applied in the background, each as update_entry would. Follow the job with " + + "get_job; an item that fails, such as one updated since you read it, is listed there and the rest go on. Once the " + + "job has succeeded, submit the set with submit_change_set. The list may not be longer than your items-per-change-set cap.", + ToolSchemas.Object( + ToolSchemas.Json( + "items", + $"The updates, at most {MaxItems}: each names an entry or entity, the latest version you read and its whole new fields.", + new JsonObject + { + ["type"] = "array", + ["minItems"] = 1, + ["maxItems"] = MaxItems, + ["items"] = new JsonObject + { + ["type"] = "object", + ["properties"] = new JsonObject + { + ["id"] = new JsonObject { ["type"] = "string", ["format"] = "uuid" }, + ["base_version"] = new JsonObject { ["type"] = "integer", ["minimum"] = 1 }, + ["fields"] = new JsonObject { ["type"] = "object" }, + }, + ["required"] = new JsonArray("id", "base_version", "fields"), + ["additionalProperties"] = false, + }, + }, + JsonNode.Parse("""[{"id": "0192f3a1-7c2e-7a4b-9d3e-1f2a3b4c5d6e", "base_version": 2, "fields": {"title": "Widget", "price": 19.99}}]""")!), + ToolSchemas.String("rationale", "Why the updates are made, for the person who reviews them.", "Sync prices from the catalog export."), + ToolSchemas.Strings("sources", "Where the updates come from, such as URLs or file names.", "exports/catalog-2026-10-19.csv", required: false)), + readOnly: false, + UpdateAsync), + ]; + + private static async Task UpdateAsync(ToolInvocation call, CancellationToken cancellationToken) + { + var arguments = new ToolArgumentsReader(call.Arguments); + var list = arguments.RequiredArray("items", ItemsForm); + var rationale = arguments.RequiredString("rationale", "Sync prices from the catalog export."); + var sources = arguments.OptionalStrings("sources", "exports/catalog-2026-10-19.csv") ?? []; + var errors = new List(arguments.Errors()); + var items = list is { } elements ? ReadItems(elements, errors) : []; + if (errors.Count > 0) + { + return ToolResult.Failure(errors); + } + + var services = call.Services; + var initiator = services.GetRequiredService().Current + ?? throw new InvalidOperationException("No initiator is known for this call; a change set records who opened it (CHG-01)."); + + // Refused ahead of any write, so a refused cap keeps its security event (SEC-08) and no set is opened for nothing. + var planned = ChangeSet.Open(Site.Default.Id, rationale!, sources, initiator); + if (!planned.IsSuccess) + { + return ToolResult.Failure(planned.Errors); + } + + var permissions = services.GetRequiredService(); + foreach (var check in new Func>[] + { + () => permissions.CheckNewChangeSetAsync(cancellationToken), + () => permissions.CheckNewChangeSetSizeAsync(items.Count, cancellationToken), + }) + { + var allowed = await check(); + if (!allowed.IsSuccess) + { + return ToolResult.Failure(allowed.Errors); + } + } + + // The set and the job commit together, so there is never a job without its set or a set nobody is working on. + await using var scope = await services.GetRequiredService().BeginAsync(cancellationToken); + + var set = await services.GetRequiredService().OpenAsync(new NewChangeSet(rationale!, sources), cancellationToken); + if (!set.IsSuccess) + { + return ToolResult.Failure(set.Errors); + } + + var job = await services.GetRequiredService().EnqueueAsync( + new NewJob(BulkEntryUpdates.Name, BulkEntryUpdates.Payload(set.Value.Id, items)) { Subject = new JobSubject("change_set", set.Value.Id) }, + cancellationToken); + + await scope.CommitAsync(cancellationToken); + + return ToolResult.Success(new JsonObject + { + ["changeSet"] = ToolJson.ChangeSet(set.Value), + ["job"] = ToolJson.JobRecord(job), + ["next"] = $"The {items.Count} updates are applied in the background. Call get_job with id {job.Id} until it has succeeded, " + + $"then submit_change_set with id {set.Value.Id}.", + }); + } + + // Every malformed item is reported at once by its index, before anything is opened. + private static List ReadItems(JsonElement list, List errors) + { + var items = new List(); + var seen = new HashSet(); + var index = 0; + + foreach (var element in list.EnumerateArray()) + { + var reader = new ToolArgumentsReader(element); + var id = reader.RequiredGuid("id"); + var baseVersion = reader.RequiredPositiveInteger("base_version"); + var fields = reader.RequiredObject("fields", "an object with the entry's fields"); + var itemErrors = reader.Errors(); + + errors.AddRange(itemErrors.Select(error => new Error(error.Code, $"items[{index}]: {error.Message}"))); + if (itemErrors.Count == 0) + { + if (seen.Add(id!.Value)) + { + items.Add(new BulkEntryUpdate(id.Value, baseVersion!.Value, fields!.Value.Clone())); + } + else + { + errors.Add(new Error(ToolErrorCodes.ArgumentInvalid, $"items[{index}]: {id} is already in the list; give each entry once.")); + } + } + + index++; + } + + if (index == 0) + { + errors.Add(new Error(ToolErrorCodes.ArgumentInvalid, "'items' is empty: give at least one update.")); + } + else if (index > MaxItems) + { + errors.Add(new Error(ToolErrorCodes.ArgumentInvalid, $"'items' has {index} updates and a call takes at most {MaxItems}; split them over several calls.")); + } + + return items; + } +} diff --git a/src/FluentCMS.Core/Builtin/Tools/JobTools.cs b/src/FluentCMS.Core/Builtin/Tools/JobTools.cs new file mode 100644 index 000000000..713313d69 --- /dev/null +++ b/src/FluentCMS.Core/Builtin/Tools/JobTools.cs @@ -0,0 +1,45 @@ +using FluentCMS.Core.Identity; +using FluentCMS.Core.Jobs; +using FluentCMS.Core.Results; +using FluentCMS.Core.Tools; +using Microsoft.Extensions.DependencyInjection; + +namespace FluentCMS.Core.Builtin.Tools; + +// How far background work an agent started has got (MCP-08). An agent reads its own jobs only. +public sealed class JobTools : IToolProvider +{ + public IReadOnlyList Tools { get; } = + [ + new AgentTool( + "get_job", + "Get a job's progress", + "Returns one of your background jobs, such as the one bulk_update_entries started: its state (queued, running, " + + "succeeded, failed or dead), its progress as done of total with a message, its attempts and its errors. A failed job " + + "lists what failed; a dead one stopped on an error another attempt did not fix. Call it again until the state is " + + "succeeded, failed or dead.", + ToolSchemas.Object(ToolSchemas.Uuid("id", "The job's id, as the call that started it returned it.")), + readOnly: true, + GetAsync), + ]; + + private static async Task GetAsync(ToolInvocation call, CancellationToken cancellationToken) + { + var arguments = new ToolArgumentsReader(call.Arguments); + var id = arguments.RequiredGuid("id"); + if (arguments.Errors() is { Count: > 0 } errors) + { + return ToolResult.Failure(errors); + } + + var job = await call.Services.GetRequiredService().GetAsync(id!.Value, cancellationToken); + var caller = call.Services.GetRequiredService().Current; + + // Another caller's job reads as missing, so its existence says nothing about someone else's work. + return job.IsSuccess && job.Value.Initiator == caller + ? ToolResult.Success(ToolJson.JobRecord(job.Value)) + : ToolResult.Failure( + [new Error(JobErrorCodes.JobNotFound, $"You have no job {id}.")], + "A job's id comes back from the call that started it, such as bulk_update_entries."); + } +} diff --git a/src/FluentCMS.Core/Builtin/Tools/ToolJson.cs b/src/FluentCMS.Core/Builtin/Tools/ToolJson.cs index 2d8fd65b3..b2d0b414b 100644 --- a/src/FluentCMS.Core/Builtin/Tools/ToolJson.cs +++ b/src/FluentCMS.Core/Builtin/Tools/ToolJson.cs @@ -221,6 +221,24 @@ public static JsonObject Usage(EntryUsage usage) }; } + // The whole job, for get_job and bulk updates: what it works on, its progress, attempts and last error. + public static JsonObject JobRecord(Job job) + { + return new JsonObject + { + ["id"] = job.Id.ToString(), + ["type"] = job.Type, + ["state"] = Name(job.State), + ["subject"] = job.Subject is { } subject ? new JsonObject { ["type"] = subject.Type, ["id"] = subject.Id.ToString() } : null, + ["progress"] = new JsonObject { ["done"] = job.Progress.Done, ["total"] = job.Progress.Total, ["message"] = job.Progress.Message }, + ["attempts"] = job.Attempts, + ["maxAttempts"] = job.MaxAttempts, + ["lastError"] = job.LastError, + ["createdAt"] = Time(job.CreatedAt), + ["finishedAt"] = Time(job.FinishedAt), + }; + } + public static JsonObject Facts(EntityFactSheet sheet) { return new JsonObject diff --git a/src/FluentCMS.Core/ChangeSets/ChangeSetErrorCodes.cs b/src/FluentCMS.Core/ChangeSets/ChangeSetErrorCodes.cs index ef6803f72..5205fa362 100644 --- a/src/FluentCMS.Core/ChangeSets/ChangeSetErrorCodes.cs +++ b/src/FluentCMS.Core/ChangeSets/ChangeSetErrorCodes.cs @@ -13,6 +13,7 @@ public static class ChangeSetErrorCodes public const string SourceBlank = "source_blank"; public const string SessionIdInvalid = "session_id_invalid"; public const string NotOpen = "change_set_not_open"; + public const string Busy = "change_set_busy"; public const string OperationsMissing = "operations_missing"; public const string TransitionNotAllowed = "transition_not_allowed"; public const string RejectionReasonMissing = "rejection_reason_missing"; diff --git a/src/FluentCMS.Core/Checks/ChangeSetChecks.cs b/src/FluentCMS.Core/Checks/ChangeSetChecks.cs index 6fcbf0694..aa7d6070b 100644 --- a/src/FluentCMS.Core/Checks/ChangeSetChecks.cs +++ b/src/FluentCMS.Core/Checks/ChangeSetChecks.cs @@ -69,6 +69,18 @@ public async Task> SubmitAsync(Guid changeSetId, Can : Result.Failure(abandoned.IsSuccess ? recovered.Errors : abandoned.Errors); } + // A job still adding items to the set, such as a bulk update, ends first, so a reviewer never gets part of its work. The job + // is enqueued with the set, so an open set cannot be read before it. + if (read.Value.State == ChangeSetState.Open + && MaySubmit(read.Value) + && (await jobs.ListForSubjectAsync(new JobSubject(SubjectType, changeSetId), cancellationToken)) + .FirstOrDefault(job => job.Type != JobType && job.State is JobState.Queued or JobState.Running) is { } busy) + { + return Result.Failure( + ChangeSetErrorCodes.Busy, + $"Change set {changeSetId} is still being filled by job {busy.Id} ({busy.Type}); submit it once the job has ended."); + } + var inline = read.Value.Operations.Count <= InlineItems && !await AnyJudgedAsync(cancellationToken); // Opened before the set moves, so a host that cannot run the engine says so instead of leaving every set to its job. diff --git a/src/FluentCMS.Core/Tools/ToolArgumentsReader.cs b/src/FluentCMS.Core/Tools/ToolArgumentsReader.cs index 019eabea9..2974dd6cf 100644 --- a/src/FluentCMS.Core/Tools/ToolArgumentsReader.cs +++ b/src/FluentCMS.Core/Tools/ToolArgumentsReader.cs @@ -49,6 +49,23 @@ public ToolArgumentsReader(JsonElement arguments) return null; } + public JsonElement? RequiredArray(string name, string form) + { + if (Read(name) is not { } value) + { + Missing(name, form); + return null; + } + + if (value.ValueKind == JsonValueKind.Array) + { + return value; + } + + Invalid(name, $"must be a JSON array: {form}"); + return null; + } + public JsonElement? RequiredObject(string name, string form) { if (Read(name) is not { } value) diff --git a/tests/FluentCMS.IntegrationTests/Mcp/BulkToolsTests.cs b/tests/FluentCMS.IntegrationTests/Mcp/BulkToolsTests.cs new file mode 100644 index 000000000..d3a3a3a4b --- /dev/null +++ b/tests/FluentCMS.IntegrationTests/Mcp/BulkToolsTests.cs @@ -0,0 +1,333 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using FluentCMS.Core.Agents; +using FluentCMS.Core.Builtin.Jobs; +using FluentCMS.Core.ChangeSets; +using FluentCMS.Core.Content; +using FluentCMS.Core.Jobs; +using FluentCMS.Core.Results; +using FluentCMS.Core.Schema; +using FluentCMS.Core.Tools; +using FluentCMS.Infrastructure.Persistence; +using Microsoft.AspNetCore.Builder; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace FluentCMS.IntegrationTests.Mcp; + +public sealed class BulkToolsTests +{ + private static readonly Dictionary FastQueue = new() + { + ["JobQueue:PollingInterval"] = "00:00:00.050", + ["JobQueue:RetryDelay"] = "00:00:00.050", + }; + + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task A_bulk_update_of_180_entities_returns_the_change_set_at_once_and_reports_progress_until_it_is_ready(string provider) + { + await using var agent = await ConnectedAgent.StartAsync(provider, settings: FastQueue); + var products = await CreateProductsAsync(agent, 180); + + var started = await agent.CallAsync("bulk_update_entries", new() + { + ["items"] = products.Select(id => Item(id, 1, 29.99m)).ToArray(), + ["rationale"] = "Sync prices from the catalog export.", + ["sources"] = new[] { "exports/catalog-2026-10-19.csv" }, + }); + var changeSetId = (string)started["changeSet"]!["id"]!; + var jobId = (string)started["job"]!["id"]!; + var seen = new List(); + var finished = await FinishedAsync(agent, jobId, seen); + + Assert.Equal("open", (string?)started["changeSet"]!["state"]); + Assert.Equal(("queued", 0), ((string?)started["job"]!["state"], (int)started["job"]!["progress"]!["done"]!)); + Assert.Equal(changeSetId, (string?)started["job"]!["subject"]!["id"]); + Assert.Equal(seen.Order(), seen); + Assert.Equal("succeeded", (string?)finished["state"]); + Assert.Equal((180, 180), ((int)finished["progress"]!["done"]!, (int)finished["progress"]!["total"]!)); + Assert.Equal("Updated 180 of 180.", (string?)finished["progress"]!["message"]); + + var set = await agent.AsPersonAsync(directory => directory.GetAsync(Guid.Parse(changeSetId))); + Assert.Equal(180, set.Operations.Count); + Assert.All(set.Operations, operation => Assert.Equal((ItemKind.Entity, 2), (operation.ItemKind, operation.ProposedVersion!.Value))); + Assert.Equal(agent.Credential.Initiator, set.Initiator); + + var submitted = await agent.CallAsync("submit_change_set", new() { ["id"] = changeSetId }); + Assert.Equal("checks_running", (string?)submitted["changeSet"]!["state"]); + } + + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task An_item_that_fails_is_listed_by_its_id_and_the_rest_go_into_the_set(string provider) + { + await using var agent = await ConnectedAgent.StartAsync(provider, settings: FastQueue); + var products = await CreateProductsAsync(agent, 4); + // Plain values where the entity's fields take facts: one item with a problem in every field. + var withoutFacts = new Dictionary { ["id"] = products[2].ToString(), ["base_version"] = 1, ["fields"] = new { name = "Widget", price = 3 } }; + + var started = await agent.CallAsync("bulk_update_entries", new() + { + ["items"] = new[] { Item(products[0], 1, 1m), Item(products[1], 5, 2m), withoutFacts, Item(products[3], 1, 4m) }, + ["rationale"] = "Sync prices.", + }); + var finished = await FinishedAsync(agent, (string)started["job"]!["id"]!); + + Assert.Equal("failed", (string?)finished["state"]); + Assert.Equal(1, (int)finished["attempts"]!); + Assert.StartsWith("bulk_items_failed: 2 of 4 items failed. ", (string?)finished["lastError"], StringComparison.Ordinal); + Assert.Contains($"items[1] {products[1]}", (string?)finished["lastError"]); + Assert.Contains($"items[2] {products[2]}", (string?)finished["lastError"]); + Assert.Equal("Updated 2 of 4; 2 failed.", (string?)finished["progress"]!["message"]); + var set = await agent.AsPersonAsync(directory => directory.GetAsync(Guid.Parse((string)started["changeSet"]!["id"]!))); + Assert.Equal([products[0], products[3]], set.Operations.Select(operation => operation.ItemId)); + } + + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task Many_failed_items_keep_their_count_and_the_first_twenty_in_the_jobs_last_error(string provider) + { + await using var agent = await ConnectedAgent.StartAsync(provider, settings: FastQueue); + var products = await CreateProductsAsync(agent, 30); + + var started = await agent.CallAsync("bulk_update_entries", new() + { + ["items"] = products.Select(id => new Dictionary { ["id"] = id.ToString(), ["base_version"] = 1, ["fields"] = new { name = "Widget", price = 3 } }).ToArray(), + ["rationale"] = "Sync prices.", + }); + var finished = await FinishedAsync(agent, (string)started["job"]!["id"]!); + + var lastError = (string)finished["lastError"]!; + Assert.StartsWith("bulk_items_failed: 30 of 30 items failed; the first 20 are listed. ", lastError, StringComparison.Ordinal); + Assert.Contains($"items[19] {products[19]}", lastError); + Assert.DoesNotContain("items[20]", lastError); + Assert.Equal("Updated 0 of 30; 30 failed.", (string?)finished["progress"]!["message"]); + } + + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task A_list_past_the_cap_or_with_a_malformed_item_is_refused_before_a_set_is_opened(string provider) + { + await using var agent = await ConnectedAgent.StartAsync(provider, itemsPerChangeSet: 2, settings: FastQueue); + var products = await CreateProductsAsync(agent, 3); + + var overCap = await agent.FailAsync("bulk_update_entries", new() + { + ["items"] = products.Select(id => Item(id, 1, 1m)).ToArray(), + ["rationale"] = "Too many.", + }); + var malformed = await agent.FailAsync("bulk_update_entries", new() + { + ["items"] = new object[] { Item(products[0], 1, 1m), new Dictionary { ["id"] = "42", ["fields"] = new { } }, Item(products[0], 1, 1m) }, + ["rationale"] = "Malformed.", + }); + + Assert.Equal(AgentPermissionErrorCodes.CapExceeded, (string?)overCap["errors"]![0]!["code"]); + Assert.StartsWith("3 items are more than", (string?)overCap["errors"]![0]!["message"], StringComparison.Ordinal); + var refusal = Assert.Single(await agent.QueryAsync(context => context.AuditEvents + .AsNoTracking() + .Where(row => row.Operation == "agent.items_per_change_set_exceeded") + .ToListAsync())); + Assert.Equal(("agent_credential", agent.Credential.Id), (refusal.SubjectType, refusal.SubjectId)); + Assert.Equal( + [(ToolErrorCodes.ArgumentInvalid, "items[1]"), (ToolErrorCodes.ArgumentMissing, "items[1]"), (ToolErrorCodes.ArgumentInvalid, "items[2]")], + malformed["errors"]!.AsArray().Select(error => ((string)error!["code"]!, ((string)error["message"]!).Split(':')[0]))); + // The only set is the one the test's content model was written in. + Assert.Empty(await agent.QueryAsync(context => context.ChangeSets + .AsNoTracking() + .Where(row => row.SessionId != TestChangeSetTargets.ContentModelSession) + .ToListAsync())); + Assert.Empty(await agent.QueryAsync(context => context.Jobs.AsNoTracking().Where(row => row.Type == BulkEntryUpdates.Name).ToListAsync())); + } + + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task A_job_run_again_after_stopping_partway_adds_each_item_once(string provider) + { + await using var agent = await ConnectedAgent.StartAsync(provider); + var products = await CreateProductsAsync(agent, 3); + var set = await agent.AsAgentAsync(async services => (await services.GetRequiredService().OpenAsync(new NewChangeSet("Sync prices.", []))).Value); + var payload = BulkEntryUpdates.Payload(set.Id, [.. products.Select(id => new BulkEntryUpdate(id, 1, Fields(5m)))]); + var job = new Job + { + Id = Guid.NewGuid(), + Type = BulkEntryUpdates.Name, + Payload = payload, + State = JobState.Running, + Attempts = 1, + MaxAttempts = 3, + CreatedAt = DateTimeOffset.UtcNow, + ScheduledAt = DateTimeOffset.UtcNow, + }; + + // The first run stops after the first item, as a worker that lost its lease would; the second runs the whole job. + var first = await agent.AsAgentAsync(services => + new BulkEntryUpdates().RunAsync(new JobContext(job, services, (progress, _) => Task.FromResult(progress.Done < 1)), CancellationToken.None)); + var second = await agent.AsAgentAsync(services => + new BulkEntryUpdates().RunAsync(new JobContext(job, services, (_, _) => Task.FromResult(true)), CancellationToken.None)); + + Assert.True(first.IsSuccess); + Assert.True(second.IsSuccess); + var stored = await agent.AsPersonAsync(directory => directory.GetAsync(set.Id)); + Assert.Equal(products, stored.Operations.Select(operation => operation.ItemId)); + foreach (var id in products) + { + Assert.Equal(2, (await agent.AsPersonAsync(entries => entries.GetAsync(id))).Latest.Version); + } + } + + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task A_job_that_fails_partway_is_retried_by_the_worker_and_adds_each_item_once(string provider) + { + var failed = 0; + var handler = new SteppedBulkUpdates((context, run) => Interlocked.Exchange(ref failed, 1) == 0 + ? run(new JobContext(context.Job, context.Services, (progress, token) => progress.Done == 2 + ? throw new InvalidOperationException("The host stopped after the second item.") + : context.ReportProgressAsync(progress.Done, progress.Total, progress.Message, token))) + : run(context)); + await using var agent = await ConnectedAgent.StartAsync(provider, settings: FastQueue, configure: handler.Register); + var products = await CreateProductsAsync(agent, 3); + + var started = await agent.CallAsync("bulk_update_entries", new() + { + ["items"] = products.Select(id => Item(id, 1, 5m)).ToArray(), + ["rationale"] = "Sync prices.", + }); + var finished = await FinishedAsync(agent, (string)started["job"]!["id"]!); + + Assert.Equal(("succeeded", 2), ((string?)finished["state"], (int)finished["attempts"]!)); + Assert.Equal("Updated 3 of 3.", (string?)finished["progress"]!["message"]); + var set = await agent.AsPersonAsync(directory => directory.GetAsync(Guid.Parse((string)started["changeSet"]!["id"]!))); + Assert.Equal(products, set.Operations.Select(operation => operation.ItemId)); + foreach (var id in products) + { + Assert.Equal(2, (await agent.AsPersonAsync(entries => entries.GetAsync(id))).Latest.Version); + } + } + + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task A_set_is_not_submitted_while_its_bulk_job_is_still_filling_it(string provider) + { + var release = new TaskCompletionSource(); + var handler = new SteppedBulkUpdates(async (context, run) => + { + await release.Task; + return await run(context); + }); + await using var agent = await ConnectedAgent.StartAsync(provider, settings: FastQueue, configure: handler.Register); + var products = await CreateProductsAsync(agent, 2); + var started = await agent.CallAsync("bulk_update_entries", new() + { + ["items"] = products.Select(id => Item(id, 1, 5m)).ToArray(), + ["rationale"] = "Sync prices.", + }); + var changeSetId = (string)started["changeSet"]!["id"]!; + + var early = await agent.FailAsync("submit_change_set", new() { ["id"] = changeSetId }); + release.SetResult(); + var finished = await FinishedAsync(agent, (string)started["job"]!["id"]!); + var submitted = await agent.CallAsync("submit_change_set", new() { ["id"] = changeSetId }); + + Assert.Equal(ChangeSetErrorCodes.Busy, (string?)early["errors"]![0]!["code"]); + Assert.Equal("succeeded", (string?)finished["state"]); + Assert.NotEqual("open", (string?)submitted["changeSet"]!["state"]); + var set = await agent.AsPersonAsync(directory => directory.GetAsync(Guid.Parse(changeSetId))); + Assert.Equal(products, set.Operations.Select(operation => operation.ItemId)); + } + + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task Get_job_shows_an_agent_its_own_jobs_only(string provider) + { + await using var agent = await ConnectedAgent.StartAsync(provider, settings: FastQueue); + await using var another = await agent.AnotherAgentAsync("another-agent"); + var products = await CreateProductsAsync(agent, 2); + var started = await agent.CallAsync("bulk_update_entries", new() { ["items"] = new[] { Item(products[0], 1, 1m) }, ["rationale"] = "One price." }); + var anothers = await another.CallAsync("bulk_update_entries", new() { ["items"] = new[] { Item(products[1], 1, 2m) }, ["rationale"] = "Another price." }); + + var own = await agent.CallAsync("get_job", new() { ["id"] = (string)started["job"]!["id"]! }); + var other = await agent.FailAsync("get_job", new() { ["id"] = (string)anothers["job"]!["id"]! }); + + Assert.Equal(BulkEntryUpdates.Name, (string?)own["type"]); + Assert.Equal(JobErrorCodes.JobNotFound, (string?)other["errors"]![0]!["code"]); + } + + private static async Task> CreateProductsAsync(ConnectedAgent agent, int count) + { + await agent.AsPersonAsync(directory => directory.CreateAsync(new NewContentType( + "Product", + "product", + new ContentTypeDefinition([new FieldDefinition("name", "text"), new FieldDefinition("price", "number")], isEntity: true)), + TestChangeSetTargets.ContentModel)); + + var ids = new List(); + for (var index = 0; index < count; index++) + { + var entry = await agent.AsPersonAsync(entries => entries.CreateAsync(new NewEntry("product", Fields(10m + index)))); + ids.Add(entry.Id); + } + + return ids; + } + + private static Dictionary Item(Guid id, int baseVersion, decimal price) + { + return new() { ["id"] = id.ToString(), ["base_version"] = baseVersion, ["fields"] = Fields(price) }; + } + + private static JsonElement Fields(decimal price) + { + return JsonDocument.Parse($$"""{"name": {{Fact("\"Widget\"")}}, "price": {{Fact(price.ToString(System.Globalization.CultureInfo.InvariantCulture))}}}""").RootElement.Clone(); + } + + private static string Fact(string value) + { + return $$"""{"value": {{value}}, "source": null, "owner": null, "validFrom": null, "validUntil": null, "lastVerified": null}"""; + } + + // Each read's progress goes into seen, when given. + private static async Task FinishedAsync(ConnectedAgent agent, string jobId, List? seen = null) + { + // The job takes a few seconds for 180 items on its own; a loaded test machine can take far longer. + var deadline = DateTime.UtcNow.AddMinutes(3); + while (true) + { + var job = await agent.CallAsync("get_job", new() { ["id"] = jobId }); + seen?.Add((int)job["progress"]!["done"]!); + if ((string?)job["state"] is "succeeded" or "failed" or "dead" || DateTime.UtcNow > deadline) + { + return job; + } + + await Task.Delay(100); + } + } + + // The product's bulk handler with a test's step around each attempt, run by the real worker. It is registered before the + // product's handlers, and the worker takes the first handler of a job's type. + private sealed class SteppedBulkUpdates(Func>, Task> attempt) : IJobHandler + { + public string Type => BulkEntryUpdates.Name; + + public Task RunAsync(JobContext context, CancellationToken cancellationToken) + { + return attempt(context, inner => new BulkEntryUpdates().RunAsync(inner, cancellationToken)); + } + + public void Register(WebApplicationBuilder builder) + { + builder.Services.AddSingleton(this); + } + } +} diff --git a/tests/FluentCMS.IntegrationTests/Mcp/McpAgents.cs b/tests/FluentCMS.IntegrationTests/Mcp/McpAgents.cs index 2f5222638..a45daa184 100644 --- a/tests/FluentCMS.IntegrationTests/Mcp/McpAgents.cs +++ b/tests/FluentCMS.IntegrationTests/Mcp/McpAgents.cs @@ -21,12 +21,12 @@ internal static class McpAgents // The built-in tools every server lists, sorted by name. public static readonly string[] BuiltInTools = [ - "attach_media", "check_draft", "create_content_type", "create_entry", "delete_entry", "get_blast_radius", "get_change_set", - "get_change_set_checks", "get_change_set_diff", "get_change_set_review", "get_content_type", "get_desk_counts", - "get_entity_facts", "get_entity_relations", "get_entry", "get_entry_history", "get_media", "get_resolved_entry", - "get_schema_document", "get_where_used", "list_content_types", "list_desk_queue", "list_entries", "list_media", - "list_stale_facts", "list_unsourced_facts", "open_change_set", "relate_entities", "reopen_change_set", "revise_content_type", - "search_entries", "set_entity_fact", "submit_change_set", "update_entry", "update_media", "upload_media", + "attach_media", "bulk_update_entries", "check_draft", "create_content_type", "create_entry", "delete_entry", "get_blast_radius", + "get_change_set", "get_change_set_checks", "get_change_set_diff", "get_change_set_review", "get_content_type", + "get_desk_counts", "get_entity_facts", "get_entity_relations", "get_entry", "get_entry_history", "get_job", "get_media", + "get_resolved_entry", "get_schema_document", "get_where_used", "list_content_types", "list_desk_queue", "list_entries", + "list_media", "list_stale_facts", "list_unsourced_facts", "open_change_set", "relate_entities", "reopen_change_set", + "revise_content_type", "search_entries", "set_entity_fact", "submit_change_set", "update_entry", "update_media", "upload_media", ]; public static async Task ToolNamesAsync(McpClient client) diff --git a/tests/FluentCMS.Tests/Jobs/BulkEntryUpdatesTests.cs b/tests/FluentCMS.Tests/Jobs/BulkEntryUpdatesTests.cs new file mode 100644 index 000000000..f122c774b --- /dev/null +++ b/tests/FluentCMS.Tests/Jobs/BulkEntryUpdatesTests.cs @@ -0,0 +1,35 @@ +using System.Text.Json; +using FluentCMS.Core.Builtin.Jobs; +using FluentCMS.Core.Jobs; +using Microsoft.Extensions.DependencyInjection; + +namespace FluentCMS.Tests.Jobs; + +public sealed class BulkEntryUpdatesTests +{ + // Another attempt reads the same payload, so the job fails at once instead of throwing to be retried. + [Theory] + [InlineData("{}")] + [InlineData("[]")] + [InlineData("{\"changeSetId\": \"not-an-id\", \"items\": []}")] + [InlineData("{\"changeSetId\": \"0192f3a1-7c2e-7a4b-9d3e-1f2a3b4c5d6e\"}")] + public async Task A_payload_it_cannot_read_is_a_failure(string payload) + { + var job = new Job + { + Id = Guid.CreateVersion7(), + Type = BulkEntryUpdates.Name, + Payload = JsonDocument.Parse(payload).RootElement.Clone(), + State = JobState.Running, + Attempts = 1, + MaxAttempts = NewJob.DefaultMaxAttempts, + CreatedAt = DateTimeOffset.UtcNow, + ScheduledAt = DateTimeOffset.UtcNow, + }; + await using var services = new ServiceCollection().BuildServiceProvider(); + + var result = await new BulkEntryUpdates().RunAsync(new JobContext(job, services, (_, _) => Task.FromResult(true)), CancellationToken.None); + + Assert.Equal("payload_invalid", Assert.Single(result.Errors).Code); + } +} diff --git a/tests/FluentCMS.Tests/Tools/McpCoverageTests.cs b/tests/FluentCMS.Tests/Tools/McpCoverageTests.cs index 23425d0ae..e5c3cfc9d 100644 --- a/tests/FluentCMS.Tests/Tools/McpCoverageTests.cs +++ b/tests/FluentCMS.Tests/Tools/McpCoverageTests.cs @@ -40,7 +40,8 @@ public sealed class McpCoverageTests new("MED", "Read and list media", ["get_media", "list_media"]), new("MED", "Attach media to a field", ["attach_media"]), new("Search", "Search entries and entities by their words", ["search_entries"]), - Missing("MCP-08", "Bulk operations that return a change set id and report progress", "#2423"), + new("MCP-08", "Bulk updates of entries and entities that return a change set id at once", ["bulk_update_entries"]), + new("MCP-08", "Read the progress of background work", ["get_job"]), ]; // A person approves, rejects, publishes and reverts; no tool may (MCP-06, SEC-02). Tool names start with their verb. diff --git a/tests/FluentCMS.Tests/Tools/ToolProviderConformanceTests.cs b/tests/FluentCMS.Tests/Tools/ToolProviderConformanceTests.cs index d91370030..bd395c5ea 100644 --- a/tests/FluentCMS.Tests/Tools/ToolProviderConformanceTests.cs +++ b/tests/FluentCMS.Tests/Tools/ToolProviderConformanceTests.cs @@ -120,6 +120,16 @@ public sealed class CheckToolsConformanceTests : ToolProviderConformanceTests protected override object Provider { get; } = new CheckTools(); } +public sealed class BulkToolsConformanceTests : ToolProviderConformanceTests +{ + protected override object Provider { get; } = new BulkTools(); +} + +public sealed class JobToolsConformanceTests : ToolProviderConformanceTests +{ + protected override object Provider { get; } = new JobTools(); +} + public sealed class EntityToolsConformanceTests : ToolProviderConformanceTests { protected override object Provider { get; } = new EntityTools();