diff --git a/src/FluentCMS.Core/Builtin/Tools/EntryTools.cs b/src/FluentCMS.Core/Builtin/Tools/EntryTools.cs new file mode 100644 index 000000000..a79eb1f72 --- /dev/null +++ b/src/FluentCMS.Core/Builtin/Tools/EntryTools.cs @@ -0,0 +1,353 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using FluentCMS.Core.ChangeSets; +using FluentCMS.Core.Content; +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; + +// Entries (CNT-01 to CNT-05, MCP-03): reading them as stored, resolved and in use, and proposing creates, updates and unpublishing, +// which go into a change set a person reviews. Entities are entries of entity types, so their fields are facts. +public sealed class EntryTools : IToolProvider +{ + private const string FindEntries = "Call list_entries with the content type's slug for the ids of its entries."; + private const string ReadSchema = "Call get_schema_document with the content type's slug for its fields and the values each one takes."; + + public IReadOnlyList Tools { get; } = + [ + new AgentTool( + "list_entries", + "List entries", + "Lists the entries of one content type, oldest first, with each one's id, state (draft, in_review, published, reverted " + + "or archived), latest version and published version. Read one with get_entry.", + ToolSchemas.Object(ContentTypeArgument), + readOnly: true, + ListAsync), + new AgentTool( + "get_entry", + "Get an entry", + "Returns one entry with its state, its latest and published versions and the fields of its latest draft, or of the " + + "version you name. A reference field holds the id of the entry or entity it points at; get_resolved_entry shows the " + + "values instead. Update from latestVersion.", + ToolSchemas.Object( + IdArgument, + ToolSchemas.PositiveInteger("version", "A version to read instead of the latest draft.", 1, required: false)), + readOnly: true, + GetAsync), + new AgentTool( + "get_entry_history", + "Get an entry's history", + "Lists every version of an entry, oldest first, with the content type version it was written against and when. " + + "Versions are never overwritten, so any of them can be read with get_entry.", + ToolSchemas.Object(IdArgument), + readOnly: true, + HistoryAsync), + new AgentTool( + "get_resolved_entry", + "Get an entry with its facts resolved", + "Returns a version of an entry, the latest draft unless you name one, with every reference to an entity field replaced " + + "by that entity's current published value, such as a product's price on a product page. A reference that cannot " + + "be resolved holds null and is listed under unresolved with its reason.", + ToolSchemas.Object( + IdArgument, + ToolSchemas.PositiveInteger("version", "A version to resolve instead of the latest draft.", 1, required: false)), + readOnly: true, + ResolvedAsync), + new AgentTool( + "get_where_used", + "Find where something is used", + "Lists the entries whose latest draft or published version references an entry or entity, one field of an entity " + + "(give entry_id and field, such as a product's price), or a media asset (give asset_id), with the path of each " + + "reference. Call it before changing a fact to see which pages show it.", + ToolSchemas.Object( + ToolSchemas.Uuid("entry_id", "The entry or entity to look for. Give this or asset_id.", required: false), + ToolSchemas.String("field", "With entry_id: one field of the entity, to find only references to that field.", "price", required: false), + ToolSchemas.Uuid("asset_id", "The media asset to look for. Give this or entry_id.", required: false)), + readOnly: true, + WhereUsedAsync), + new AgentTool( + "create_entry", + "Propose a new entry", + "Proposes a new entry of a content type: it stores version 1 as a draft 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 fields comes back " + + "at once, by field; get_schema_document shows what each field takes. A reference field takes the id of an entry or entity.", + ToolSchemas.Object([ContentTypeArgument, FieldsArgument, .. ChangeSetTarget.Arguments]), + readOnly: false, + CreateAsync), + new AgentTool( + "update_entry", + "Propose an update of an entry", + "Proposes new fields for an entry from the version you read, given as base_version: it stores the next version as a " + + "draft and adds the update to a change set in the same call. The fields replace the whole previous version, so give " + + "every field to keep. If someone updated the entry since you read it, the call is refused; read it again with get_entry.", + ToolSchemas.Object([ + IdArgument, + ToolSchemas.PositiveInteger("base_version", "The latest version you read, as get_entry returned it.", 1), + FieldsArgument, + .. ChangeSetTarget.Arguments]), + readOnly: false, + UpdateAsync), + new AgentTool( + "delete_entry", + "Propose unpublishing an entry", + "Proposes taking a published entry offline: it adds a delete to a change set, and publishing the set unpublishes the " + + "entry and archives it. Its versions stay, so the change can be reverted. Call get_where_used first to see which " + + "entries reference it.", + ToolSchemas.Object([IdArgument, .. ChangeSetTarget.Arguments]), + readOnly: false, + DeleteAsync), + ]; + + private static ToolArgument ContentTypeArgument { get; } = + ToolSchemas.String("content_type", "The content type's slug, as list_content_types shows it.", "product-page"); + + private static ToolArgument IdArgument { get; } = ToolSchemas.Uuid("id", "The entry's id, as list_entries or create_entry returned it."); + + private static ToolArgument FieldsArgument { get; } = ToolSchemas.Json( + "fields", + "The entry's fields, one property per field of the content type; get_schema_document shows each field's values and an " + + "example entry. On an entity type every value is a fact with its source, owner and validity.", + new JsonObject { ["type"] = "object" }, + JsonNode.Parse("""{"headline": "The Widget", "price": "0199a000-0000-7000-8000-000000000001"}""")!); + + private static async Task ListAsync(ToolInvocation call, CancellationToken cancellationToken) + { + var arguments = new ToolArgumentsReader(call.Arguments); + var contentType = arguments.RequiredString("content_type", "product-page"); + if (arguments.Errors() is { Count: > 0 } errors) + { + return ToolResult.Failure(errors); + } + + var entries = await call.Services.GetRequiredService().ListAsync(contentType!, cancellationToken); + return ToolResult.From(entries, list => new JsonObject { ["entries"] = new JsonArray([.. list.Select(ToolJson.EntrySummary)]) }, Hint(entries.Errors)); + } + + private static async Task GetAsync(ToolInvocation call, CancellationToken cancellationToken) + { + var arguments = new ToolArgumentsReader(call.Arguments); + var id = arguments.RequiredGuid("id"); + var version = arguments.OptionalPositiveInteger("version"); + if (arguments.Errors() is { Count: > 0 } errors) + { + return ToolResult.Failure(errors); + } + + var directory = call.Services.GetRequiredService(); + var entry = await directory.GetAsync(id!.Value, cancellationToken); + if (!entry.IsSuccess || version is not { } number) + { + return ToolResult.From(entry, read => ToolJson.Entry(read), Hint(entry.Errors)); + } + + var stored = await directory.GetVersionAsync(id.Value, number, cancellationToken); + return ToolResult.From(stored, read => ToolJson.Entry(entry.Value, read), "Leave out the version to read the latest draft."); + } + + private static async Task HistoryAsync(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 history = await call.Services.GetRequiredService().HistoryAsync(id!.Value, cancellationToken); + return ToolResult.From(history, versions => new JsonObject { ["versions"] = new JsonArray([.. versions.Select(ToolJson.EntryVersion)]) }, Hint(history.Errors)); + } + + private static async Task ResolvedAsync(ToolInvocation call, CancellationToken cancellationToken) + { + var arguments = new ToolArgumentsReader(call.Arguments); + var id = arguments.RequiredGuid("id"); + var version = arguments.OptionalPositiveInteger("version"); + if (arguments.Errors() is { Count: > 0 } errors) + { + return ToolResult.Failure(errors); + } + + var resolved = await call.Services.GetRequiredService().ResolveAsync(id!.Value, version, cancellationToken); + return ToolResult.From(resolved, ToolJson.Resolved, Hint(resolved.Errors)); + } + + private static async Task WhereUsedAsync(ToolInvocation call, CancellationToken cancellationToken) + { + var arguments = new ToolArgumentsReader(call.Arguments); + var entryId = arguments.OptionalGuid("entry_id"); + var field = arguments.OptionalString("field", "price"); + var assetId = arguments.OptionalGuid("asset_id"); + var errors = arguments.Errors().ToList(); + + if (errors.Count == 0 && (entryId is null) == (assetId is null)) + { + errors.Add(new Error(ToolErrorCodes.ArgumentMissing, "Give either 'entry_id' or 'asset_id', not both.")); + } + else if (errors.Count == 0 && field is not null && entryId is null) + { + errors.Add(new Error(ToolErrorCodes.ArgumentInvalid, "'field' names a field of the entity given as 'entry_id', so it needs 'entry_id'.")); + } + + if (errors.Count > 0) + { + return ToolResult.Failure(errors); + } + + var whereUsed = call.Services.GetRequiredService(); + var usages = entryId is { } id + ? field is null + ? await whereUsed.ForEntryAsync(id, cancellationToken) + : await whereUsed.ForEntityFieldAsync(id, field, cancellationToken) + : Result>.Success(await whereUsed.ForMediaAsync(assetId!.Value, cancellationToken)); + + return ToolResult.From(usages, list => new JsonObject { ["usages"] = new JsonArray([.. list.Select(ToolJson.Usage)]) }, Hint(usages.Errors)); + } + + private static async Task CreateAsync(ToolInvocation call, CancellationToken cancellationToken) + { + var arguments = new ToolArgumentsReader(call.Arguments); + var contentType = arguments.RequiredString("content_type", "product-page"); + var fields = arguments.RequiredObject("fields", FieldsForm); + var errors = new List(); + var target = ChangeSetTarget.Read(arguments, errors); + errors.InsertRange(0, arguments.Errors()); + if (errors.Count > 0) + { + return ToolResult.Failure(errors); + } + + return await ProposeAsync( + call, + target!, + entries => entries.CreateAsync(new NewEntry(contentType!, fields!.Value), cancellationToken), + (entry, kind) => ChangeOperation.Create(OperationKind.Create, kind, entry.Id, null, entry.LatestVersion), + cancellationToken); + } + + // The operation is based on what is published, which is what publishing it replaces; a draft never published is still a create. + private static async Task UpdateAsync(ToolInvocation call, CancellationToken cancellationToken) + { + var arguments = new ToolArgumentsReader(call.Arguments); + var id = arguments.RequiredGuid("id"); + var baseVersion = arguments.RequiredPositiveInteger("base_version"); + var fields = arguments.RequiredObject("fields", FieldsForm); + var errors = new List(); + var target = ChangeSetTarget.Read(arguments, errors); + errors.InsertRange(0, arguments.Errors()); + if (errors.Count > 0) + { + return ToolResult.Failure(errors); + } + + return await ProposeAsync( + call, + target!, + entries => entries.UpdateAsync(id!.Value, baseVersion!.Value, fields!.Value, cancellationToken), + (entry, kind) => entry.PublishedVersion is { } published + ? ChangeOperation.Create(OperationKind.Update, kind, entry.Id, published, entry.LatestVersion) + : ChangeOperation.Create(OperationKind.Create, kind, entry.Id, null, entry.LatestVersion), + cancellationToken); + } + + private static async Task DeleteAsync(ToolInvocation call, CancellationToken cancellationToken) + { + var arguments = new ToolArgumentsReader(call.Arguments); + var id = arguments.RequiredGuid("id"); + var errors = new List(); + var target = ChangeSetTarget.Read(arguments, errors); + errors.InsertRange(0, arguments.Errors()); + if (errors.Count > 0) + { + return ToolResult.Failure(errors); + } + + return await ProposeAsync( + call, + target!, + entries => entries.GetAsync(id!.Value, cancellationToken), + (entry, kind) => entry.PublishedVersion is { } published + ? ChangeOperation.Create(OperationKind.Delete, kind, entry.Id, published, null) + : Result.Failure( + ContentErrorCodes.EntryNotPublished, + $"Entry {entry.Id} is not published, so there is nothing to take offline."), + cancellationToken); + } + + // The version an agent writes and its operation in the change set commit together or not at all, as for content types, so no + // version an agent writes exists outside a change set a person reviews (AUD-N1, CHG-01). + private static async Task ProposeAsync( + 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 them all. + await using var scope = await services.GetRequiredService().BeginAsync(cancellationToken); + + var entry = await write(services.GetRequiredService()); + if (!entry.IsSuccess) + { + return ToolResult.Failure(entry.Errors, Hint(entry.Errors)); + } + + // Types are never deleted, but reading one is scope-checked, so an agent that may write a type's entries without reading it is refused here. + var type = await services.GetRequiredService().GetAsync(entry.Value.ContentType, cancellationToken); + if (!type.IsSuccess) + { + return ToolResult.Failure(type.Errors); + } + + var kind = type.Value.Definition.IsEntity ? ItemKind.Entity : ItemKind.Entry; + var proposed = operation(entry.Value, kind); + if (!proposed.IsSuccess) + { + return ToolResult.Failure(proposed.Errors, Hint(proposed.Errors)); + } + + var set = await target.AddAsync(services.GetRequiredService(), proposed.Value, cancellationToken); + if (!set.IsSuccess) + { + return ToolResult.Failure(set.Errors); + } + + await scope.CommitAsync(cancellationToken); + + return ToolResult.Success(new JsonObject + { + ["entry"] = ToolJson.Entry(entry.Value), + ["changeSet"] = ToolJson.ChangeSet(set.Value), + ["next"] = $"The {ToolJson.Name(proposed.Value.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 const string FieldsForm = "an object with one property per field, such as {\"headline\": \"The Widget\"}"; + + private static string? Hint(IReadOnlyList errors) + { + return errors.FirstOrDefault()?.Code switch + { + ContentErrorCodes.VersionConflict => "Call get_entry for the latest version and its fields, then update from that version.", + ContentErrorCodes.EntryNotFound => FindEntries, + ContentErrorCodes.VersionNotFound => "Call get_entry_history for the versions the entry has.", + ContentErrorCodes.FieldsMalformed or ContentErrorCodes.FieldUnknown or ContentErrorCodes.FieldRequired or SchemaErrorCodes.ValueInvalid => ReadSchema, + ContentErrorCodes.ReferenceNotFound or ContentErrorCodes.ReferenceTypeMismatch => + "Call list_entries with the referenced content type's slug for the ids a reference field can take.", + ContentErrorCodes.EntryNotPublished => "Only a published entry can be taken offline; get_entry shows its publishedVersion.", + SchemaErrorCodes.ContentTypeNotFound => "Call list_content_types for the slugs of the site's content types.", + _ => null, + }; + } +} diff --git a/src/FluentCMS.Core/Builtin/Tools/ToolJson.cs b/src/FluentCMS.Core/Builtin/Tools/ToolJson.cs index d31cf9b11..4fb46c413 100644 --- a/src/FluentCMS.Core/Builtin/Tools/ToolJson.cs +++ b/src/FluentCMS.Core/Builtin/Tools/ToolJson.cs @@ -1,6 +1,7 @@ using System.Text.Json; using System.Text.Json.Nodes; using FluentCMS.Core.ChangeSets; +using FluentCMS.Core.Content; using FluentCMS.Core.Identity; using FluentCMS.Core.Schema; @@ -88,6 +89,76 @@ public static JsonObject Diff(ChangeSetDiff diff) return new JsonObject { ["changeSetId"] = diff.ChangeSetId.ToString(), ["items"] = items }; } + public static JsonObject EntrySummary(EntrySummary entry) + { + return new JsonObject + { + ["id"] = entry.Id.ToString(), + ["state"] = Name(entry.State), + ["latestVersion"] = entry.LatestVersion, + ["publishedVersion"] = entry.PublishedVersion, + }; + } + + // The entry with the fields of the version given, its latest draft unless another is named. + public static JsonObject Entry(Entry entry, EntryVersion? version = null) + { + var shown = version ?? entry.Latest; + + return new JsonObject + { + ["id"] = entry.Id.ToString(), + ["contentType"] = entry.ContentType, + ["state"] = Name(entry.State), + ["latestVersion"] = entry.LatestVersion, + ["publishedVersion"] = entry.PublishedVersion, + ["version"] = shown.Version, + ["contentTypeVersion"] = shown.ContentTypeVersion, + ["fields"] = Node(shown.Fields), + }; + } + + public static JsonObject EntryVersion(EntryVersionSummary version) + { + return new JsonObject + { + ["version"] = version.Version, + ["contentTypeVersion"] = version.ContentTypeVersion, + ["createdAt"] = version.CreatedAt.ToString("O", System.Globalization.CultureInfo.InvariantCulture), + }; + } + + public static JsonObject Resolved(ResolvedEntry resolved) + { + return new JsonObject + { + ["entryId"] = resolved.EntryId.ToString(), + ["contentType"] = resolved.ContentType, + ["version"] = resolved.Version, + ["fields"] = Node(resolved.Fields), + ["unresolved"] = new JsonArray([.. resolved.Unresolved.Select(reference => new JsonObject + { + ["path"] = reference.Path, + ["entityId"] = reference.EntityId.ToString(), + ["field"] = reference.Field, + ["reason"] = Name(reference.Reason), + })]), + }; + } + + public static JsonObject Usage(EntryUsage usage) + { + return new JsonObject + { + ["entryId"] = usage.EntryId.ToString(), + ["contentType"] = usage.ContentType, + ["version"] = usage.Version, + ["isLatest"] = usage.IsLatest, + ["isPublished"] = usage.IsPublished, + ["paths"] = new JsonArray([.. usage.Paths.Select(path => JsonValue.Create(path))]), + }; + } + private static JsonObject Operation(ChangeOperation operation) { return new JsonObject diff --git a/src/FluentCMS.Core/Content/ContentErrorCodes.cs b/src/FluentCMS.Core/Content/ContentErrorCodes.cs index 15e36c7b6..9cbe6c644 100644 --- a/src/FluentCMS.Core/Content/ContentErrorCodes.cs +++ b/src/FluentCMS.Core/Content/ContentErrorCodes.cs @@ -13,4 +13,5 @@ public static class ContentErrorCodes public const string EntryNotEntity = "entry_not_entity"; public const string EntryNotDeleted = "entry_not_deleted"; public const string RetentionExpired = "retention_expired"; + public const string EntryNotPublished = "entry_not_published"; } diff --git a/tests/FluentCMS.IntegrationTests/Mcp/ConnectedAgent.cs b/tests/FluentCMS.IntegrationTests/Mcp/ConnectedAgent.cs new file mode 100644 index 000000000..6c322813d --- /dev/null +++ b/tests/FluentCMS.IntegrationTests/Mcp/ConnectedAgent.cs @@ -0,0 +1,87 @@ +using System.Text.Json.Nodes; +using FluentCMS.Core.Agents; +using FluentCMS.Core.Schema; +using FluentCMS.Core.Sites; +using FluentCMS.Infrastructure.Persistence; +using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; + +namespace FluentCMS.IntegrationTests.Mcp; + +// An agent connected over HTTP to a running host with its own database, and the administrator who issued its credential. +internal sealed class ConnectedAgent( + TemporaryInstall install, + TemporaryDatabase database, + AdminHost host, + Guid administrator, + AgentCredential credential, + McpClient client) : IAsyncDisposable +{ + public AgentCredential Credential { get; } = credential; + + // Every type unless types narrows the scope; reads, creates and updates unless operations says otherwise. + public static async Task StartAsync( + string provider, + int itemsPerChangeSet = 200, + IReadOnlyList? types = null, + AgentOperation[]? operations = null) + { + 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], types, null, operations ?? [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); + } + + 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.IntegrationTests/Mcp/EntryToolsTests.cs b/tests/FluentCMS.IntegrationTests/Mcp/EntryToolsTests.cs new file mode 100644 index 000000000..fadfc7014 --- /dev/null +++ b/tests/FluentCMS.IntegrationTests/Mcp/EntryToolsTests.cs @@ -0,0 +1,200 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using FluentCMS.Core.Agents; +using FluentCMS.Core.ChangeSets; +using FluentCMS.Core.Content; +using FluentCMS.Core.Schema; +using FluentCMS.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; + +namespace FluentCMS.IntegrationTests.Mcp; + +public sealed class EntryToolsTests +{ + // S2 in the F-CNT acceptance, end to end over MCP. + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task An_agent_proposes_a_product_and_a_page_that_shows_its_price_and_reads_them_once_published(string provider) + { + await using var agent = await ConnectedAgent.StartAsync(provider); + await CatalogAsync(agent); + + var opened = await agent.CallAsync("open_change_set", new() { ["rationale"] = "Launch the Widget.", ["sources"] = new[] { "https://example.com/widget" } }); + var setId = (string)opened["changeSet"]!["id"]!; + var product = await agent.CallAsync("create_entry", new() { ["content_type"] = "product", ["fields"] = Product("19.99"), ["change_set_id"] = setId }); + var productId = (string)product["entry"]!["id"]!; + var page = await agent.CallAsync("create_entry", new() + { + ["content_type"] = "product-page", + ["fields"] = new JsonObject { ["headline"] = "The Widget", ["price"] = productId }, + ["change_set_id"] = setId, + }); + var pageId = (string)page["entry"]!["id"]!; + await agent.CallAsync("submit_change_set", new() { ["id"] = setId }); + + Assert.Equal(["entity", "entry"], page["changeSet"]!["operations"]!.AsArray().Select(operation => (string?)operation!["itemKind"])); + Assert.Contains("submit_change_set", (string?)page["next"]); + Assert.Equal("in_review", (string?)(await agent.CallAsync("get_entry", new() { ["id"] = pageId }))["state"]); + + await PublishAsPersonAsync(agent, setId); + + var resolved = await agent.CallAsync("get_resolved_entry", new() { ["id"] = pageId }); + var usages = await agent.CallAsync("get_where_used", new() { ["entry_id"] = productId, ["field"] = "price" }); + var listed = await agent.CallAsync("list_entries", new() { ["content_type"] = "product-page" }); + var stored = await agent.CallAsync("get_entry", new() { ["id"] = pageId }); + var history = await agent.CallAsync("get_entry_history", new() { ["id"] = pageId }); + + Assert.Equal(19.99m, (decimal)resolved["fields"]!["price"]!); + Assert.Empty(resolved["unresolved"]!.AsArray()); + var usage = Assert.Single(usages["usages"]!.AsArray())!; + Assert.Equal(pageId, (string?)usage["entryId"]); + Assert.Equal(["/price"], usage["paths"]!.AsArray().Select(path => (string?)path)); + Assert.Equal("published", (string?)Assert.Single(listed["entries"]!.AsArray())!["state"]); + Assert.Equal(productId, (string?)stored["fields"]!["price"]); + Assert.Equal(1, (int)stored["publishedVersion"]!); + Assert.Equal([1], history["versions"]!.AsArray().Select(version => (int)version!["version"]!)); + } + + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task An_update_is_based_on_the_published_version_and_a_refused_one_stores_nothing(string provider) + { + await using var agent = await ConnectedAgent.StartAsync(provider); + await CatalogAsync(agent); + var setId = (string)(await agent.CallAsync("open_change_set", new() { ["rationale"] = "Launch." }))["changeSet"]!["id"]!; + var productId = (string)(await agent.CallAsync("create_entry", new() { ["content_type"] = "product", ["fields"] = Product("19.99"), ["change_set_id"] = setId }))["entry"]!["id"]!; + await agent.CallAsync("submit_change_set", new() { ["id"] = setId }); + await PublishAsPersonAsync(agent, setId); + + var update = await agent.CallAsync("update_entry", new() + { + ["id"] = productId, + ["base_version"] = 1, + ["fields"] = Product("24.99"), + ["session_id"] = "price-update", + ["rationale"] = "New price list.", + }); + var stale = await agent.FailAsync("update_entry", new() + { + ["id"] = productId, + ["base_version"] = 1, + ["fields"] = Product("29.99"), + ["session_id"] = "price-update-2", + ["rationale"] = "Another price.", + }); + var invalid = await agent.FailAsync("update_entry", new() + { + ["id"] = productId, + ["base_version"] = 2, + ["fields"] = new JsonObject { ["nmae"] = "Typo", ["price"] = "free" }, + ["session_id"] = "price-update-3", + ["rationale"] = "Broken fields.", + }); + + var operation = Assert.Single(update["changeSet"]!["operations"]!.AsArray())!; + Assert.Equal(("update", "entity", 1, 2), ((string?)operation["kind"], (string?)operation["itemKind"], (int?)operation["baseVersion"], (int?)operation["proposedVersion"])); + Assert.Equal(ContentErrorCodes.VersionConflict, Code(stale)); + Assert.Contains("get_entry", (string?)stale["hint"]); + Assert.True(invalid["errors"]!.AsArray().Count >= 2, invalid.ToJsonString()); + Assert.Contains("get_schema_document", (string?)invalid["hint"]); + Assert.Equal(2, await agent.QueryAsync(context => context.EntryVersions.CountAsync())); + Assert.Equal(2, await agent.QueryAsync(context => context.ChangeSets.CountAsync())); + } + + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task Delete_proposes_unpublishing_a_published_entry_only(string provider) + { + await using var agent = await ConnectedAgent.StartAsync( + provider, + operations: [AgentOperation.Read, AgentOperation.Create, AgentOperation.Update, AgentOperation.Delete]); + await CatalogAsync(agent); + var setId = (string)(await agent.CallAsync("open_change_set", new() { ["rationale"] = "Launch." }))["changeSet"]!["id"]!; + var productId = (string)(await agent.CallAsync("create_entry", new() { ["content_type"] = "product", ["fields"] = Product("19.99"), ["change_set_id"] = setId }))["entry"]!["id"]!; + + var unpublished = await agent.FailAsync("delete_entry", new() { ["id"] = productId, ["session_id"] = "retire", ["rationale"] = "Retire the Widget." }); + Assert.Equal(ContentErrorCodes.EntryNotPublished, Code(unpublished)); + + await agent.CallAsync("submit_change_set", new() { ["id"] = setId }); + await PublishAsPersonAsync(agent, setId); + var delete = await agent.CallAsync("delete_entry", new() { ["id"] = productId, ["session_id"] = "retire", ["rationale"] = "Retire the Widget." }); + + var operation = Assert.Single(delete["changeSet"]!["operations"]!.AsArray())!; + Assert.Equal(("delete", 1), ((string?)operation["kind"], (int?)operation["baseVersion"])); + Assert.Null(operation["proposedVersion"]); + } + + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task An_agent_reads_resolves_and_finds_uses_only_within_its_scope(string provider) + { + await using var agent = await ConnectedAgent.StartAsync(provider, types: ["product"]); + await CatalogAsync(agent); + var product = await agent.AsPersonAsync(entries => entries.CreateAsync(new NewEntry("product", Json(Product("19.99").ToJsonString())))); + var page = await agent.AsPersonAsync(entries => + entries.CreateAsync(new NewEntry("product-page", Json($$"""{"headline": "The Widget", "price": "{{product.Id}}"}""")))); + + var get = await agent.FailAsync("get_entry", new() { ["id"] = page.Id.ToString() }); + var resolve = await agent.FailAsync("get_resolved_entry", new() { ["id"] = page.Id.ToString() }); + var usages = await agent.CallAsync("get_where_used", new() { ["entry_id"] = product.Id.ToString(), ["field"] = "price" }); + var neither = await agent.FailAsync("get_where_used", new() { ["field"] = "price" }); + + Assert.Equal(AgentPermissionErrorCodes.ScopeDenied, Code(get)); + Assert.Equal(AgentPermissionErrorCodes.ScopeDenied, Code(resolve)); + Assert.Empty(usages["usages"]!.AsArray()); + Assert.Equal("argument_missing", Code(neither)); + } + + // A product entity and a product page whose price shows the product's, defined by a person. + private static async Task CatalogAsync(ConnectedAgent agent) + { + await agent.AsPersonAsync(directory => directory.CreateAsync(new NewContentType( + "Product", + "product", + new ContentTypeDefinition([new FieldDefinition("name", "text", required: true), new FieldDefinition("price", "number")], isEntity: true)))); + await agent.AsPersonAsync(directory => directory.CreateAsync(new NewContentType( + "Product page", + "product-page", + new ContentTypeDefinition( + [ + new FieldDefinition("headline", "text"), + new FieldDefinition("price", "reference", settings: Json("""{"target": "product", "field": "price"}""")), + ])))); + } + + private static async Task PublishAsPersonAsync(ConnectedAgent agent, string setId) + { + var id = Guid.Parse(setId); + await agent.AsPersonAsync(directory => directory.TransitionAsync(id, ChangeSetState.Approved)); + await agent.AsPersonAsync(directory => directory.PublishAsync(id)); + } + + private static JsonObject Product(string price) + { + static JsonObject Fact(JsonNode? value) => new() + { + ["value"] = value, + ["source"] = null, + ["owner"] = null, + ["validFrom"] = null, + ["validUntil"] = null, + ["lastVerified"] = null, + }; + + return new JsonObject { ["name"] = Fact("Widget"), ["price"] = Fact(JsonNode.Parse(price)) }; + } + + private static string? Code(JsonObject failure) + { + return (string?)failure["errors"]![0]!["code"]; + } + + private static JsonElement Json(string json) + { + return JsonSerializer.Deserialize(json); + } +} diff --git a/tests/FluentCMS.IntegrationTests/Mcp/McpAgents.cs b/tests/FluentCMS.IntegrationTests/Mcp/McpAgents.cs index 2be354156..e802125db 100644 --- a/tests/FluentCMS.IntegrationTests/Mcp/McpAgents.cs +++ b/tests/FluentCMS.IntegrationTests/Mcp/McpAgents.cs @@ -21,8 +21,9 @@ internal static class McpAgents // The built-in tools every server lists, sorted by name. 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", + "create_content_type", "create_entry", "delete_entry", "get_change_set", "get_change_set_diff", "get_content_type", "get_entry", + "get_entry_history", "get_resolved_entry", "get_schema_document", "get_where_used", "list_content_types", "list_entries", + "open_change_set", "reopen_change_set", "revise_content_type", "submit_change_set", "update_entry", ]; public static async Task ToolNamesAsync(McpClient client) diff --git a/tests/FluentCMS.IntegrationTests/Mcp/WriteToolsTests.cs b/tests/FluentCMS.IntegrationTests/Mcp/WriteToolsTests.cs index 162b52b30..cb109ac24 100644 --- a/tests/FluentCMS.IntegrationTests/Mcp/WriteToolsTests.cs +++ b/tests/FluentCMS.IntegrationTests/Mcp/WriteToolsTests.cs @@ -173,74 +173,8 @@ private static JsonObject Definition(params (string Name, string Type)[] fields) return (string?)failure["errors"]![0]!["code"]; } - private static async Task AgentAsync(string provider, int itemsPerChangeSet = 200) + private static 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; - } + return ConnectedAgent.StartAsync(provider, itemsPerChangeSet); } } diff --git a/tests/FluentCMS.Tests/Tools/McpCoverageTests.cs b/tests/FluentCMS.Tests/Tools/McpCoverageTests.cs index d18d83843..519b11132 100644 --- a/tests/FluentCMS.Tests/Tools/McpCoverageTests.cs +++ b/tests/FluentCMS.Tests/Tools/McpCoverageTests.cs @@ -20,7 +20,11 @@ public sealed class McpCoverageTests new("CHG", "Read a change set's diff", ["get_change_set_diff"]), new("CHG", "Submit a change set for checks and review", ["submit_change_set"]), new("CHG", "Reopen a change set after failed checks", ["reopen_change_set"]), - Missing("CNT", "Propose, read and list entries", "#2416"), + new("CNT", "List entries of a content type", ["list_entries"]), + new("CNT", "Read an entry at any version, and its history", ["get_entry", "get_entry_history"]), + new("CNT", "Read an entry with its facts resolved", ["get_resolved_entry"]), + new("CNT", "Find where an entry, entity field or media asset is used", ["get_where_used"]), + new("CNT", "Propose a new entry, an update or unpublishing", ["create_entry", "update_entry", "delete_entry"]), Missing("ENT", "Propose, read and list entities and their facts", "#2417"), Missing("RUL", "Read and propose rules", "#2419"), Missing("CHK", "Read the check results of a change set", "#2420"), diff --git a/tests/FluentCMS.Tests/Tools/ToolProviderConformanceTests.cs b/tests/FluentCMS.Tests/Tools/ToolProviderConformanceTests.cs index 47b0fb713..88d593f76 100644 --- a/tests/FluentCMS.Tests/Tools/ToolProviderConformanceTests.cs +++ b/tests/FluentCMS.Tests/Tools/ToolProviderConformanceTests.cs @@ -109,3 +109,8 @@ public sealed class ChangeSetToolsConformanceTests : ToolProviderConformanceTest { protected override object Provider { get; } = new ChangeSetTools(); } + +public sealed class EntryToolsConformanceTests : ToolProviderConformanceTests +{ + protected override object Provider { get; } = new EntryTools(); +}