diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6e6cb5805..e57130cda 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), `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), 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 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. 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.Admin/Components/Pages/ChangeSetView.razor b/src/FluentCMS.Admin/Components/Pages/ChangeSetView.razor index 25eb4a754..d50826d0d 100644 --- a/src/FluentCMS.Admin/Components/Pages/ChangeSetView.razor +++ b/src/FluentCMS.Admin/Components/Pages/ChangeSetView.razor @@ -241,20 +241,6 @@ @code { private const int HistoryLimit = 200; - // The events the directory appends when it opens a set and on each move; refusals and other events about the set are history too. - private static readonly HashSet Moves = - [ - "change_set.opened", - "change_set.reopened", - "change_set.submitted", - "change_set.checks_failed", - "change_set.awaiting_review", - "change_set.approved", - "change_set.published", - "change_set.rejected", - "change_set.reverted", - ]; - private ChangeSet? _set; private List _history = []; private bool _historyTruncated; @@ -371,9 +357,9 @@ { await foreach (var auditEvent in history.Value) { - if (Moves.Contains(auditEvent.Operation)) + if (ChangeSetMoves.Operations.Contains(auditEvent.Operation)) { - opened ??= auditEvent.Operation == "change_set.opened" ? auditEvent : null; + opened ??= auditEvent.Operation == ChangeSetMoves.Opened ? auditEvent : null; lastMove = auditEvent; } diff --git a/src/FluentCMS.Core/Builtin/Tools/DeskTools.cs b/src/FluentCMS.Core/Builtin/Tools/DeskTools.cs new file mode 100644 index 000000000..0b8c3f9cc --- /dev/null +++ b/src/FluentCMS.Core/Builtin/Tools/DeskTools.cs @@ -0,0 +1,208 @@ +using System.Text.Json.Nodes; +using FluentCMS.Core.Audit; +using FluentCMS.Core.ChangeSets; +using FluentCMS.Core.Desk; +using FluentCMS.Core.Identity; +using FluentCMS.Core.Results; +using FluentCMS.Core.Tools; +using Microsoft.Extensions.DependencyInjection; + +namespace FluentCMS.Core.Builtin.Tools; + +// The review desk for agents, read only (MCP-01, DSK): what waits in each queue and where a change set stands in review. Review +// decisions are a person's, so no tool makes one (MCP-06). +public sealed class DeskTools : IToolProvider +{ + private const int DefaultLimit = 50; + + // Awaiting me is a person's queue, so an agent reads the sets awaiting review as awaiting anyone. + private static readonly Dictionary Queues = new(StringComparer.Ordinal) + { + ["awaiting_review"] = DeskQueue.AwaitingAnyone, + ["checks_failed"] = DeskQueue.ChecksFailed, + ["stale_facts"] = DeskQueue.StaleFacts, + ["recently_published"] = DeskQueue.RecentlyPublished, + ["reverted"] = DeskQueue.Reverted, + }; + + public IReadOnlyList Tools { get; } = + [ + new AgentTool( + "get_desk_counts", + "Get the review desk's counts", + "Returns how many items wait in each queue of the review desk: sets awaiting review, sets whose checks failed, stale facts, " + + "and sets published or reverted in the last seven days. Call list_desk_queue to see the items of one queue.", + ToolSchemas.Object(), + readOnly: true, + CountsAsync), + new AgentTool( + "list_desk_queue", + "List a review desk queue", + "Lists one queue of the review desk, as reviewers see it: awaiting_review and checks_failed put the set that has waited " + + "longest first; recently_published and reverted cover the last seven days, newest first; stale_facts lists the facts " + + "that are stale, the longest stale first. Read a set with get_change_set or get_change_set_review.", + ToolSchemas.Object( + new ToolArgument( + "queue", + "The queue to list.", + true, + new JsonObject + { + ["type"] = "string", + ["enum"] = new JsonArray([.. Queues.Keys.Select(name => JsonValue.Create(name))]), + ["examples"] = new JsonArray("awaiting_review"), + }), + ToolSchemas.PositiveInteger("page", "The page to read, from 1.", 1, required: false), + ToolSchemas.PositiveInteger("limit", $"How many items a page holds, at most {ChangeSetListQuery.MaxLimit}; {DefaultLimit} when left out.", DefaultLimit, required: false)), + readOnly: true, + ListAsync), + new AgentTool( + "get_change_set_review", + "Get a change set's review status", + "Returns where a change set stands in review: its state and since when, the reason if a person rejected it, the set it " + + "reverts, and every move it made, in order, with who made it and when. Use it to learn whether your set was approved, " + + "published or rejected and why; a rejected set's reason says what to change in a new set.", + ToolSchemas.Object(ToolSchemas.Uuid("id", "The change set's id.")), + readOnly: true, + ReviewAsync), + ]; + + private static async Task CountsAsync(ToolInvocation call, CancellationToken cancellationToken) + { + var arguments = new ToolArgumentsReader(call.Arguments); + if (arguments.Errors() is { Count: > 0 } errors) + { + return ToolResult.Failure(errors); + } + + var counts = await call.Services.GetRequiredService().CountAsync(cancellationToken); + return ToolResult.From(counts, values => new JsonObject + { + ["counts"] = new JsonObject([.. Queues.Select(queue => KeyValuePair.Create(queue.Key, (JsonNode?)values[queue.Value]))]), + }); + } + + private static async Task ListAsync(ToolInvocation call, CancellationToken cancellationToken) + { + var arguments = new ToolArgumentsReader(call.Arguments); + var name = arguments.RequiredString("queue", "awaiting_review"); + var page = arguments.OptionalPositiveInteger("page") ?? 1; + var limit = arguments.OptionalPositiveInteger("limit") ?? DefaultLimit; + var errors = arguments.Errors().ToList(); + if (name is not null && !Queues.ContainsKey(name)) + { + errors.Add(new Error(ToolErrorCodes.ArgumentInvalid, $"The argument 'queue' must be one of {string.Join(", ", Queues.Keys)}, and it is '{name}'.")); + } + + if (limit > ChangeSetListQuery.MaxLimit) + { + errors.Add(new Error(ToolErrorCodes.ArgumentInvalid, $"The argument 'limit' must be at most {ChangeSetListQuery.MaxLimit}, and it is {limit}.")); + } + + if (errors.Count > 0) + { + return ToolResult.Failure(errors); + } + + var queue = Queues[name!]; + var offset = (int)Math.Min((page - 1L) * limit, int.MaxValue - limit); + var queues = call.Services.GetRequiredService(); + + if (queue == DeskQueue.StaleFacts) + { + var facts = await queues.ListStaleFactsAsync(offset, limit, cancellationToken); + return ToolResult.From(facts, list => new JsonObject + { + ["queue"] = name, + ["total"] = list.Total, + ["facts"] = new JsonArray([.. list.Items.Select(ToolJson.Stale)]), + ["next"] = "Re-verify each fact with set_entity_fact and a new last_verified, or a new value and valid_until.", + }); + } + + var sets = await queues.ListChangeSetsAsync(queue, offset, limit, cancellationToken); + return ToolResult.From(sets, list => new JsonObject + { + ["queue"] = name, + ["total"] = list.Total, + ["changeSets"] = new JsonArray([.. list.Items.Select(Summary)]), + ["next"] = "Read a set's operations with get_change_set, its changes with get_change_set_diff and its review with get_change_set_review.", + }); + } + + private static async Task ReviewAsync(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 read = await call.Services.GetRequiredService().GetAsync(id!.Value, cancellationToken); + if (!read.IsSuccess) + { + return ToolResult.Failure(read.Errors, "A change set's id comes back from the call that opened it, or from list_desk_queue."); + } + + var set = read.Value; + var history = call.Services.GetRequiredService().Read(new AuditQuery { Subject = new AuditSubject("change_set", set.Id) }); + var moves = new JsonArray(); + DateTimeOffset? since = null; + if (history.IsSuccess) + { + await foreach (var auditEvent in history.Value.WithCancellation(cancellationToken)) + { + if (!ChangeSetMoves.Operations.Contains(auditEvent.Operation)) + { + continue; + } + + since = auditEvent.Timestamp; + moves.Add(new JsonObject + { + ["move"] = auditEvent.Operation["change_set.".Length..], + ["by"] = Initiator(auditEvent.Initiator), + ["at"] = auditEvent.Timestamp.ToUniversalTime().ToString("O"), + }); + } + } + + return ToolResult.Success(new JsonObject + { + ["id"] = set.Id.ToString(), + ["state"] = ToolJson.Name(set.State), + ["stateSince"] = since?.ToUniversalTime().ToString("O"), + ["rejectionReason"] = set.RejectionReason, + ["revertsChangeSetId"] = set.RevertsChangeSetId?.ToString(), + ["moves"] = moves, + }); + } + + private static JsonObject Summary(ChangeSetSummary set) + { + return new JsonObject + { + ["id"] = set.Id.ToString(), + ["state"] = ToolJson.Name(set.State), + ["rationale"] = set.Rationale, + ["initiator"] = Initiator(set.Initiator), + ["items"] = set.ItemCount, + ["revertsChangeSetId"] = set.RevertsChangeSetId?.ToString(), + ["openedAt"] = set.OpenedAt.ToUniversalTime().ToString("O"), + ["stateSince"] = set.StateSince.ToUniversalTime().ToString("O"), + }; + } + + private static JsonObject Initiator(Initiator initiator) + { + var kind = initiator switch + { + AgentInitiator => "agent", + SystemInitiator => "system", + _ => "user", + }; + + return new JsonObject { ["kind"] = kind, ["name"] = initiator.DisplayName }; + } +} diff --git a/src/FluentCMS.Core/ChangeSets/ChangeSetListQuery.cs b/src/FluentCMS.Core/ChangeSets/ChangeSetListQuery.cs index 6ec30e9fc..dd42548fb 100644 --- a/src/FluentCMS.Core/ChangeSets/ChangeSetListQuery.cs +++ b/src/FluentCMS.Core/ChangeSets/ChangeSetListQuery.cs @@ -1,3 +1,4 @@ +using FluentCMS.Core.Identity; using FluentCMS.Core.Results; namespace FluentCMS.Core.ChangeSets; @@ -11,6 +12,9 @@ public sealed record ChangeSetListQuery public DateTimeOffset? EnteredSince { get; init; } + // Only the sets this person or agent opened; null for every set. + public Initiator? OpenedBy { get; init; } + // Newest first puts the set that entered its state last first; otherwise the set that has waited longest comes first. public bool NewestFirst { get; init; } diff --git a/src/FluentCMS.Core/ChangeSets/ChangeSetMoves.cs b/src/FluentCMS.Core/ChangeSets/ChangeSetMoves.cs new file mode 100644 index 000000000..e5ccd116e --- /dev/null +++ b/src/FluentCMS.Core/ChangeSets/ChangeSetMoves.cs @@ -0,0 +1,22 @@ +namespace FluentCMS.Core.ChangeSets; + +// The audit operations the change set directory appends, with the set as the subject, when it opens a set and on each move between +// the CHG-04 states. Other events about a set, such as a refused move, are not moves. Read sides date a set and list its review +// decisions from these. +public static class ChangeSetMoves +{ + public const string Opened = "change_set.opened"; + + public static IReadOnlyList Operations { get; } = + [ + Opened, + "change_set.reopened", + "change_set.submitted", + "change_set.checks_failed", + "change_set.awaiting_review", + "change_set.approved", + "change_set.published", + "change_set.rejected", + "change_set.reverted", + ]; +} diff --git a/src/FluentCMS.Core/Desk/DeskQueues.cs b/src/FluentCMS.Core/Desk/DeskQueues.cs index c470aaabf..1b9a122eb 100644 --- a/src/FluentCMS.Core/Desk/DeskQueues.cs +++ b/src/FluentCMS.Core/Desk/DeskQueues.cs @@ -1,10 +1,12 @@ using FluentCMS.Core.ChangeSets; using FluentCMS.Core.Entities; +using FluentCMS.Core.Identity; using FluentCMS.Core.Results; namespace FluentCMS.Core.Desk; -internal sealed class DeskQueues(IChangeSetLists lists, IEntityFacts facts, TimeProvider clock) : IDeskQueues +// An agent reads its own change sets only (IAgentPermissions.CheckChangeSetReadAsync), so an agent's queues hold the sets it opened. +internal sealed class DeskQueues(IChangeSetLists lists, IEntityFacts facts, TimeProvider clock, ICurrentInitiator currentInitiator) : IDeskQueues { // How far back the recently published and reverted queues reach. public static readonly TimeSpan RecentWithin = TimeSpan.FromDays(7); @@ -25,7 +27,7 @@ public async Task>> CountAsync(Cancel foreach (var queue in ChangeSetQueues.Where(queue => queue != DeskQueue.AwaitingMe)) { - var counted = await lists.CountAsync(Query(queue, now), cancellationToken); + var counted = await lists.CountAsync(Scoped(Query(queue, now)), cancellationToken); if (!counted.IsSuccess) { return Result>.Failure(counted.Errors); @@ -55,7 +57,7 @@ public async Task> CountAsync(DeskQueue queue, CancellationToken can return stale.IsSuccess ? Result.Success(stale.Value.Count) : Result.Failure(stale.Errors); } - return await lists.CountAsync(Query(queue, clock.GetUtcNow()), cancellationToken); + return await lists.CountAsync(Scoped(Query(queue, clock.GetUtcNow())), cancellationToken); } public Task> ListChangeSetsAsync(DeskQueue queue, int offset, int limit, CancellationToken cancellationToken = default) @@ -67,7 +69,7 @@ public Task> ListChangeSetsAsync(DeskQueue queue, int offs $"The {queue} queue does not list change sets; it is one of {string.Join(", ", ChangeSetQueues)}.")); } - return lists.ListAsync(Query(queue, clock.GetUtcNow()) with { Offset = offset, Limit = limit }, cancellationToken); + return lists.ListAsync(Scoped(Query(queue, clock.GetUtcNow())) with { Offset = offset, Limit = limit }, cancellationToken); } public async Task> ListStaleFactsAsync(int offset, int limit, CancellationToken cancellationToken = default) @@ -84,6 +86,11 @@ public async Task> ListStaleFactsAsync(int offset, int lim : Result.Failure(stale.Errors); } + private ChangeSetListQuery Scoped(ChangeSetListQuery query) + { + return currentInitiator.Current is AgentInitiator agent ? query with { OpenedBy = agent } : query; + } + // Awaiting me lists every set awaiting review until routing records approvals (F-RTE); it will then leave out the sets the // reviewer has approved. Work queues put the set that has waited longest first; the recent ones the newest. private static ChangeSetListQuery Query(DeskQueue queue, DateTimeOffset now) diff --git a/src/FluentCMS.Infrastructure/ChangeSets/ChangeSetLists.cs b/src/FluentCMS.Infrastructure/ChangeSets/ChangeSetLists.cs index a875e0615..063248fae 100644 --- a/src/FluentCMS.Infrastructure/ChangeSets/ChangeSetLists.cs +++ b/src/FluentCMS.Infrastructure/ChangeSets/ChangeSetLists.cs @@ -17,19 +17,6 @@ internal sealed class ChangeSetLists(FluentCmsDbContext context) : IChangeSetLis { private const string SubjectType = "change_set"; - private static readonly string[] Moves = - [ - ChangeSetDirectory.OpenedOperation, - "change_set.reopened", - "change_set.submitted", - "change_set.checks_failed", - "change_set.awaiting_review", - "change_set.approved", - "change_set.published", - "change_set.rejected", - "change_set.reverted", - ]; - public async Task> ListAsync(ChangeSetListQuery query, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(query); @@ -76,9 +63,9 @@ private static (string Sql, List Parameters) Select(ChangeSetListQuery q { var parameters = new List(); var openedSubject = Parameter(parameters, SubjectType); - var opened = Parameter(parameters, ChangeSetDirectory.OpenedOperation); + var opened = Parameter(parameters, ChangeSetMoves.Opened); var movedSubject = Parameter(parameters, SubjectType); - var moves = string.Join(", ", Moves.Select(move => Parameter(parameters, move))); + var moves = string.Join(", ", ChangeSetMoves.Operations.Select(move => Parameter(parameters, move))); var site = Parameter(parameters, Site.Default.Id); var states = string.Join(", ", query.States.Distinct().Select(state => Parameter(parameters, state.ToString()))); @@ -91,7 +78,7 @@ private static (string Sql, List Parameters) Select(ChangeSetListQuery q (SELECT MAX(e."Timestamp") FROM "AuditEvents" e WHERE e."SubjectType" = {movedSubject} AND e."SubjectId" = s."Id" AND e."Operation" IN ({moves})) AS "StateSince" FROM "ChangeSets" s - WHERE s."SiteId" = {site} AND s."State" IN ({states}) + WHERE s."SiteId" = {site} AND s."State" IN ({states}){OpenedBy(query, parameters)} ) AS "Listed" """; @@ -103,6 +90,17 @@ private static (string Sql, List Parameters) Select(ChangeSetListQuery q return (sql, parameters); } + private static string OpenedBy(ChangeSetListQuery query, List parameters) + { + if (query.OpenedBy is not { } initiator) + { + return string.Empty; + } + + var (kind, id) = InitiatorKinds.Describe(initiator); + return $" AND s.\"InitiatorKind\" = {Parameter(parameters, kind)} AND s.\"InitiatorId\" = {Parameter(parameters, id)}"; + } + private static string Parameter(List parameters, object value) { parameters.Add(value); diff --git a/tests/FluentCMS.IntegrationTests/Desk/DeskQueueTests.cs b/tests/FluentCMS.IntegrationTests/Desk/DeskQueueTests.cs index e65c2ed6e..ee5615aff 100644 --- a/tests/FluentCMS.IntegrationTests/Desk/DeskQueueTests.cs +++ b/tests/FluentCMS.IntegrationTests/Desk/DeskQueueTests.cs @@ -225,10 +225,20 @@ private static async Task ListAsync(ChangeSetScenario scenario, C return (await scope.ServiceProvider.GetRequiredService().ListAsync(query)).Value; } + // As a reviewer reads them; an agent's queues hold only its own sets. private static async Task QueuesAsync(ChangeSetScenario scenario, Func> read) { - await using var scope = scenario.Services.CreateAsyncScope(); - return await read(scope.ServiceProvider.GetRequiredService()); + var initiator = scenario.Initiator; + scenario.Initiator = Amir; + try + { + await using var scope = scenario.Services.CreateAsyncScope(); + return await read(scope.ServiceProvider.GetRequiredService()); + } + finally + { + scenario.Initiator = initiator; + } } private static (Guid? Reverts, Initiator Initiator, ChangeSetState State, int Items) Describe(ChangeSetSummary set) diff --git a/tests/FluentCMS.IntegrationTests/Mcp/DeskToolsTests.cs b/tests/FluentCMS.IntegrationTests/Mcp/DeskToolsTests.cs new file mode 100644 index 000000000..bfc1ff666 --- /dev/null +++ b/tests/FluentCMS.IntegrationTests/Mcp/DeskToolsTests.cs @@ -0,0 +1,139 @@ +using System.Text.Json.Nodes; +using FluentCMS.Core.Agents; +using FluentCMS.Core.ChangeSets; +using FluentCMS.Core.Tools; +using FluentCMS.Infrastructure.Persistence; + +namespace FluentCMS.IntegrationTests.Mcp; + +public sealed class DeskToolsTests +{ + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task An_agent_reads_the_desk_queues_and_counts_of_its_own_sets(string provider) + { + await using var agent = await ConnectedAgent.StartAsync(provider); + var first = await SubmitAsync(agent, "product"); + var second = await SubmitAsync(agent, "category"); + var failed = await SubmitAsync(agent, "brand"); + await MoveAsync(agent, first, ChangeSetState.AwaitingReview); + await MoveAsync(agent, second, ChangeSetState.AwaitingReview); + await MoveAsync(agent, failed, ChangeSetState.ChecksFailed); + var someoneElses = await PersonsSetAsync(agent, "page"); + var events = await agent.QueryAsync(context => Task.FromResult(context.AuditEvents.Count())); + + var counts = await agent.CallAsync("get_desk_counts", []); + var awaiting = await agent.CallAsync("list_desk_queue", new() { ["queue"] = "awaiting_review" }); + var page = await agent.CallAsync("list_desk_queue", new() { ["queue"] = "awaiting_review", ["page"] = 2, ["limit"] = 1 }); + var checksFailed = await agent.CallAsync("list_desk_queue", new() { ["queue"] = "checks_failed" }); + var stale = await agent.CallAsync("list_desk_queue", new() { ["queue"] = "stale_facts" }); + + Assert.Equal( + ["awaiting_review", "checks_failed", "stale_facts", "recently_published", "reverted"], + counts["counts"]!.AsObject().Select(pair => pair.Key)); + Assert.Equal((2, 1, 0), ((int)counts["counts"]!["awaiting_review"]!, (int)counts["counts"]!["checks_failed"]!, (int)counts["counts"]!["stale_facts"]!)); + Assert.Equal([first.ToString(), second.ToString()], Ids(awaiting)); + Assert.DoesNotContain(someoneElses.ToString(), Ids(awaiting)); + Assert.Equal(2, (int)awaiting["total"]!); + var listed = awaiting["changeSets"]![0]!; + Assert.Equal( + ("awaiting_review", "Add the product type.", "agent", "schema-agent", 1), + ((string?)listed["state"], (string?)listed["rationale"], (string?)listed["initiator"]!["kind"], (string?)listed["initiator"]!["name"], (int)listed["items"]!)); + Assert.Equal([second.ToString()], Ids(page)); + Assert.Equal([failed.ToString()], Ids(checksFailed)); + Assert.Empty(stale["facts"]!.AsArray()); + Assert.Equal(events, await agent.QueryAsync(context => Task.FromResult(context.AuditEvents.Count()))); + } + + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task An_agent_reads_who_rejected_its_set_and_why_and_not_the_review_of_another_set(string provider) + { + await using var agent = await ConnectedAgent.StartAsync(provider); + var set = await SubmitAsync(agent, "product"); + await MoveAsync(agent, set, ChangeSetState.AwaitingReview); + await agent.AsPersonAsync(directory => directory.RejectAsync(set, "The type duplicates item.")); + var someoneElses = await PersonsSetAsync(agent, "page"); + + var review = await agent.CallAsync("get_change_set_review", new() { ["id"] = set.ToString() }); + var refused = await agent.FailAsync("get_change_set_review", new() { ["id"] = someoneElses.ToString() }); + + Assert.Equal(("rejected", "The type duplicates item."), ((string?)review["state"], (string?)review["rejectionReason"])); + var moves = review["moves"]!.AsArray(); + Assert.Equal(["opened", "submitted", "awaiting_review", "rejected"], moves.Select(move => (string?)move!["move"])); + Assert.Equal(("agent", "schema-agent"), ((string?)moves[0]!["by"]!["kind"], (string?)moves[0]!["by"]!["name"])); + Assert.Equal(("user", "Test administrator"), ((string?)moves[3]!["by"]!["kind"], (string?)moves[3]!["by"]!["name"])); + Assert.Equal((string?)moves[3]!["at"], (string?)review["stateSince"]); + Assert.Equal(AgentPermissionErrorCodes.NotOwner, Code(refused)); + } + + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task Bad_arguments_and_an_unknown_set_are_refused_with_what_to_do(string provider) + { + await using var agent = await ConnectedAgent.StartAsync(provider); + + var unknownQueue = await agent.FailAsync("list_desk_queue", new() { ["queue"] = "awaiting_me" }); + var tooMany = await agent.FailAsync("list_desk_queue", new() { ["queue"] = "reverted", ["limit"] = 500 }); + var zeroPage = await agent.FailAsync("list_desk_queue", new() { ["queue"] = "reverted", ["page"] = 0 }); + var unknownSet = await agent.FailAsync("get_change_set_review", new() { ["id"] = Guid.NewGuid().ToString() }); + + Assert.Equal(ToolErrorCodes.ArgumentInvalid, Code(unknownQueue)); + Assert.Contains("awaiting_review", Message(unknownQueue)); + Assert.Equal(ToolErrorCodes.ArgumentInvalid, Code(tooMany)); + Assert.Equal(ToolErrorCodes.ArgumentInvalid, Code(zeroPage)); + Assert.Equal(ChangeSetErrorCodes.ChangeSetNotFound, Code(unknownSet)); + Assert.Contains("list_desk_queue", unknownSet.ToJsonString()); + } + + private static string? Code(JsonObject failure) + { + return (string?)failure["errors"]![0]!["code"]; + } + + private static string Message(JsonObject failure) + { + return (string?)failure["errors"]![0]!["message"] ?? string.Empty; + } + + private static List Ids(JsonObject list) + { + return [.. list["changeSets"]!.AsArray().Select(set => (string?)set!["id"])]; + } + + // A set the agent opens and submits through its tools, proposing one new content type. + private static async Task SubmitAsync(ConnectedAgent agent, string slug) + { + var opened = await agent.CallAsync("open_change_set", new() { ["rationale"] = $"Add the {slug} type." }); + var id = (string)opened["changeSet"]!["id"]!; + await agent.CallAsync("create_content_type", new() + { + ["name"] = slug, + ["slug"] = slug, + ["definition"] = new JsonObject { ["fields"] = new JsonArray(new JsonObject { ["name"] = "title", ["type"] = "text" }) }, + ["change_set_id"] = id, + }); + await agent.CallAsync("submit_change_set", new() { ["id"] = id }); + return Guid.Parse(id); + } + + // The checks and the reviewer move a set on; a person stands in for the engine here. + private static Task MoveAsync(ConnectedAgent agent, Guid id, ChangeSetState state) + { + return agent.AsPersonAsync(directory => directory.TransitionAsync(id, state)); + } + + private static async Task PersonsSetAsync(ConnectedAgent agent, string slug) + { + var type = await agent.CreateAsPersonAsync(slug); + var set = await agent.AsPersonAsync(directory => directory.OpenAsync(new NewChangeSet($"Add the {slug} type.", []))); + await agent.AsPersonAsync(directory => + directory.AddOperationAsync(set.Id, ChangeOperation.Create(OperationKind.Create, ItemKind.Schema, type.Id, null, 1).Value)); + await MoveAsync(agent, set.Id, ChangeSetState.ChecksRunning); + await MoveAsync(agent, set.Id, ChangeSetState.AwaitingReview); + return set.Id; + } +} diff --git a/tests/FluentCMS.IntegrationTests/Mcp/McpAgents.cs b/tests/FluentCMS.IntegrationTests/Mcp/McpAgents.cs index 2866d77e3..6309d85b5 100644 --- a/tests/FluentCMS.IntegrationTests/Mcp/McpAgents.cs +++ b/tests/FluentCMS.IntegrationTests/Mcp/McpAgents.cs @@ -22,10 +22,10 @@ internal static class McpAgents public static readonly string[] BuiltInTools = [ "check_draft", "create_content_type", "create_entry", "delete_entry", "get_blast_radius", "get_change_set", "get_change_set_diff", - "get_content_type", "get_entity_facts", "get_entity_relations", "get_entry", "get_entry_history", "get_resolved_entry", - "get_schema_document", "get_where_used", "list_content_types", "list_entries", "list_stale_facts", "list_unsourced_facts", - "open_change_set", "relate_entities", "reopen_change_set", "revise_content_type", "set_entity_fact", "submit_change_set", - "update_entry", + "get_change_set_review", "get_content_type", "get_desk_counts", "get_entity_facts", "get_entity_relations", "get_entry", + "get_entry_history", "get_resolved_entry", "get_schema_document", "get_where_used", "list_content_types", "list_desk_queue", + "list_entries", "list_stale_facts", "list_unsourced_facts", "open_change_set", "relate_entities", "reopen_change_set", + "revise_content_type", "set_entity_fact", "submit_change_set", "update_entry", ]; public static async Task ToolNamesAsync(McpClient client) diff --git a/tests/FluentCMS.Tests/Desk/DeskQueuesTests.cs b/tests/FluentCMS.Tests/Desk/DeskQueuesTests.cs index 266662111..8f2459e1e 100644 --- a/tests/FluentCMS.Tests/Desk/DeskQueuesTests.cs +++ b/tests/FluentCMS.Tests/Desk/DeskQueuesTests.cs @@ -2,6 +2,7 @@ using FluentCMS.Core.ChangeSets; using FluentCMS.Core.Desk; using FluentCMS.Core.Entities; +using FluentCMS.Core.Identity; using FluentCMS.Core.Results; using Microsoft.Extensions.DependencyInjection; @@ -13,12 +14,14 @@ public sealed class DeskQueuesTests : IDisposable private readonly RecordingLists _lists = new(); private readonly StaleFacts _facts = new(); + private readonly Caller _initiator = new(); private readonly ServiceProvider _provider; public DeskQueuesTests() { _provider = new ServiceCollection() .AddSingleton(new FixedClock(Now)) + .AddSingleton(_initiator) .AddFluentCmsCore() .AddSingleton(_lists) .AddSingleton(_facts) @@ -60,6 +63,24 @@ public async Task Counts_every_queue_and_the_stale_facts() Assert.All(_lists.Counted.Where(query => query.States.Contains(ChangeSetState.Published)), query => Assert.Equal(Now.AddDays(-7), query.EnteredSince)); } + // An agent reads its own change sets only, so its queues and counts hold the sets it opened; a person's hold every set. + [Fact] + public async Task An_agent_reads_only_the_sets_it_opened() + { + var agent = new AgentInitiator(Guid.NewGuid(), "catalog-sync"); + _initiator.Current = agent; + + await Queues.ListChangeSetsAsync(DeskQueue.AwaitingAnyone, 0, 10); + await Queues.CountAsync(DeskQueue.ChecksFailed); + await Queues.CountAsync(); + _initiator.Current = new UserInitiator(Guid.NewGuid(), "Dana"); + await Queues.ListChangeSetsAsync(DeskQueue.AwaitingAnyone, 0, 10); + + Assert.Equal(agent, _lists.Listed[0].OpenedBy); + Assert.All(_lists.Counted, query => Assert.Equal(agent, query.OpenedBy)); + Assert.Null(_lists.Listed[1].OpenedBy); + } + [Fact] public async Task One_queue_is_counted_on_its_own() { @@ -162,6 +183,11 @@ public Task>> UnsourcedInChangeSetAsync(Guid chan } } + private sealed class Caller : ICurrentInitiator + { + public Initiator? Current { get; set; } + } + private sealed class FixedClock(DateTimeOffset now) : TimeProvider { public override DateTimeOffset GetUtcNow() diff --git a/tests/FluentCMS.Tests/Tools/McpCoverageTests.cs b/tests/FluentCMS.Tests/Tools/McpCoverageTests.cs index f191a0da9..e9dc1963e 100644 --- a/tests/FluentCMS.Tests/Tools/McpCoverageTests.cs +++ b/tests/FluentCMS.Tests/Tools/McpCoverageTests.cs @@ -34,7 +34,8 @@ public sealed class McpCoverageTests Missing("RUL", "Read and propose rules", "#2419"), new("CHK", "Run the checks on a draft before proposing it", ["check_draft"]), Missing("CHK", "Read the check results of a change set", "#2420"), - Missing("DSK", "Read review decisions, read-only", "#2422"), + new("DSK", "Read the review desk's queues and counts, read-only", ["get_desk_counts", "list_desk_queue"]), + new("DSK", "Read a change set's review status and decisions, read-only", ["get_change_set_review"]), Missing("MED", "Upload and describe media", "#2427"), Missing("Search", "Search entries and entities", "#2430"), Missing("MCP-08", "Bulk operations that return a change set id and report progress", "#2423"), diff --git a/tests/FluentCMS.Tests/Tools/ToolProviderConformanceTests.cs b/tests/FluentCMS.Tests/Tools/ToolProviderConformanceTests.cs index df9c5b2a0..145b933bb 100644 --- a/tests/FluentCMS.Tests/Tools/ToolProviderConformanceTests.cs +++ b/tests/FluentCMS.Tests/Tools/ToolProviderConformanceTests.cs @@ -124,3 +124,8 @@ public sealed class EntityToolsConformanceTests : ToolProviderConformanceTests { protected override object Provider { get; } = new EntityTools(); } + +public sealed class DeskToolsConformanceTests : ToolProviderConformanceTests +{ + protected override object Provider { get; } = new DeskTools(); +}