diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 26cddc82b..81e50a385 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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 ` 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 251cdac67..6949ad847 100644 --- a/src/FluentCMS.Core/Agents/AgentPermissions.cs +++ b/src/FluentCMS.Core/Agents/AgentPermissions.cs @@ -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 CheckChangeSetSizeAsync(ChangeSet changeSet, CancellationToken cancellationToken = default) + public async Task CheckChangeSetSizeAsync(ChangeSet changeSet, int adding = 0, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(changeSet); @@ -104,7 +104,8 @@ public async Task 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(); } @@ -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."); } diff --git a/src/FluentCMS.Core/Agents/IAgentPermissions.cs b/src/FluentCMS.Core/Agents/IAgentPermissions.cs index 2bc83396b..5faf67d22 100644 --- a/src/FluentCMS.Core/Agents/IAgentPermissions.cs +++ b/src/FluentCMS.Core/Agents/IAgentPermissions.cs @@ -14,6 +14,7 @@ public interface IAgentPermissions Task 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 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 CheckChangeSetSizeAsync(ChangeSet changeSet, int adding = 0, CancellationToken cancellationToken = default); } diff --git a/src/FluentCMS.Core/Builtin/Tools/ChangeSetTarget.cs b/src/FluentCMS.Core/Builtin/Tools/ChangeSetTarget.cs new file mode 100644 index 000000000..94adf41e1 --- /dev/null +++ b/src/FluentCMS.Core/Builtin/Tools/ChangeSetTarget.cs @@ -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 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 CheckAsync(IServiceProvider services, CancellationToken cancellationToken) + { + var directory = services.GetRequiredService(); + var permissions = services.GetRequiredService(); + + 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> AddAsync(IChangeSetDirectory directory, ChangeOperation operation, CancellationToken cancellationToken) + { + return _changeSetId is { } id + ? directory.AddOperationAsync(id, operation, cancellationToken) + : directory.AddToSessionAsync(_sessionId!, _opening!, operation, cancellationToken); + } +} diff --git a/src/FluentCMS.Core/Builtin/Tools/ChangeSetTools.cs b/src/FluentCMS.Core/Builtin/Tools/ChangeSetTools.cs index a9441d859..0bb5dd987 100644 --- a/src/FluentCMS.Core/Builtin/Tools/ChangeSetTools.cs +++ b/src/FluentCMS.Core/Builtin/Tools/ChangeSetTools.cs @@ -1,3 +1,4 @@ +using System.Text.Json.Nodes; using FluentCMS.Core.ChangeSets; using FluentCMS.Core.Results; using FluentCMS.Core.Tools; @@ -5,7 +6,8 @@ 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."; @@ -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 GetAsync(ToolCall call, CancellationToken cancellationToken) + private static async Task 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().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 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().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 GetAsync(ToolInvocation call, CancellationToken cancellationToken) { var arguments = new ToolArgumentsReader(call.Arguments); var id = arguments.RequiredGuid("id"); @@ -46,7 +116,7 @@ private static async Task GetAsync(ToolCall call, CancellationToken return ToolResult.From(set, ToolJson.ChangeSet, Hint(set)); } - private static async Task DiffAsync(ToolCall call, CancellationToken cancellationToken) + private static async Task DiffAsync(ToolInvocation call, CancellationToken cancellationToken) { var arguments = new ToolArgumentsReader(call.Arguments); var id = arguments.RequiredGuid("id"); diff --git a/src/FluentCMS.Core/Builtin/Tools/ProposedWrite.cs b/src/FluentCMS.Core/Builtin/Tools/ProposedWrite.cs new file mode 100644 index 000000000..ee086c0e3 --- /dev/null +++ b/src/FluentCMS.Core/Builtin/Tools/ProposedWrite.cs @@ -0,0 +1,65 @@ +using System.Text.Json.Nodes; +using FluentCMS.Core.ChangeSets; +using FluentCMS.Core.Persistence; +using FluentCMS.Core.Results; +using FluentCMS.Core.Schema; +using FluentCMS.Core.Tools; +using Microsoft.Extensions.DependencyInjection; + +namespace FluentCMS.Core.Builtin.Tools; + +// A schema write an agent proposes: the new version and its operation in the change set commit together or not at all, so no +// version an agent writes exists outside a change set a person reviews (AUD-N1, CHG-01). +internal static class ProposedWrite +{ + public static async Task RunAsync( + ToolInvocation call, + ChangeSetTarget target, + Func>> write, + Func operation, + CancellationToken cancellationToken) + { + var services = call.Services; + var ready = await target.CheckAsync(services, cancellationToken); + if (!ready.IsSuccess) + { + return ToolResult.Failure(ready.Errors); + } + + // Each directory finds its expected failures before its own unit of work, so leaving this one uncommitted undoes both. + await using var scope = await services.GetRequiredService().BeginAsync(cancellationToken); + + var type = await write(services.GetRequiredService()); + if (!type.IsSuccess) + { + return ToolResult.Failure(type.Errors, Hint(type.Errors)); + } + + var proposed = operation(type.Value); + var set = await target.AddAsync(services.GetRequiredService(), proposed, cancellationToken); + if (!set.IsSuccess) + { + return ToolResult.Failure(set.Errors); + } + + await scope.CommitAsync(cancellationToken); + + return ToolResult.Success(new JsonObject + { + ["contentType"] = ToolJson.ContentType(type.Value), + ["changeSet"] = ToolJson.ChangeSet(set.Value), + ["next"] = $"The {ToolJson.Name(proposed.Kind)} is in change set {set.Value.Id}, which stays open for more " + + "operations. Call submit_change_set when it is complete; checks run and a person reviews it before it is published.", + }); + } + + private static string? Hint(IReadOnlyList errors) + { + return errors.FirstOrDefault()?.Code switch + { + SchemaErrorCodes.VersionConflict => "Call get_content_type for the current version and its fields, then revise from that version.", + SchemaErrorCodes.ContentTypeNotFound => "Call list_content_types for the slugs of the site's content types.", + _ => null, + }; + } +} diff --git a/src/FluentCMS.Core/Builtin/Tools/SchemaTools.cs b/src/FluentCMS.Core/Builtin/Tools/SchemaTools.cs index bb50688aa..3db536dec 100644 --- a/src/FluentCMS.Core/Builtin/Tools/SchemaTools.cs +++ b/src/FluentCMS.Core/Builtin/Tools/SchemaTools.cs @@ -1,4 +1,5 @@ using System.Text.Json.Nodes; +using FluentCMS.Core.ChangeSets; using FluentCMS.Core.Results; using FluentCMS.Core.Schema; using FluentCMS.Core.Tools; @@ -6,7 +7,8 @@ namespace FluentCMS.Core.Builtin.Tools; -// Reading the schema (SCH-01, SCH-06, MCP-03): the content types of the site and the schema document entries are written from. +// The schema (SCH-01, SCH-06, MCP-03): reading the content types and the schema document entries are written from, and proposing +// new types and revisions, which go into a change set a person reviews. public sealed class SchemaTools : IToolProvider, IResourceProvider { private const string FindSlugs = "Call list_content_types for the slugs of the site's content types."; @@ -44,6 +46,34 @@ public sealed class SchemaTools : IToolProvider, IResourceProvider ToolSchemas.String("slug", "A content type's slug, to read only that type's schema.", "product", required: false)), readOnly: true, SchemaDocumentAsync), + new AgentTool( + "create_content_type", + "Propose a new content type", + "Proposes a new content type: it stores version 1, unpublished, and adds the create to a change set in the same " + + "call. It is not published until a person approves and publishes the set. Every problem with the definition comes back " + + "at once, by field. Field types: text, richText, number, boolean, dateTime, enum, list, json, media and reference; " + + "get_schema_document shows how values of each look.", + ToolSchemas.Object([ + ToolSchemas.String("name", "The name people see, such as 'Product page'.", "Product"), + ToolSchemas.String("slug", "Lowercase words joined by hyphens; entries and references use it, so it never changes.", "product"), + Definition, + .. ChangeSetTarget.Arguments]), + readOnly: false, + CreateAsync), + new AgentTool( + "revise_content_type", + "Propose a revision of a content type", + "Proposes a new version of a content type from the version you read, given as base_version: it stores the next " + + "version, unpublished, and adds the update to a change set in the same call. The definition replaces the whole " + + "previous one, so list every field to keep. If someone revised the type since you read it, the call is refused; " + + "read it again with get_content_type.", + ToolSchemas.Object([ + ToolSchemas.String("slug", "The content type's slug.", "product"), + ToolSchemas.PositiveInteger("base_version", "The version you read and revise, as get_content_type returned it.", 1), + Definition, + .. ChangeSetTarget.Arguments]), + readOnly: false, + ReviseAsync), ]; public IReadOnlyList Resources { get; } = @@ -67,7 +97,106 @@ public sealed class SchemaTools : IToolProvider, IResourceProvider }), ]; - private static async Task ListAsync(ToolCall call, CancellationToken cancellationToken) + private static ToolArgument Definition { get; } = ToolSchemas.Json( + "definition", + "The fields of the type in order, each with a name, a field type, whether it is required, a description and the " + + "type's settings; 'entity' makes it an entity type whose fields are facts with a source (SCH-03).", + new JsonObject + { + ["type"] = "object", + ["properties"] = new JsonObject + { + ["entity"] = new JsonObject { ["type"] = "boolean" }, + ["description"] = new JsonObject { ["type"] = "string" }, + ["fields"] = new JsonObject + { + ["type"] = "array", + ["items"] = new JsonObject + { + ["type"] = "object", + ["properties"] = new JsonObject + { + ["name"] = new JsonObject { ["type"] = "string" }, + ["type"] = new JsonObject { ["type"] = "string" }, + ["required"] = new JsonObject { ["type"] = "boolean" }, + ["description"] = new JsonObject { ["type"] = "string" }, + ["settings"] = new JsonObject { ["type"] = "object" }, + }, + ["required"] = new JsonArray("name", "type"), + }, + }, + }, + ["required"] = new JsonArray("fields"), + }, + JsonNode.Parse("""{"fields": [{"name": "title", "type": "text", "required": true}, {"name": "price", "type": "number", "settings": {"min": 0}}]}""")!); + + private static async Task CreateAsync(ToolInvocation call, CancellationToken cancellationToken) + { + var arguments = new ToolArgumentsReader(call.Arguments); + var name = arguments.RequiredString("name", "Product"); + var slug = arguments.RequiredString("slug", "product"); + var definition = arguments.RequiredObject("definition", DefinitionForm); + var errors = new List(); + var target = ChangeSetTarget.Read(arguments, errors); + if (Refused(arguments, errors, definition, out var parsed) is { } refused) + { + return refused; + } + + return await ProposedWrite.RunAsync( + call, + target!, + directory => directory.CreateAsync(new NewContentType(name!, slug!, parsed!), cancellationToken), + type => ChangeOperation.Create(OperationKind.Create, ItemKind.Schema, type.Id, null, type.Version).Value, + cancellationToken); + } + + private static async Task ReviseAsync(ToolInvocation call, CancellationToken cancellationToken) + { + var arguments = new ToolArgumentsReader(call.Arguments); + var slug = arguments.RequiredString("slug", "product"); + var baseVersion = arguments.RequiredPositiveInteger("base_version"); + var definition = arguments.RequiredObject("definition", DefinitionForm); + var errors = new List(); + var target = ChangeSetTarget.Read(arguments, errors); + if (Refused(arguments, errors, definition, out var parsed) is { } refused) + { + return refused; + } + + return await ProposedWrite.RunAsync( + call, + target!, + directory => directory.ReviseAsync(slug!, baseVersion!.Value, parsed!, cancellationToken), + type => ChangeOperation.Create(OperationKind.Update, ItemKind.Schema, type.Id, baseVersion!.Value, type.Version).Value, + cancellationToken); + } + + private const string DefinitionForm = "an object with a 'fields' array, such as {\"fields\": [{\"name\": \"title\", \"type\": \"text\"}]}"; + + // Argument and definition errors come back together, before anything is read or written. + private static ToolResult? Refused(ToolArgumentsReader arguments, List errors, System.Text.Json.JsonElement? definition, out ContentTypeDefinition? parsed) + { + errors.InsertRange(0, arguments.Errors()); + parsed = null; + + if (definition is { } json) + { + var read = ContentTypeDefinition.Parse(json.GetRawText()); + if (read.IsSuccess) + { + parsed = read.Value; + } + else + { + errors.AddRange(read.Errors); + } + } + + return errors.Count > 0 ? ToolResult.Failure(errors) : null; + } + + private static async Task ListAsync(ToolInvocation call, CancellationToken cancellationToken) { var arguments = new ToolArgumentsReader(call.Arguments); if (arguments.Errors() is { Count: > 0 } errors) @@ -79,7 +208,7 @@ private static async Task ListAsync(ToolCall call, CancellationToken return ToolResult.Success(new JsonObject { ["contentTypes"] = new JsonArray([.. types.Select(ToolJson.Summary)]) }); } - private static async Task GetAsync(ToolCall call, CancellationToken cancellationToken) + private static async Task GetAsync(ToolInvocation call, CancellationToken cancellationToken) { var arguments = new ToolArgumentsReader(call.Arguments); var slug = arguments.RequiredString("slug", "product"); @@ -97,7 +226,7 @@ private static async Task GetAsync(ToolCall call, CancellationToken return ToolResult.From(type, ToolJson.ContentType, Hint(type)); } - private static async Task SchemaDocumentAsync(ToolCall call, CancellationToken cancellationToken) + private static async Task SchemaDocumentAsync(ToolInvocation call, CancellationToken cancellationToken) { var arguments = new ToolArgumentsReader(call.Arguments); var slug = arguments.OptionalString("slug", "product"); diff --git a/src/FluentCMS.Core/ChangeSets/IChangeSetDirectory.cs b/src/FluentCMS.Core/ChangeSets/IChangeSetDirectory.cs index 8e52a6ec4..01da49d7d 100644 --- a/src/FluentCMS.Core/ChangeSets/IChangeSetDirectory.cs +++ b/src/FluentCMS.Core/ChangeSets/IChangeSetDirectory.cs @@ -11,6 +11,9 @@ public interface IChangeSetDirectory // Computed from the stored versions each operation names; a read, so it appends no event. Task> DiffAsync(Guid id, CancellationToken cancellationToken = default); + // The open set the caller opened under this session id, the one AddToSessionAsync adds to; change_set_not_found when none is. + Task> GetSessionSetAsync(string sessionId, CancellationToken cancellationToken = default); + Task> OpenAsync(NewChangeSet changeSet, CancellationToken cancellationToken = default); Task> AddOperationAsync(Guid changeSetId, ChangeOperation operation, CancellationToken cancellationToken = default); diff --git a/src/FluentCMS.Core/Tools/AgentTool.cs b/src/FluentCMS.Core/Tools/AgentTool.cs index 9ab8a1397..7871ec14b 100644 --- a/src/FluentCMS.Core/Tools/AgentTool.cs +++ b/src/FluentCMS.Core/Tools/AgentTool.cs @@ -7,7 +7,7 @@ namespace FluentCMS.Core.Tools; // call it and what to call next (MCP-02). The input schema refuses arguments it does not name, as the arguments reader does. public sealed class AgentTool { - private readonly Func> _invoke; + private readonly Func> _invoke; public AgentTool( string name, @@ -15,7 +15,7 @@ public AgentTool( string description, JsonObject inputSchema, bool readOnly, - Func> invoke) + Func> invoke) { if (!ToolNames.IsName(name)) { @@ -47,7 +47,7 @@ public AgentTool( // Whether the tool only reads. A tool that writes goes through a change set, so a person reviews what it proposes. public bool ReadOnly { get; } - public Task InvokeAsync(ToolCall call, CancellationToken cancellationToken = default) + public Task InvokeAsync(ToolInvocation call, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(call); return _invoke(call, cancellationToken); @@ -56,4 +56,4 @@ public Task InvokeAsync(ToolCall call, CancellationToken cancellatio // The arguments are a JSON object, empty when the agent sent none. The services are the call's own scope, so a tool reads the // directories as the agent that called it. -public sealed record ToolCall(JsonElement Arguments, IServiceProvider Services); +public sealed record ToolInvocation(JsonElement Arguments, IServiceProvider Services); diff --git a/src/FluentCMS.Core/Tools/ToolArgumentsReader.cs b/src/FluentCMS.Core/Tools/ToolArgumentsReader.cs index 35232c217..3e51944e2 100644 --- a/src/FluentCMS.Core/Tools/ToolArgumentsReader.cs +++ b/src/FluentCMS.Core/Tools/ToolArgumentsReader.cs @@ -32,39 +32,78 @@ public ToolArgumentsReader(JsonElement arguments) return String(name, example, required: false); } - public Guid? RequiredGuid(string name) + public IReadOnlyList? OptionalStrings(string name, string example) { if (Read(name) is not { } value) { - Missing(name, "a UUID such as '0192f3a1-7c2e-7a4b-9d3e-1f2a3b4c5d6e'"); return null; } - if (value.ValueKind == JsonValueKind.String && Guid.TryParse(value.GetString(), out var id)) + if (value.ValueKind == JsonValueKind.Array + && value.EnumerateArray().All(item => item.ValueKind == JsonValueKind.String && !string.IsNullOrWhiteSpace(item.GetString()))) { - return id; + return [.. value.EnumerateArray().Select(item => item.GetString()!)]; } - Invalid(name, "must be a UUID string such as '0192f3a1-7c2e-7a4b-9d3e-1f2a3b4c5d6e'"); + Invalid(name, $"must be an array of non-empty strings such as [\"{example}\"]"); return null; } - public int? OptionalPositiveInteger(string name) + public JsonElement? RequiredObject(string name, string form) { if (Read(name) is not { } value) { + Missing(name, form); return null; } - if (value.ValueKind == JsonValueKind.Number && value.TryGetInt32(out var number) && number > 0) + if (value.ValueKind == JsonValueKind.Object) { - return number; + return value; } - Invalid(name, "must be a whole number greater than zero"); + Invalid(name, $"must be a JSON object: {form}"); return null; } + public Guid? OptionalGuid(string name) + { + return Read(name) is null ? null : GuidOf(name); + } + + public Guid? RequiredGuid(string name) + { + if (Read(name) is null) + { + Missing(name, "a UUID such as '0192f3a1-7c2e-7a4b-9d3e-1f2a3b4c5d6e'"); + return null; + } + + return GuidOf(name); + } + + public int? RequiredPositiveInteger(string name) + { + if (Read(name) is null) + { + Missing(name, "a whole number greater than zero, such as 1"); + return null; + } + + return PositiveIntegerOf(name); + } + + public int? OptionalPositiveInteger(string name) + { + return Read(name) is null ? null : PositiveIntegerOf(name); + } + + // Whether the agent gave the argument at all, whatever its value. + public bool Has(string name) + { + return _arguments.ValueKind == JsonValueKind.Object && _arguments.TryGetProperty(name, out var value) && value.ValueKind != JsonValueKind.Null; + } + public IReadOnlyList Errors() { var errors = new List(_errors); @@ -105,6 +144,31 @@ public IReadOnlyList Errors() return null; } + private int? PositiveIntegerOf(string name) + { + var value = _arguments.GetProperty(name); + + if (value.ValueKind == JsonValueKind.Number && value.TryGetInt32(out var number) && number > 0) + { + return number; + } + + Invalid(name, "must be a whole number greater than zero"); + return null; + } + + private Guid? GuidOf(string name) + { + var value = _arguments.GetProperty(name); + if (value.ValueKind == JsonValueKind.String && Guid.TryParse(value.GetString(), out var id)) + { + return id; + } + + Invalid(name, "must be a UUID string such as '0192f3a1-7c2e-7a4b-9d3e-1f2a3b4c5d6e'"); + return null; + } + private JsonElement? Read(string name) { _known.Add(name); diff --git a/src/FluentCMS.Core/Tools/ToolSchemas.cs b/src/FluentCMS.Core/Tools/ToolSchemas.cs index ef755f361..648e5f76c 100644 --- a/src/FluentCMS.Core/Tools/ToolSchemas.cs +++ b/src/FluentCMS.Core/Tools/ToolSchemas.cs @@ -46,6 +46,28 @@ public static ToolArgument Uuid(string name, string description, bool required = new JsonObject { ["type"] = "string", ["format"] = "uuid", ["examples"] = new JsonArray("0192f3a1-7c2e-7a4b-9d3e-1f2a3b4c5d6e") }); } + public static ToolArgument Strings(string name, string description, string example, bool required = true) + { + return new ToolArgument( + name, + description, + required, + new JsonObject + { + ["type"] = "array", + ["items"] = new JsonObject { ["type"] = "string", ["minLength"] = 1 }, + ["examples"] = new JsonArray(new JsonArray(example)), + }); + } + + // An argument that is itself a JSON object, with its own schema and one example that satisfies it. + public static ToolArgument Json(string name, string description, JsonObject schema, JsonNode example, bool required = true) + { + var copy = schema.DeepClone().AsObject(); + copy["examples"] = new JsonArray(example.DeepClone()); + return new ToolArgument(name, description, required, copy); + } + public static ToolArgument PositiveInteger(string name, string description, int example, bool required = true) { return new ToolArgument(name, description, required, new JsonObject { ["type"] = "integer", ["minimum"] = 1, ["examples"] = new JsonArray(example) }); diff --git a/src/FluentCMS.Infrastructure/ChangeSets/ChangeSetDirectory.cs b/src/FluentCMS.Infrastructure/ChangeSets/ChangeSetDirectory.cs index 53a136f64..309c12469 100644 --- a/src/FluentCMS.Infrastructure/ChangeSets/ChangeSetDirectory.cs +++ b/src/FluentCMS.Infrastructure/ChangeSets/ChangeSetDirectory.cs @@ -59,6 +59,16 @@ public async Task> DiffAsync(Guid id, CancellationToken ca return Result.Success(new ChangeSetDiff(set.Id, items)); } + public async Task> GetSessionSetAsync(string sessionId, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(sessionId); + + var openId = await OpenSetOfSessionAsync(Initiator(), sessionId, cancellationToken); + return openId is { } id && await ReadAsync(id, cancellationToken) is { } stored + ? Result.Success(stored.ChangeSet) + : Result.Failure(ChangeSetErrorCodes.ChangeSetNotFound, $"You have no open change set for session '{sessionId}'."); + } + public async Task> OpenAsync(NewChangeSet changeSet, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(changeSet); @@ -245,7 +255,7 @@ private async Task> AddAsync( } // Checked against the read whose revision guards the write, so two concurrent operations cannot both pass the cap. - var allowed = await permissions.CheckChangeSetSizeAsync(set, cancellationToken); + var allowed = await permissions.CheckChangeSetSizeAsync(set, cancellationToken: cancellationToken); if (!allowed.IsSuccess) { return Result.Failure(allowed.Errors); diff --git a/src/FluentCMS.Mcp/CoreTools.cs b/src/FluentCMS.Mcp/CoreTools.cs index 64857e363..0c437cd29 100644 --- a/src/FluentCMS.Mcp/CoreTools.cs +++ b/src/FluentCMS.Mcp/CoreTools.cs @@ -65,7 +65,7 @@ public override async ValueTask InvokeAsync(RequestContext()); - var result = await tool.InvokeAsync(new ToolCall(arguments, request.Services!), cancellationToken); + var result = await tool.InvokeAsync(new ToolInvocation(arguments, request.Services!), cancellationToken); var node = result.ToJson(); var json = JsonSerializer.SerializeToElement(node); diff --git a/tests/FluentCMS.IntegrationTests/Mcp/BuiltInToolsTests.cs b/tests/FluentCMS.IntegrationTests/Mcp/BuiltInToolsTests.cs index 88efd5ec6..9728b8f42 100644 --- a/tests/FluentCMS.IntegrationTests/Mcp/BuiltInToolsTests.cs +++ b/tests/FluentCMS.IntegrationTests/Mcp/BuiltInToolsTests.cs @@ -37,7 +37,7 @@ public async Task An_agent_reads_content_types_and_the_schema_document_with_the_ { Assert.False(string.IsNullOrWhiteSpace(tool.Description)); Assert.Equal("object", tool.ProtocolTool.InputSchema.GetProperty("type").GetString()); - Assert.True(tool.ProtocolTool.Annotations?.ReadOnlyHint); + Assert.Equal(tool.Name.StartsWith("get_", StringComparison.Ordinal) || tool.Name.StartsWith("list_", StringComparison.Ordinal), tool.ProtocolTool.Annotations?.ReadOnlyHint); }); var list = await CallAsync(client, "list_content_types"); diff --git a/tests/FluentCMS.IntegrationTests/Mcp/McpAgents.cs b/tests/FluentCMS.IntegrationTests/Mcp/McpAgents.cs index 36e0bb6eb..2be354156 100644 --- a/tests/FluentCMS.IntegrationTests/Mcp/McpAgents.cs +++ b/tests/FluentCMS.IntegrationTests/Mcp/McpAgents.cs @@ -19,7 +19,11 @@ internal static class McpAgents public static readonly UserInitiator Issuer = new(Guid.NewGuid(), "Test administrator"); // The built-in tools every server lists, sorted by name. - public static readonly string[] BuiltInTools = ["get_change_set", "get_change_set_diff", "get_content_type", "get_schema_document", "list_content_types"]; + public static readonly string[] BuiltInTools = + [ + "create_content_type", "get_change_set", "get_change_set_diff", "get_content_type", "get_schema_document", + "list_content_types", "open_change_set", "reopen_change_set", "revise_content_type", "submit_change_set", + ]; public static async Task ToolNamesAsync(McpClient client) { diff --git a/tests/FluentCMS.IntegrationTests/Mcp/WriteToolsTests.cs b/tests/FluentCMS.IntegrationTests/Mcp/WriteToolsTests.cs new file mode 100644 index 000000000..162b52b30 --- /dev/null +++ b/tests/FluentCMS.IntegrationTests/Mcp/WriteToolsTests.cs @@ -0,0 +1,246 @@ +using System.Text.Json.Nodes; +using FluentCMS.Core.Agents; +using FluentCMS.Core.ChangeSets; +using FluentCMS.Core.Identity; +using FluentCMS.Core.Schema; +using FluentCMS.Core.Sites; +using FluentCMS.Core.Tools; +using FluentCMS.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; + +namespace FluentCMS.IntegrationTests.Mcp; + +public sealed class WriteToolsTests +{ + private static readonly JsonObject Title = Definition(("title", "text")); + + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task An_agent_proposes_a_new_type_and_a_revision_in_one_set_and_submits_it(string provider) + { + await using var agent = await AgentAsync(provider); + var product = await agent.CreateAsPersonAsync("product"); + + var opened = await agent.CallAsync("open_change_set", new() { ["rationale"] = "Add categories and prices.", ["sources"] = new[] { "https://example.com/spec" } }); + var id = (string)opened["changeSet"]!["id"]!; + var created = await agent.CallAsync("create_content_type", new() + { + ["name"] = "Category", + ["slug"] = "category", + ["definition"] = Title, + ["change_set_id"] = id, + }); + var revised = await agent.CallAsync("revise_content_type", new() + { + ["slug"] = "product", + ["base_version"] = 1, + ["definition"] = Definition(("title", "text"), ("price", "number")), + ["change_set_id"] = id, + }); + var submitted = await agent.CallAsync("submit_change_set", new() { ["id"] = id }); + + Assert.Equal(1, (int)created["contentType"]!["version"]!); + Assert.Equal(2, (int)revised["contentType"]!["version"]!); + Assert.Contains("submit_change_set", (string?)revised["next"]); + Assert.Equal("checks_running", (string?)submitted["changeSet"]!["state"]); + + var stored = await agent.AsPersonAsync(directory => directory.GetAsync(Guid.Parse(id))); + var category = await agent.AsPersonAsync(directory => directory.GetAsync("category")); + Assert.Equal(ChangeSetState.ChecksRunning, stored.State); + Assert.Equal(agent.Credential.Initiator, stored.Initiator); + Assert.Equal( + [ + ChangeOperation.Create(OperationKind.Create, ItemKind.Schema, category.Id, null, 1).Value, + ChangeOperation.Create(OperationKind.Update, ItemKind.Schema, product.Id, 1, 2).Value, + ], + stored.Operations); + + var calls = (await agent.QueryAsync(context => context.ToolCalls.AsNoTracking().ToListAsync())).OrderBy(call => call.Timestamp).ToList(); + Assert.Equal(["open_change_set", "create_content_type", "revise_content_type", "submit_change_set"], calls.Select(call => call.Tool)); + Assert.All(calls, call => Assert.Equal(ToolCallOutcome.Succeeded, call.Outcome)); + } + + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task Writes_with_one_session_id_land_in_one_set_opened_with_the_first_rationale(string provider) + { + await using var agent = await AgentAsync(provider); + + var first = await agent.CallAsync("create_content_type", new() + { + ["name"] = "Category", + ["slug"] = "category", + ["definition"] = Title, + ["session_id"] = "catalog-sync", + ["rationale"] = "Sync the catalog.", + ["sources"] = new[] { "exports/catalog.csv" }, + }); + var second = await agent.CallAsync("create_content_type", new() + { + ["name"] = "Region", + ["slug"] = "region", + ["definition"] = Title, + ["session_id"] = "catalog-sync", + ["rationale"] = "Ignored once the session's set is open.", + }); + + Assert.Equal((string?)first["changeSet"]!["id"], (string?)second["changeSet"]!["id"]); + Assert.Equal("Sync the catalog.", (string?)second["changeSet"]!["rationale"]); + Assert.Equal(2, second["changeSet"]!["operations"]!.AsArray().Count); + } + + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task A_refused_write_stores_no_version_and_a_refused_cap_keeps_its_event(string provider) + { + await using var agent = await AgentAsync(provider, itemsPerChangeSet: 1); + var personsSet = await agent.AsPersonAsync(directory => directory.OpenAsync(new NewChangeSet("A person's own set.", []))); + var id = (string)(await agent.CallAsync("open_change_set", new() { ["rationale"] = "One type at most." }))["changeSet"]!["id"]!; + var invalid = await agent.FailAsync("create_content_type", new() + { + ["name"] = "Region", + ["slug"] = "Region Type", + ["definition"] = Definition(("title", "no-such-type"), ("Bad Name", "text")), + ["change_set_id"] = id, + }); + await agent.CallAsync("create_content_type", new() { ["name"] = "Category", ["slug"] = "category", ["definition"] = Title, ["change_set_id"] = id }); + + var overCap = await agent.FailAsync("create_content_type", new() { ["name"] = "Region", ["slug"] = "region", ["definition"] = Title, ["change_set_id"] = id }); + var notOwn = await agent.FailAsync("create_content_type", new() + { + ["name"] = "Region", + ["slug"] = "region", + ["definition"] = Title, + ["change_set_id"] = personsSet.Id.ToString(), + }); + var noTarget = await agent.FailAsync("create_content_type", new() { ["name"] = "Region", ["slug"] = "region", ["definition"] = Title }); + var submitPersons = await agent.FailAsync("submit_change_set", new() { ["id"] = personsSet.Id.ToString() }); + + Assert.True(invalid["errors"]!.AsArray().Count >= 3, invalid.ToJsonString()); + Assert.Equal(AgentPermissionErrorCodes.CapExceeded, Code(overCap)); + Assert.Equal(AgentPermissionErrorCodes.NotOwner, Code(notOwn)); + Assert.Equal(ToolErrorCodes.ArgumentMissing, Code(noTarget)); + Assert.Equal(AgentPermissionErrorCodes.NotOwner, Code(submitPersons)); + + Assert.Equal(["category"], await agent.QueryAsync(context => context.ContentTypes.AsNoTracking().Select(row => row.Slug).ToListAsync())); + Assert.Single((await agent.AsPersonAsync(directory => directory.GetAsync(Guid.Parse(id)))).Operations); + Assert.Empty((await agent.AsPersonAsync(directory => directory.GetAsync(personsSet.Id))).Operations); + Assert.Contains( + await agent.QueryAsync(context => context.AuditEvents.AsNoTracking().Select(row => row.Operation).ToListAsync()), + operation => operation == "agent.items_per_change_set_exceeded"); + } + + [Fact] + public async Task A_revision_from_a_stale_version_is_refused_with_the_way_to_the_current_one() + { + await using var agent = await AgentAsync(DatabaseProviders.Sqlite); + await agent.CreateAsPersonAsync("product"); + await agent.AsPersonAsync(directory => directory.ReviseAsync( + "product", + 1, + new ContentTypeDefinition([new FieldDefinition("title", "text"), new FieldDefinition("summary", "text")]))); + + var stale = await agent.FailAsync("revise_content_type", new() + { + ["slug"] = "product", + ["base_version"] = 1, + ["definition"] = Title, + ["session_id"] = "product-work", + ["rationale"] = "Drop the summary.", + }); + + Assert.Equal(SchemaErrorCodes.VersionConflict, Code(stale)); + Assert.Contains("get_content_type", (string?)stale["hint"]); + Assert.Equal(2, (await agent.AsPersonAsync(directory => directory.GetAsync("product"))).Version); + } + + private static JsonObject Definition(params (string Name, string Type)[] fields) + { + return new JsonObject + { + ["fields"] = new JsonArray([.. fields.Select(field => new JsonObject { ["name"] = field.Name, ["type"] = field.Type })]), + }; + } + + private static string? Code(JsonObject failure) + { + return (string?)failure["errors"]![0]!["code"]; + } + + private static async Task AgentAsync(string provider, int itemsPerChangeSet = 200) + { + var install = new TemporaryInstall(); + var database = await TemporaryDatabase.CreateAsync(provider); + var host = await AdminHost.StartAsync(install, settings: database.Configuration); + var administrator = await host.CreateFirstAdministratorAsync(); + var issued = await McpAgents.IssueAsync(host, administrator, new NewAgentCredential( + "schema-agent", + DateTimeOffset.UtcNow.AddDays(30), + AgentScope.Create([Site.Default.Id], null, null, [AgentOperation.Read, AgentOperation.Create, AgentOperation.Update]).Value, + AgentCaps.Create(600, TimeSpan.FromHours(1), 5m, TimeSpan.FromDays(1), itemsPerChangeSet, 20).Value)); + var client = await McpAgents.ConnectOverHttpAsync(host, issued.Key); + return new ConnectedAgent(install, database, host, administrator, issued.Credential, client); + } + + private sealed class ConnectedAgent( + TemporaryInstall install, + TemporaryDatabase database, + AdminHost host, + Guid administrator, + AgentCredential credential, + McpClient client) : IAsyncDisposable + { + public AgentCredential Credential { get; } = credential; + + public async Task CallAsync(string tool, Dictionary arguments) + { + var result = await client.CallToolAsync(tool, arguments); + Assert.False(result.IsError ?? false, Text(result)); + return JsonNode.Parse(Text(result))!.AsObject(); + } + + public async Task FailAsync(string tool, Dictionary arguments) + { + var result = await client.CallToolAsync(tool, arguments); + Assert.True(result.IsError, Text(result)); + return JsonNode.Parse(Text(result))!.AsObject(); + } + + public async Task AsPersonAsync(Func>> action) + where TService : notnull + { + return (await host.AsUserAsync>(administrator, action)).Value; + } + + public Task CreateAsPersonAsync(string slug) + { + return AsPersonAsync(directory => + directory.CreateAsync(new NewContentType(slug, slug, new ContentTypeDefinition([new FieldDefinition("title", "text")])))); + } + + public async Task QueryAsync(Func> query) + { + await using var scope = host.App.Services.CreateAsyncScope(); + return await query(scope.ServiceProvider.GetRequiredService()); + } + + public async ValueTask DisposeAsync() + { + await client.DisposeAsync(); + await host.DisposeAsync(); + await database.DisposeAsync(); + install.Dispose(); + } + + private static string Text(CallToolResult result) + { + return Assert.IsType(Assert.Single(result.Content)).Text; + } + } +} diff --git a/tests/FluentCMS.Tests/Tools/ToolConventionsTests.cs b/tests/FluentCMS.Tests/Tools/ToolConventionsTests.cs index c89ef7116..8cbd0aba3 100644 --- a/tests/FluentCMS.Tests/Tools/ToolConventionsTests.cs +++ b/tests/FluentCMS.Tests/Tools/ToolConventionsTests.cs @@ -41,6 +41,27 @@ public void A_missing_malformed_or_unknown_argument_is_reported_by_name() errors.Select(error => (error.Code, error.Message))); } + [Fact] + public void Lists_objects_and_optional_ids_are_read_and_checked() + { + var good = Reader("""{"sources": ["a", "b"], "definition": {"fields": []}, "change_set_id": "0192f3a1-7c2e-7a4b-9d3e-1f2a3b4c5d6e", "base_version": 3}"""); + var bad = Reader("""{"sources": ["a", 1], "definition": [], "change_set_id": 7}"""); + + Assert.Equal(["a", "b"], good.OptionalStrings("sources", "a")); + Assert.Equal(JsonValueKind.Object, good.RequiredObject("definition", "an object")!.Value.ValueKind); + Assert.Equal(Guid.Parse("0192f3a1-7c2e-7a4b-9d3e-1f2a3b4c5d6e"), good.OptionalGuid("change_set_id")); + Assert.Equal(3, good.RequiredPositiveInteger("base_version")); + Assert.Empty(good.Errors()); + + Assert.Null(bad.OptionalStrings("sources", "a")); + Assert.Null(bad.RequiredObject("definition", "an object")); + Assert.Null(bad.OptionalGuid("change_set_id")); + Assert.Null(bad.RequiredPositiveInteger("base_version")); + Assert.Equal( + [ToolErrorCodes.ArgumentInvalid, ToolErrorCodes.ArgumentInvalid, ToolErrorCodes.ArgumentInvalid, ToolErrorCodes.ArgumentMissing], + bad.Errors().Select(error => error.Code)); + } + [Fact] public void A_tool_without_arguments_accepts_none_and_null_and_refuses_a_value_that_is_not_an_object() { diff --git a/tests/FluentCMS.Tests/Tools/ToolProviderConformanceTests.cs b/tests/FluentCMS.Tests/Tools/ToolProviderConformanceTests.cs index 6b110c7df..47b0fb713 100644 --- a/tests/FluentCMS.Tests/Tools/ToolProviderConformanceTests.cs +++ b/tests/FluentCMS.Tests/Tools/ToolProviderConformanceTests.cs @@ -69,7 +69,7 @@ public async Task An_unknown_argument_is_an_error_before_any_service_is_read() foreach (var tool in Tools) { - var result = await tool.InvokeAsync(new ToolCall(arguments, new NoServices())); + var result = await tool.InvokeAsync(new ToolInvocation(arguments, new NoServices())); Assert.False(result.IsSuccess, tool.Name); Assert.Contains(result.Errors, error => error.Code == ToolErrorCodes.ArgumentUnknown && error.Message.Contains("no_such_argument"));