Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <key>` 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.

Expand Down
18 changes: 2 additions & 16 deletions src/FluentCMS.Admin/Components/Pages/ChangeSetView.razor
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> 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<AuditEvent> _history = [];
private bool _historyTruncated;
Expand Down Expand Up @@ -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;
}

Expand Down
208 changes: 208 additions & 0 deletions src/FluentCMS.Core/Builtin/Tools/DeskTools.cs
Original file line number Diff line number Diff line change
@@ -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<string, DeskQueue> 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<AgentTool> 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<ToolResult> 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<IDeskQueues>().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<ToolResult> 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<IDeskQueues>();

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<ToolResult> 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<IChangeSetDirectory>().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<IAuditReader>().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 };
}
}
4 changes: 4 additions & 0 deletions src/FluentCMS.Core/ChangeSets/ChangeSetListQuery.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using FluentCMS.Core.Identity;
using FluentCMS.Core.Results;

namespace FluentCMS.Core.ChangeSets;
Expand All @@ -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; }

Expand Down
22 changes: 22 additions & 0 deletions src/FluentCMS.Core/ChangeSets/ChangeSetMoves.cs
Original file line number Diff line number Diff line change
@@ -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<string> 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",
];
}
Loading
Loading