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 @@ -54,7 +54,7 @@ Tests run against SQLite in-process. To run the PostgreSQL tests as well, set `F

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` and `Content__DeletedRetention` (how long a deleted entry can be restored, `30.00:00:00` 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`. The server has no tools yet.
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.

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
7 changes: 4 additions & 3 deletions src/FluentCMS.Core/Agents/AgentPermissions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ await RecordAsync(
+ $"(change sets per day). Add the operation to a change set that is still open, or wait until fewer than {cap} were opened in the last 24 hours.");
}

public async Task<Result> CheckChangeSetSizeAsync(ChangeSet changeSet, CancellationToken cancellationToken = default)
public async Task<Result> CheckChangeSetSizeAsync(ChangeSet changeSet, int adding = 0, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(changeSet);

Expand All @@ -104,7 +104,8 @@ public async Task<Result> CheckChangeSetSizeAsync(ChangeSet changeSet, Cancellat
}

var cap = credential.Caps.ItemsPerChangeSet;
if (changeSet.Operations.Count <= cap)
var count = changeSet.Operations.Count + adding;
if (count <= cap)
{
return Result.Success();
}
Expand All @@ -115,7 +116,7 @@ await RecordAsync(

return Result.Failure(
AgentPermissionErrorCodes.CapExceeded,
$"Change set {changeSet.Id} already holds {changeSet.Operations.Count - 1} items, and the credential '{credential.DisplayName}' allows "
$"Change set {changeSet.Id} already holds {count - 1} items, and the credential '{credential.DisplayName}' allows "
+ $"{cap} (items per change set). Submit this change set and add the rest to a new one.");
}

Expand Down
5 changes: 3 additions & 2 deletions src/FluentCMS.Core/Agents/IAgentPermissions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ public interface IAgentPermissions

Task<Result> CheckNewChangeSetAsync(CancellationToken cancellationToken = default);

// Called with the agent's own set as read for the write and the new operation added in memory, before anything is written.
Task<Result> CheckChangeSetSizeAsync(ChangeSet changeSet, CancellationToken cancellationToken = default);
// Called with the agent's own set as read for the write and the new operation added in memory, before anything is written,
// or ahead of a write with the number of operations it will add.
Task<Result> CheckChangeSetSizeAsync(ChangeSet changeSet, int adding = 0, CancellationToken cancellationToken = default);
}
110 changes: 110 additions & 0 deletions src/FluentCMS.Core/Builtin/Tools/ChangeSetTarget.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
using FluentCMS.Core.Agents;
using FluentCMS.Core.ChangeSets;
using FluentCMS.Core.Results;
using FluentCMS.Core.Tools;
using Microsoft.Extensions.DependencyInjection;

namespace FluentCMS.Core.Builtin.Tools;

// Where a write tool records its operation (CHG-01, CHG-02): an open change set named by its id, or the caller's set for a
// session id, which the session's first operation opens with the call's rationale and sources.
internal sealed class ChangeSetTarget
{
private const string SessionExample = "catalog-sync-2026-10-18";
private const string RationaleExample = "Add a price field so product pages can show prices.";
private const string SourceExample = "https://example.com/catalog-spec";

private readonly Guid? _changeSetId;
private readonly string? _sessionId;
private readonly NewChangeSet? _opening;

private ChangeSetTarget(Guid? changeSetId, string? sessionId, NewChangeSet? opening)
{
_changeSetId = changeSetId;
_sessionId = sessionId;
_opening = opening;
}

public static ToolArgument[] Arguments { get; } =
[
ToolSchemas.Uuid(
"change_set_id",
"The open change set to add the operation to, as open_change_set returned it. Give this or session_id, not both.",
required: false),
ToolSchemas.String(
"session_id",
"Groups your operations into one change set without keeping its id: the first operation of a session opens a set, "
+ "and later ones join it until it is submitted. Give this or change_set_id, not both.",
SessionExample,
required: false),
ToolSchemas.String(
"rationale",
"Why the change is made, for the person who reviews it. Required with session_id, where it opens the session's set.",
RationaleExample,
required: false),
ToolSchemas.Strings(
"sources",
"Where the change comes from, such as URLs or file names, for the reviewer. Only with session_id.",
SourceExample,
required: false),
];

public static ChangeSetTarget? Read(ToolArgumentsReader arguments, List<Error> errors)
{
var changeSetId = arguments.OptionalGuid("change_set_id");
var sessionId = arguments.OptionalString("session_id", SessionExample);
var rationale = arguments.OptionalString("rationale", RationaleExample);
var sources = arguments.OptionalStrings("sources", SourceExample);

if (changeSetId is not null && sessionId is null && rationale is null && sources is null)
{
return new ChangeSetTarget(changeSetId, null, null);
}

if (sessionId is not null && changeSetId is null && rationale is not null)
{
return new ChangeSetTarget(null, sessionId, new NewChangeSet(rationale, sources ?? []));
}

// A malformed id is already reported by the reader; saying the target is missing as well would mislead.
if (changeSetId is null && sessionId is null && arguments.Has("change_set_id"))
{
return null;
}

errors.Add(new Error(
ToolErrorCodes.ArgumentMissing,
changeSetId is not null
? "With 'change_set_id' give neither 'session_id', 'rationale' nor 'sources': the open set already has its rationale."
: sessionId is not null
? "'rationale' is required with 'session_id': it opens the session's change set and the reviewer reads it."
: "Say which change set the operation goes into: give 'change_set_id' from open_change_set, or a 'session_id' with a 'rationale'."));
return null;
}

// Refuses ahead of the write what the change set would refuse after it, so no version is stored for nothing and a refused
// cap keeps its security event (SEC-08), which a rolled-back write would take with it. The set checks again on the add.
public async Task<Result> CheckAsync(IServiceProvider services, CancellationToken cancellationToken)
{
var directory = services.GetRequiredService<IChangeSetDirectory>();
var permissions = services.GetRequiredService<IAgentPermissions>();

var set = _changeSetId is { } id
? await directory.GetAsync(id, cancellationToken)
: await directory.GetSessionSetAsync(_sessionId!, cancellationToken);

if (set.IsSuccess)
{
return await permissions.CheckChangeSetSizeAsync(set.Value, adding: 1, cancellationToken);
}

return _changeSetId is null ? await permissions.CheckNewChangeSetAsync(cancellationToken) : Result.Failure(set.Errors);
}

public Task<Result<ChangeSet>> AddAsync(IChangeSetDirectory directory, ChangeOperation operation, CancellationToken cancellationToken)
{
return _changeSetId is { } id
? directory.AddOperationAsync(id, operation, cancellationToken)
: directory.AddToSessionAsync(_sessionId!, _opening!, operation, cancellationToken);
}
}
76 changes: 73 additions & 3 deletions src/FluentCMS.Core/Builtin/Tools/ChangeSetTools.cs
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
using System.Text.Json.Nodes;
using FluentCMS.Core.ChangeSets;
using FluentCMS.Core.Results;
using FluentCMS.Core.Tools;
using Microsoft.Extensions.DependencyInjection;

namespace FluentCMS.Core.Builtin.Tools;

// Reading change sets (CHG-01, CHG-03): a set's state and operations, and what each operation changes field by field.
// An agent's change sets (CHG-01, CHG-03, CHG-04): reading a set and its diff, opening one, and submitting or reopening it.
// Approving, rejecting and publishing are a person's, so no tool does them (MCP-06).
public sealed class ChangeSetTools : IToolProvider
{
private const string FindIds = "A change set's id comes back from the call that opened it or added an operation to it.";
Expand All @@ -31,9 +33,77 @@ public sealed class ChangeSetTools : IToolProvider
ToolSchemas.Object(ToolSchemas.Uuid("id", "The change set's id.")),
readOnly: true,
DiffAsync),
new AgentTool(
"open_change_set",
"Open a change set",
"Opens a change set for the changes you are about to propose, with the reason for them and where they come from. "
+ "Pass its id as change_set_id to the write tools, then call submit_change_set. A person reviews the set before "
+ "anything in it is published. To group operations without keeping an id, give the write tools a session_id instead.",
ToolSchemas.Object(
ToolSchemas.String("rationale", "Why the changes are made, for the person who reviews them.", "Add a price field so product pages can show prices."),
ToolSchemas.Strings("sources", "Where the changes come from, such as URLs or file names.", "https://example.com/catalog-spec", required: false)),
readOnly: false,
OpenAsync),
new AgentTool(
"submit_change_set",
"Submit a change set for checks and review",
"Submits one of your open change sets once it holds every operation it needs: checks run on it, and a person reviews "
+ "it before anything is published. A submitted set takes no more operations. An agent never approves or publishes "
+ "a set; if checks fail, reopen it with reopen_change_set, fix it and submit it again.",
ToolSchemas.Object(ToolSchemas.Uuid("id", "The id of your open change set.")),
readOnly: false,
(call, cancellationToken) => MoveAsync(call, ChangeSetState.ChecksRunning, cancellationToken)),
new AgentTool(
"reopen_change_set",
"Reopen a change set after failed checks",
"Reopens one of your change sets whose checks failed, so you can add operations that fix it and submit it again. "
+ "Read the set with get_change_set first.",
ToolSchemas.Object(ToolSchemas.Uuid("id", "The id of your change set whose checks failed.")),
readOnly: false,
(call, cancellationToken) => MoveAsync(call, ChangeSetState.Open, cancellationToken)),
];

private static async Task<ToolResult> GetAsync(ToolCall call, CancellationToken cancellationToken)
private static async Task<ToolResult> OpenAsync(ToolInvocation call, CancellationToken cancellationToken)
{
var arguments = new ToolArgumentsReader(call.Arguments);
var rationale = arguments.RequiredString("rationale", "Add a price field so product pages can show prices.");
var sources = arguments.OptionalStrings("sources", "https://example.com/catalog-spec");
if (arguments.Errors() is { Count: > 0 } errors)
{
return ToolResult.Failure(errors);
}

var set = await call.Services.GetRequiredService<IChangeSetDirectory>().OpenAsync(new NewChangeSet(rationale!, sources ?? []), cancellationToken);
return ToolResult.From(set, opened => new JsonObject
{
["changeSet"] = ToolJson.ChangeSet(opened),
["next"] = $"Pass change_set_id {opened.Id} to create_content_type or revise_content_type, then call submit_change_set.",
});
}

private static async Task<ToolResult> MoveAsync(ToolInvocation call, ChangeSetState target, 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 set = await call.Services.GetRequiredService<IChangeSetDirectory>().TransitionAsync(id!.Value, target, cancellationToken);
return ToolResult.From(
set,
moved => new JsonObject
{
["changeSet"] = ToolJson.ChangeSet(moved),
["next"] = target == ChangeSetState.Open
? "The set is open again: add the operations that fix it, then call submit_change_set."
: "Checks run next, and a person reviews the set. Follow its state with get_change_set.",
},
Hint(set));
}

private static async Task<ToolResult> GetAsync(ToolInvocation call, CancellationToken cancellationToken)
{
var arguments = new ToolArgumentsReader(call.Arguments);
var id = arguments.RequiredGuid("id");
Expand All @@ -46,7 +116,7 @@ private static async Task<ToolResult> GetAsync(ToolCall call, CancellationToken
return ToolResult.From(set, ToolJson.ChangeSet, Hint(set));
}

private static async Task<ToolResult> DiffAsync(ToolCall call, CancellationToken cancellationToken)
private static async Task<ToolResult> DiffAsync(ToolInvocation call, CancellationToken cancellationToken)
{
var arguments = new ToolArgumentsReader(call.Arguments);
var id = arguments.RequiredGuid("id");
Expand Down
Loading
Loading