From e2b2ac8058022f0d587b62df338ad6a7400b81c1 Mon Sep 17 00:00:00 2001 From: Amir Pournasserian Date: Sun, 18 Oct 2026 21:07:11 -0400 Subject: [PATCH 1/2] Add the entity tools --- .../Builtin/Tools/EntityTools.cs | 322 ++++++++++++++++++ .../Builtin/Tools/EntryTools.cs | 98 +----- .../Builtin/Tools/ProposedEntryWrite.cs | 100 ++++++ src/FluentCMS.Core/Builtin/Tools/ToolJson.cs | 80 +++++ src/FluentCMS.Core/Entities/IEntityFacts.cs | 1 + .../Tools/ToolArgumentsReader.cs | 28 ++ .../Mcp/EntityToolsTests.cs | 204 +++++++++++ .../Mcp/McpAgents.cs | 7 +- .../FluentCMS.Tests/Tools/McpCoverageTests.cs | 7 +- .../Tools/ToolProviderConformanceTests.cs | 5 + 10 files changed, 759 insertions(+), 93 deletions(-) create mode 100644 src/FluentCMS.Core/Builtin/Tools/EntityTools.cs create mode 100644 src/FluentCMS.Core/Builtin/Tools/ProposedEntryWrite.cs create mode 100644 tests/FluentCMS.IntegrationTests/Mcp/EntityToolsTests.cs diff --git a/src/FluentCMS.Core/Builtin/Tools/EntityTools.cs b/src/FluentCMS.Core/Builtin/Tools/EntityTools.cs new file mode 100644 index 000000000..3b5db765a --- /dev/null +++ b/src/FluentCMS.Core/Builtin/Tools/EntityTools.cs @@ -0,0 +1,322 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using FluentCMS.Core.Content; +using FluentCMS.Core.Entities; +using FluentCMS.Core.Results; +using FluentCMS.Core.Schema; +using FluentCMS.Core.Tools; +using Microsoft.Extensions.DependencyInjection; + +namespace FluentCMS.Core.Builtin.Tools; + +// Entities (ENT-01, ENT-02, ENT-04, MCP-03): their facts with sources and flags, their relations, the blast radius of changing them, +// and fact-level writes that go into a change set a person reviews. Whole entities are written with create_entry and update_entry. +public sealed class EntityTools : IToolProvider +{ + private const string TimeExample = "2026-01-31T09:30:00Z"; + + public IReadOnlyList Tools { get; } = + [ + new AgentTool( + "get_entity_facts", + "Get an entity's facts", + "Returns the facts of an entity's latest draft, or of the version you name: each field's source, owner, validity and " + + "whether it is unsourced. Values are in get_entry. A fact without a source is flagged, and routing may hold it back.", + ToolSchemas.Object( + IdArgument, + ToolSchemas.PositiveInteger("version", "A version to read instead of the latest draft.", 1, required: false)), + readOnly: true, + FactsAsync), + new AgentTool( + "get_entity_relations", + "Get an entity's relations", + "Lists the entities this one relates to through its reference fields, and the entities that relate to it, with the field " + + "that holds each relation and whether the latest draft or the published version holds it. Pages that show the entity " + + "are uses, not relations: get_where_used lists them.", + ToolSchemas.Object(IdArgument), + readOnly: true, + RelationsAsync), + new AgentTool( + "list_unsourced_facts", + "List unsourced facts", + "Lists the facts without a source in the entities' latest drafts and published versions, of every entity type or of the " + + "one you name. Give each a source with set_entity_fact.", + ToolSchemas.Object(ToolSchemas.String("content_type", "An entity type's slug, to list only its facts.", "product", required: false)), + readOnly: true, + UnsourcedAsync), + new AgentTool( + "get_blast_radius", + "Get a change set's blast radius", + "Lists the published entries that show a fact the change set's entity changes change, such as every product page that " + + "shows a changed price, each with the old and new value at its path. Read it before submitting a set of fact changes.", + ToolSchemas.Object(ToolSchemas.Uuid("change_set_id", "The change set, as open_change_set or a write tool returned it.")), + readOnly: true, + BlastRadiusAsync), + new AgentTool( + "set_entity_fact", + "Propose one fact of an entity", + "Proposes a new value for one field of an entity, with its source, owner and validity, from the version you read as " + + "base_version; the other facts stay as they are. It stores the next version and adds the change to a change set in " + + "the same call. Every fact property you leave out is stored as null, and a fact without a source is flagged unsourced.", + ToolSchemas.Object([ + IdArgument, + BaseVersionArgument, + ToolSchemas.String("field", "The entity field to set, as get_content_type lists it.", "price"), + ToolSchemas.Json("value", "The fact's value, as the field's type takes it; get_schema_document shows each.", new JsonObject(), 24.99), + ToolSchemas.Json( + "source", + "Where the value comes from: a kind (url, file, person or system) and a reference to it.", + new JsonObject + { + ["type"] = "object", + ["properties"] = new JsonObject + { + [FactMetadata.SourceKind] = new JsonObject { ["enum"] = new JsonArray([.. FactMetadata.SourceKinds.Select(kind => JsonValue.Create(kind))]) }, + [FactMetadata.SourceReference] = new JsonObject { ["type"] = "string" }, + }, + ["required"] = new JsonArray(FactMetadata.SourceKind, FactMetadata.SourceReference), + }, + new JsonObject { [FactMetadata.SourceKind] = "file", [FactMetadata.SourceReference] = "exports/catalog-2026-10-18.csv" }, + required: false), + ToolSchemas.Uuid("owner", "The id of the user who owns the fact.", required: false), + ToolSchemas.String("valid_from", "When the value starts to hold, in ISO 8601 with an offset.", TimeExample, required: false), + ToolSchemas.String("valid_until", "When the value stops holding, in ISO 8601 with an offset.", TimeExample, required: false), + ToolSchemas.String("last_verified", "When someone last confirmed the value, in ISO 8601 with an offset.", TimeExample, required: false), + .. ChangeSetTarget.Arguments]), + readOnly: false, + SetFactAsync), + new AgentTool( + "relate_entities", + "Propose a relation between entities", + "Proposes relating an entity to another through one of its reference fields, from the version you read as " + + "base_version: a single reference is set to the other entity, and a list of references gets it added. It stores the " + + "next version and adds the change to a change set in the same call; the field keeps its source and validity.", + ToolSchemas.Object([ + IdArgument, + BaseVersionArgument, + ToolSchemas.String("field", "The reference field that holds the relation, such as category or specs.", "specs"), + ToolSchemas.Uuid("related_id", "The entity to relate to, of the type the field references."), + .. ChangeSetTarget.Arguments]), + readOnly: false, + RelateAsync), + ]; + + private static ToolArgument IdArgument { get; } = ToolSchemas.Uuid("id", "The entity's id, as list_entries or create_entry returned it."); + + private static ToolArgument BaseVersionArgument { get; } = + ToolSchemas.PositiveInteger("base_version", "The latest version you read, as get_entry returned it.", 1); + + private static async Task FactsAsync(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 facts = await call.Services.GetRequiredService().GetAsync(id!.Value, version, cancellationToken); + return ToolResult.From(facts, ToolJson.Facts, ProposedEntryWrite.Hint(facts.Errors)); + } + + private static async Task RelationsAsync(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 relations = await call.Services.GetRequiredService().GetAsync(id!.Value, cancellationToken); + return ToolResult.From(relations, ToolJson.Relations, ProposedEntryWrite.Hint(relations.Errors)); + } + + private static async Task UnsourcedAsync(ToolInvocation call, CancellationToken cancellationToken) + { + var arguments = new ToolArgumentsReader(call.Arguments); + var contentType = arguments.OptionalString("content_type", "product"); + if (arguments.Errors() is { Count: > 0 } errors) + { + return ToolResult.Failure(errors); + } + + var flags = await call.Services.GetRequiredService().UnsourcedAsync(contentType, cancellationToken); + return ToolResult.From( + flags, + list => new JsonObject { ["facts"] = new JsonArray([.. list.Select(ToolJson.Flag)]) }, + flags.Errors.FirstOrDefault()?.Code == EntityErrorCodes.TypeNotEntity + ? "Only entity types have facts; get_content_type shows whether a type is one." + : ProposedEntryWrite.Hint(flags.Errors)); + } + + private static async Task BlastRadiusAsync(ToolInvocation call, CancellationToken cancellationToken) + { + var arguments = new ToolArgumentsReader(call.Arguments); + var id = arguments.RequiredGuid("change_set_id"); + if (arguments.Errors() is { Count: > 0 } errors) + { + return ToolResult.Failure(errors); + } + + var radius = await call.Services.GetRequiredService().ForChangeSetAsync(id!.Value, cancellationToken); + return ToolResult.From(radius, ToolJson.BlastRadius); + } + + private static async Task SetFactAsync(ToolInvocation call, CancellationToken cancellationToken) + { + var arguments = new ToolArgumentsReader(call.Arguments); + var id = arguments.RequiredGuid("id"); + var baseVersion = arguments.RequiredPositiveInteger("base_version"); + var field = arguments.RequiredString("field", "price"); + var value = arguments.RequiredValue("value", "the fact's value, such as 24.99"); + var source = arguments.OptionalObject("source", "{\"kind\": \"file\", \"reference\": \"exports/catalog.csv\"}"); + var owner = arguments.OptionalGuid("owner"); + var validFrom = arguments.OptionalString("valid_from", TimeExample); + var validUntil = arguments.OptionalString("valid_until", TimeExample); + var lastVerified = arguments.OptionalString("last_verified", TimeExample); + var errors = new List(); + var target = ChangeSetTarget.Read(arguments, errors); + errors.InsertRange(0, arguments.Errors()); + if (errors.Count > 0) + { + return ToolResult.Failure(errors); + } + + var fact = new JsonObject + { + [FactMetadata.Value] = JsonNode.Parse(value!.Value.GetRawText()), + [FactMetadata.Source] = source is { } given ? JsonNode.Parse(given.GetRawText()) : null, + [FactMetadata.Owner] = owner?.ToString(), + [FactMetadata.ValidFrom] = validFrom, + [FactMetadata.ValidUntil] = validUntil, + [FactMetadata.LastVerified] = lastVerified, + }; + + return await ProposedEntryWrite.RunAsync( + call, + target!, + entries => ReviseEntityAsync(call.Services, entries, id!.Value, baseVersion!.Value, (_, fields) => + { + fields[field!] = fact.DeepClone(); + return Result.Success(); + }, cancellationToken), + ProposedEntryWrite.Update, + cancellationToken); + } + + private static async Task RelateAsync(ToolInvocation call, CancellationToken cancellationToken) + { + var arguments = new ToolArgumentsReader(call.Arguments); + var id = arguments.RequiredGuid("id"); + var baseVersion = arguments.RequiredPositiveInteger("base_version"); + var field = arguments.RequiredString("field", "specs"); + var relatedId = arguments.RequiredGuid("related_id"); + var errors = new List(); + var target = ChangeSetTarget.Read(arguments, errors); + errors.InsertRange(0, arguments.Errors()); + if (errors.Count > 0) + { + return ToolResult.Failure(errors); + } + + var fieldTypes = call.Services.GetRequiredService(); + + return await ProposedEntryWrite.RunAsync( + call, + target!, + entries => ReviseEntityAsync(call.Services, entries, id!.Value, baseVersion!.Value, (definition, fields) => + { + var relation = definition.Fields.FirstOrDefault(candidate => candidate.Name == field); + if (relation is null || fieldTypes.ReferenceTargets(relation.Type, relation.Settings).Count == 0) + { + return Result.Failure( + EntityErrorCodes.FieldNotRelation, + $"'{field}' is not a reference field of this entity type; its reference fields are " + + string.Join(", ", definition.Fields.Where(candidate => fieldTypes.ReferenceTargets(candidate.Type, candidate.Settings).Count > 0).Select(candidate => candidate.Name)) + + "."); + } + + var fact = fields[field!] as JsonObject ?? EmptyFact(); + var related = relatedId!.Value.ToString(); + if (relation.Type == "list") + { + var list = fact[FactMetadata.Value] as JsonArray ?? []; + if (!list.Any(item => item?.GetValue() is { } existing && Guid.TryParse(existing, out var parsed) && parsed == relatedId)) + { + list.Add(related); + } + + fact[FactMetadata.Value] = list.DeepClone(); + } + else + { + fact[FactMetadata.Value] = related; + } + + fields[field!] = fact.DeepClone(); + return Result.Success(); + }, cancellationToken), + ProposedEntryWrite.Update, + cancellationToken); + } + + // Reads the entity at the version the agent read, changes its fields in a copy and writes the next version; a stale base version + // fails on the write as for update_entry. + private static async Task> ReviseEntityAsync( + IServiceProvider services, + IEntryDirectory entries, + Guid id, + int baseVersion, + Func change, + CancellationToken cancellationToken) + { + var entry = await entries.GetAsync(id, cancellationToken); + if (!entry.IsSuccess) + { + return entry; + } + + var type = await services.GetRequiredService().GetAsync(entry.Value.ContentType, cancellationToken); + if (!type.IsSuccess) + { + return Result.Failure(type.Errors); + } + + if (!type.Value.Definition.IsEntity) + { + return Result.Failure( + ContentErrorCodes.EntryNotEntity, + $"Entry {id} is a '{entry.Value.ContentType}', which is not an entity type, so it has no facts; use update_entry."); + } + + var read = await entries.GetVersionAsync(id, baseVersion, cancellationToken); + if (!read.IsSuccess) + { + return Result.Failure(read.Errors); + } + + var fields = JsonNode.Parse(read.Value.Fields.GetRawText())!.AsObject(); + var changed = change(type.Value.Definition, fields); + if (!changed.IsSuccess) + { + return Result.Failure(changed.Errors); + } + + return await entries.UpdateAsync(id, baseVersion, JsonSerializer.SerializeToElement(fields), cancellationToken); + } + + private static JsonObject EmptyFact() + { + return new JsonObject + { + [FactMetadata.Value] = null, + [FactMetadata.Source] = null, + [FactMetadata.Owner] = null, + [FactMetadata.ValidFrom] = null, + [FactMetadata.ValidUntil] = null, + [FactMetadata.LastVerified] = null, + }; + } +} diff --git a/src/FluentCMS.Core/Builtin/Tools/EntryTools.cs b/src/FluentCMS.Core/Builtin/Tools/EntryTools.cs index a79eb1f72..c92f090bd 100644 --- a/src/FluentCMS.Core/Builtin/Tools/EntryTools.cs +++ b/src/FluentCMS.Core/Builtin/Tools/EntryTools.cs @@ -2,9 +2,7 @@ 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; @@ -14,9 +12,6 @@ namespace FluentCMS.Core.Builtin.Tools; // 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( @@ -124,7 +119,7 @@ private static async Task ListAsync(ToolInvocation call, Cancellatio } 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)); + return ToolResult.From(entries, list => new JsonObject { ["entries"] = new JsonArray([.. list.Select(ToolJson.EntrySummary)]) }, ProposedEntryWrite.Hint(entries.Errors)); } private static async Task GetAsync(ToolInvocation call, CancellationToken cancellationToken) @@ -141,7 +136,7 @@ private static async Task GetAsync(ToolInvocation call, Cancellation 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)); + return ToolResult.From(entry, read => ToolJson.Entry(read), ProposedEntryWrite.Hint(entry.Errors)); } var stored = await directory.GetVersionAsync(id.Value, number, cancellationToken); @@ -158,7 +153,7 @@ private static async Task HistoryAsync(ToolInvocation call, Cancella } 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)); + return ToolResult.From(history, versions => new JsonObject { ["versions"] = new JsonArray([.. versions.Select(ToolJson.EntryVersion)]) }, ProposedEntryWrite.Hint(history.Errors)); } private static async Task ResolvedAsync(ToolInvocation call, CancellationToken cancellationToken) @@ -172,7 +167,7 @@ private static async Task ResolvedAsync(ToolInvocation call, Cancell } var resolved = await call.Services.GetRequiredService().ResolveAsync(id!.Value, version, cancellationToken); - return ToolResult.From(resolved, ToolJson.Resolved, Hint(resolved.Errors)); + return ToolResult.From(resolved, ToolJson.Resolved, ProposedEntryWrite.Hint(resolved.Errors)); } private static async Task WhereUsedAsync(ToolInvocation call, CancellationToken cancellationToken) @@ -204,7 +199,7 @@ private static async Task WhereUsedAsync(ToolInvocation call, Cancel : 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)); + return ToolResult.From(usages, list => new JsonObject { ["usages"] = new JsonArray([.. list.Select(ToolJson.Usage)]) }, ProposedEntryWrite.Hint(usages.Errors)); } private static async Task CreateAsync(ToolInvocation call, CancellationToken cancellationToken) @@ -220,7 +215,7 @@ private static async Task CreateAsync(ToolInvocation call, Cancellat return ToolResult.Failure(errors); } - return await ProposeAsync( + return await ProposedEntryWrite.RunAsync( call, target!, entries => entries.CreateAsync(new NewEntry(contentType!, fields!.Value), cancellationToken), @@ -228,7 +223,6 @@ private static async Task CreateAsync(ToolInvocation call, Cancellat 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); @@ -243,13 +237,11 @@ private static async Task UpdateAsync(ToolInvocation call, Cancellat return ToolResult.Failure(errors); } - return await ProposeAsync( + return await ProposedEntryWrite.RunAsync( 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), + ProposedEntryWrite.Update, cancellationToken); } @@ -265,7 +257,7 @@ private static async Task DeleteAsync(ToolInvocation call, Cancellat return ToolResult.Failure(errors); } - return await ProposeAsync( + return await ProposedEntryWrite.RunAsync( call, target!, entries => entries.GetAsync(id!.Value, cancellationToken), @@ -277,77 +269,5 @@ private static async Task DeleteAsync(ToolInvocation call, Cancellat 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/ProposedEntryWrite.cs b/src/FluentCMS.Core/Builtin/Tools/ProposedEntryWrite.cs new file mode 100644 index 000000000..776ee22c2 --- /dev/null +++ b/src/FluentCMS.Core/Builtin/Tools/ProposedEntryWrite.cs @@ -0,0 +1,100 @@ +using System.Text.Json.Nodes; +using FluentCMS.Core.ChangeSets; +using FluentCMS.Core.Content; +using FluentCMS.Core.Entities; +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; + +// An entry or entity write an agent proposes, shared by the entry and entity tools. +internal static class ProposedEntryWrite +{ + 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."; + + // 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). + 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 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.", + }); + } + + // An update is based on what is published, which is what publishing it replaces; a draft never published is still a create. + public static Result Update(Entry entry, ItemKind kind) + { + return entry.PublishedVersion is { } published + ? ChangeOperation.Create(OperationKind.Update, kind, entry.Id, published, entry.LatestVersion) + : ChangeOperation.Create(OperationKind.Create, kind, entry.Id, null, entry.LatestVersion); + } + + public 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.", + EntityErrorCodes.FieldNotRelation => "Call get_content_type for the entity type's reference fields and the types they point at.", + ContentErrorCodes.EntryNotEntity => "Only entities have facts and relations; get_content_type shows whether a type is an entity type.", + 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 4fb46c413..4dd7d5ceb 100644 --- a/src/FluentCMS.Core/Builtin/Tools/ToolJson.cs +++ b/src/FluentCMS.Core/Builtin/Tools/ToolJson.cs @@ -2,6 +2,7 @@ using System.Text.Json.Nodes; using FluentCMS.Core.ChangeSets; using FluentCMS.Core.Content; +using FluentCMS.Core.Entities; using FluentCMS.Core.Identity; using FluentCMS.Core.Schema; @@ -159,6 +160,85 @@ public static JsonObject Usage(EntryUsage usage) }; } + public static JsonObject Facts(EntityFactSheet sheet) + { + return new JsonObject + { + ["entityId"] = sheet.EntityId.ToString(), + ["type"] = sheet.Type, + ["version"] = sheet.Version, + ["facts"] = new JsonArray([.. sheet.Facts.Select(fact => new JsonObject + { + ["field"] = fact.Field, + ["unsourced"] = fact.IsUnsourced, + ["source"] = fact.SourceKind is null ? null : new JsonObject { ["kind"] = fact.SourceKind, ["reference"] = fact.SourceReference }, + ["owner"] = fact.Owner?.ToString(), + ["validFrom"] = Time(fact.ValidFrom), + ["validUntil"] = Time(fact.ValidUntil), + ["lastVerified"] = Time(fact.LastVerified), + })]), + }; + } + + public static JsonObject Flag(FactFlag flag) + { + return new JsonObject + { + ["entityId"] = flag.EntityId.ToString(), + ["type"] = flag.Type, + ["field"] = flag.Field, + ["version"] = flag.Version, + ["isLatest"] = flag.IsLatest, + ["isPublished"] = flag.IsPublished, + }; + } + + public static JsonObject Relations(EntityRelations relations) + { + return new JsonObject + { + ["entityId"] = relations.EntityId.ToString(), + ["type"] = relations.Type, + ["outgoing"] = new JsonArray([.. relations.Outgoing.Select(Relation)]), + ["incoming"] = new JsonArray([.. relations.Incoming.Select(Relation)]), + }; + } + + public static JsonObject BlastRadius(BlastRadius radius) + { + return new JsonObject + { + ["changeSetId"] = radius.ChangeSetId.ToString(), + ["entries"] = new JsonArray([.. radius.Entries.Select(entry => new JsonObject + { + ["entryId"] = entry.EntryId.ToString(), + ["contentType"] = entry.ContentType, + ["version"] = entry.Version, + ["fields"] = Values(entry.Fields), + })]), + }; + } + + private static JsonObject Relation(EntityRelation relation) + { + return new JsonObject + { + ["fromId"] = relation.FromId.ToString(), + ["fromType"] = relation.FromType, + ["field"] = relation.Field, + ["toId"] = relation.ToId.ToString(), + ["toType"] = relation.ToType, + ["toField"] = relation.ToField, + ["isLatest"] = relation.IsLatest, + ["isPublished"] = relation.IsPublished, + }; + } + + private static string? Time(DateTimeOffset? time) + { + return time?.ToString("O", System.Globalization.CultureInfo.InvariantCulture); + } + private static JsonObject Operation(ChangeOperation operation) { return new JsonObject diff --git a/src/FluentCMS.Core/Entities/IEntityFacts.cs b/src/FluentCMS.Core/Entities/IEntityFacts.cs index 3ba8c2a65..0151cfb8e 100644 --- a/src/FluentCMS.Core/Entities/IEntityFacts.cs +++ b/src/FluentCMS.Core/Entities/IEntityFacts.cs @@ -24,4 +24,5 @@ public sealed record FactFlag(Guid EntityId, string Type, string Field, int Vers public static class EntityErrorCodes { public const string TypeNotEntity = "type_not_entity"; + public const string FieldNotRelation = "field_not_relation"; } diff --git a/src/FluentCMS.Core/Tools/ToolArgumentsReader.cs b/src/FluentCMS.Core/Tools/ToolArgumentsReader.cs index 3e51944e2..e544c706f 100644 --- a/src/FluentCMS.Core/Tools/ToolArgumentsReader.cs +++ b/src/FluentCMS.Core/Tools/ToolArgumentsReader.cs @@ -66,6 +66,34 @@ public ToolArgumentsReader(JsonElement arguments) return null; } + // Any JSON value but null, such as the value of a field. + public JsonElement? RequiredValue(string name, string form) + { + var value = Read(name); + if (value is null) + { + Missing(name, form); + } + + return value; + } + + public JsonElement? OptionalObject(string name, string form) + { + if (Read(name) is not { } value) + { + return null; + } + + if (value.ValueKind == JsonValueKind.Object) + { + return value; + } + + Invalid(name, $"must be a JSON object: {form}"); + return null; + } + public Guid? OptionalGuid(string name) { return Read(name) is null ? null : GuidOf(name); diff --git a/tests/FluentCMS.IntegrationTests/Mcp/EntityToolsTests.cs b/tests/FluentCMS.IntegrationTests/Mcp/EntityToolsTests.cs new file mode 100644 index 000000000..9c938b358 --- /dev/null +++ b/tests/FluentCMS.IntegrationTests/Mcp/EntityToolsTests.cs @@ -0,0 +1,204 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using FluentCMS.Core.Content; +using FluentCMS.Core.Entities; +using FluentCMS.Core.Schema; +using FluentCMS.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; + +namespace FluentCMS.IntegrationTests.Mcp; + +public sealed class EntityToolsTests +{ + private static readonly JsonObject Export = new() { ["kind"] = "file", ["reference"] = "exports/catalog-2026-10-18.csv" }; + + // S1 over MCP: the pipeline agent sets prices from the export in one session's set and reads which live pages they move. + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task An_agent_sets_prices_from_an_export_and_reads_the_pages_they_move(string provider) + { + await using var agent = await ConnectedAgent.StartAsync(provider); + await CatalogAsync(agent); + var (drill, saw, sander) = (await ProductAsync(agent, "99"), await ProductAsync(agent, "49"), await ProductAsync(agent, "79")); + var (drillPage, sawPage, sanderPage) = (await PageAsync(agent, drill), await PageAsync(agent, saw), await PageAsync(agent, sander)); + await PublishAsync(agent, drill, saw, sander, drillPage, sawPage, sanderPage); + + string? setId = null; + foreach (var (product, price) in new[] { (drill, 109m), (saw, 59m) }) + { + var proposed = await agent.CallAsync("set_entity_fact", new() + { + ["id"] = product.ToString(), + ["base_version"] = 1, + ["field"] = "price", + ["value"] = price, + ["source"] = Export, + ["session_id"] = "catalog-sync-2026-10-18", + ["rationale"] = "Sync prices from the nightly export.", + }); + setId = (string)proposed["changeSet"]!["id"]!; + Assert.Equal("Drill", (string?)proposed["entry"]!["fields"]!["name"]!["value"]); + } + + var radius = await agent.CallAsync("get_blast_radius", new() { ["change_set_id"] = setId }); + var facts = await agent.CallAsync("get_entity_facts", new() { ["id"] = drill.ToString() }); + + var entries = radius["entries"]!.AsArray(); + Assert.Equal(new[] { drillPage, sawPage }.Order().Select(id => id.ToString()), entries.Select(entry => (string?)entry!["entryId"])); + var drillDiff = Assert.Single(entries.Single(entry => (string?)entry!["entryId"] == drillPage.ToString())!["fields"]!.AsArray())!; + Assert.Equal(("/price", 99m, 109m), ((string?)drillDiff["name"], (decimal)drillDiff["before"]!, (decimal)drillDiff["after"]!)); + Assert.DoesNotContain(entries, entry => (string?)entry!["entryId"] == sanderPage.ToString()); + + var priceFact = facts["facts"]!.AsArray().Single(fact => (string?)fact!["field"] == "price")!; + Assert.False((bool)priceFact["unsourced"]!); + Assert.Equal("exports/catalog-2026-10-18.csv", (string?)priceFact["source"]!["reference"]); + } + + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task A_fact_set_without_a_source_is_flagged_and_listed(string provider) + { + await using var agent = await ConnectedAgent.StartAsync(provider); + await CatalogAsync(agent); + var drill = await ProductAsync(agent, "99"); + + await agent.CallAsync("set_entity_fact", new() + { + ["id"] = drill.ToString(), + ["base_version"] = 1, + ["field"] = "price", + ["value"] = 89, + ["session_id"] = "guess", + ["rationale"] = "A price heard on the phone.", + }); + + var facts = await agent.CallAsync("get_entity_facts", new() { ["id"] = drill.ToString() }); + var unsourced = await agent.CallAsync("list_unsourced_facts", new() { ["content_type"] = "product" }); + + Assert.True((bool)facts["facts"]!.AsArray().Single(fact => (string?)fact!["field"] == "price")!["unsourced"]!); + Assert.Contains(unsourced["facts"]!.AsArray(), flag => (string?)flag!["field"] == "price" && (int)flag["version"]! == 2); + } + + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task An_agent_relates_a_product_to_a_spec_and_a_category_and_reads_both_directions(string provider) + { + await using var agent = await ConnectedAgent.StartAsync(provider); + await CatalogAsync(agent); + var drill = await ProductAsync(agent, "99"); + var tools = await EntityAsync(agent, "category", "Tools"); + var width = await EntityAsync(agent, "spec", "Width"); + + await agent.CallAsync("relate_entities", new() { ["id"] = drill.ToString(), ["base_version"] = 1, ["field"] = "specs", ["related_id"] = width.ToString(), ["session_id"] = "relate", ["rationale"] = "Link the spec." }); + await agent.CallAsync("relate_entities", new() { ["id"] = drill.ToString(), ["base_version"] = 2, ["field"] = "category", ["related_id"] = tools.ToString(), ["session_id"] = "relate-category", ["rationale"] = "File the drill." }); + + var product = await agent.CallAsync("get_entity_relations", new() { ["id"] = drill.ToString() }); + var spec = await agent.CallAsync("get_entity_relations", new() { ["id"] = width.ToString() }); + + Assert.Equal( + [("category", tools.ToString()), ("specs", width.ToString())], + product["outgoing"]!.AsArray().Select(relation => ((string)relation!["field"]!, (string)relation["toId"]!))); + Assert.Equal(drill.ToString(), (string?)Assert.Single(spec["incoming"]!.AsArray())!["fromId"]); + } + + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task Refused_fact_and_relation_writes_say_why_and_store_nothing(string provider) + { + await using var agent = await ConnectedAgent.StartAsync(provider); + await CatalogAsync(agent); + var drill = await ProductAsync(agent, "99"); + var tools = await EntityAsync(agent, "category", "Tools"); + var page = await PageAsync(agent, drill); + var versions = await agent.QueryAsync(context => context.EntryVersions.CountAsync()); + + Dictionary Target(string session) => new() { ["session_id"] = session, ["rationale"] = "Refused." }; + var stale = await agent.FailAsync("set_entity_fact", new(Target("a")) { ["id"] = drill.ToString(), ["base_version"] = 2, ["field"] = "price", ["value"] = 1, ["source"] = Export }); + var unknownField = await agent.FailAsync("set_entity_fact", new(Target("b")) { ["id"] = drill.ToString(), ["base_version"] = 1, ["field"] = "colour", ["value"] = "red" }); + var plain = await agent.FailAsync("set_entity_fact", new(Target("c")) { ["id"] = page.ToString(), ["base_version"] = 1, ["field"] = "headline", ["value"] = "x" }); + var notRelation = await agent.FailAsync("relate_entities", new(Target("d")) { ["id"] = drill.ToString(), ["base_version"] = 1, ["field"] = "name", ["related_id"] = tools.ToString() }); + var wrongType = await agent.FailAsync("relate_entities", new(Target("e")) { ["id"] = drill.ToString(), ["base_version"] = 1, ["field"] = "specs", ["related_id"] = tools.ToString() }); + + Assert.Equal(ContentErrorCodes.VersionNotFound, Code(stale)); + Assert.Equal(ContentErrorCodes.FieldUnknown, Code(unknownField)); + Assert.Contains("get_schema_document", (string?)unknownField["hint"]); + Assert.Equal(ContentErrorCodes.EntryNotEntity, Code(plain)); + Assert.Equal(EntityErrorCodes.FieldNotRelation, Code(notRelation)); + Assert.Contains("get_content_type", (string?)notRelation["hint"]); + Assert.Equal(ContentErrorCodes.ReferenceTypeMismatch, Code(wrongType)); + Assert.Equal(versions, await agent.QueryAsync(context => context.EntryVersions.CountAsync())); + Assert.Equal(0, await agent.QueryAsync(context => context.ChangeSets.CountAsync())); + } + + private static async Task CatalogAsync(ConnectedAgent agent) + { + async Task TypeAsync(string slug, bool isEntity, params FieldDefinition[] fields) + { + await agent.AsPersonAsync(directory => + directory.CreateAsync(new NewContentType(slug, slug, new ContentTypeDefinition(fields, isEntity)))); + } + + await TypeAsync("category", true, new FieldDefinition("name", "text")); + await TypeAsync("spec", true, new FieldDefinition("name", "text")); + await TypeAsync( + "product", + true, + new FieldDefinition("name", "text"), + new FieldDefinition("price", "number"), + new FieldDefinition("category", "reference", settings: Json("""{"target": "category"}""")), + new FieldDefinition("specs", "list", settings: Json("""{"itemType": "reference", "itemSettings": {"target": "spec"}}"""))); + await TypeAsync( + "product-page", + false, + new FieldDefinition("headline", "text"), + new FieldDefinition("price", "reference", settings: Json("""{"target": "product", "field": "price"}"""))); + } + + private static Task ProductAsync(ConnectedAgent agent, string price) + { + return CreateAsync(agent, "product", $$"""{"name": {{Fact("\"Drill\"")}}, "price": {{Fact(price)}}}"""); + } + + private static Task EntityAsync(ConnectedAgent agent, string type, string name) + { + return CreateAsync(agent, type, $$"""{"name": {{Fact($"\"{name}\"")}}}"""); + } + + private static Task PageAsync(ConnectedAgent agent, Guid product) + { + return CreateAsync(agent, "product-page", $$"""{"headline": "Buy it", "price": "{{product}}"}"""); + } + + private static async Task CreateAsync(ConnectedAgent agent, string type, string fields) + { + var entry = await agent.AsPersonAsync(entries => entries.CreateAsync(new NewEntry(type, Json(fields)))); + return entry.Id; + } + + // Publish moves the pointer through change sets (#2519); these tests only need live pages and entities. + private static Task PublishAsync(ConnectedAgent agent, params Guid[] entries) + { + return agent.QueryAsync(context => context.Entries + .Where(row => entries.Contains(row.Id)) + .ExecuteUpdateAsync(setters => setters.SetProperty(row => row.PublishedVersion, 1))); + } + + private static string Fact(string value) + { + return $$"""{"value": {{value}}, "source": {"kind": "system", "reference": "seed"}, "owner": null, "validFrom": null, "validUntil": null, "lastVerified": null}"""; + } + + 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 e802125db..10f2e84f9 100644 --- a/tests/FluentCMS.IntegrationTests/Mcp/McpAgents.cs +++ b/tests/FluentCMS.IntegrationTests/Mcp/McpAgents.cs @@ -21,9 +21,10 @@ internal static class McpAgents // The built-in tools every server lists, sorted by name. public static readonly string[] BuiltInTools = [ - "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", + "create_content_type", "create_entry", "delete_entry", "get_blast_radius", "get_change_set", "get_change_set_diff", + "get_content_type", "get_entity_facts", "get_entity_relations", "get_entry", "get_entry_history", "get_resolved_entry", + "get_schema_document", "get_where_used", "list_content_types", "list_entries", "list_unsourced_facts", "open_change_set", + "relate_entities", "reopen_change_set", "revise_content_type", "set_entity_fact", "submit_change_set", "update_entry", ]; public static async Task ToolNamesAsync(McpClient client) diff --git a/tests/FluentCMS.Tests/Tools/McpCoverageTests.cs b/tests/FluentCMS.Tests/Tools/McpCoverageTests.cs index 519b11132..9a1fc7dc8 100644 --- a/tests/FluentCMS.Tests/Tools/McpCoverageTests.cs +++ b/tests/FluentCMS.Tests/Tools/McpCoverageTests.cs @@ -25,7 +25,12 @@ public sealed class McpCoverageTests 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"), + new("ENT", "Read an entity's facts with their sources and flags", ["get_entity_facts"]), + new("ENT", "Read an entity's relations in both directions", ["get_entity_relations"]), + new("ENT", "List unsourced facts", ["list_unsourced_facts"]), + new("ENT", "Propose a fact with its source, or a relation", ["set_entity_fact", "relate_entities"]), + new("ENT", "Read the pages a change set's entity changes affect", ["get_blast_radius"]), + Missing("ENT", "List stale facts", "#2417"), Missing("RUL", "Read and propose rules", "#2419"), Missing("CHK", "Read the check results of a change set", "#2420"), Missing("DSK", "Read review decisions, read-only", "#2422"), diff --git a/tests/FluentCMS.Tests/Tools/ToolProviderConformanceTests.cs b/tests/FluentCMS.Tests/Tools/ToolProviderConformanceTests.cs index 88d593f76..58050868f 100644 --- a/tests/FluentCMS.Tests/Tools/ToolProviderConformanceTests.cs +++ b/tests/FluentCMS.Tests/Tools/ToolProviderConformanceTests.cs @@ -114,3 +114,8 @@ public sealed class EntryToolsConformanceTests : ToolProviderConformanceTests { protected override object Provider { get; } = new EntryTools(); } + +public sealed class EntityToolsConformanceTests : ToolProviderConformanceTests +{ + protected override object Provider { get; } = new EntityTools(); +} From a575f7ee80d473bfced7a6fc9cc888c6f33c2982 Mon Sep 17 00:00:00 2001 From: Amir Pournasserian Date: Sun, 18 Oct 2026 21:08:11 -0400 Subject: [PATCH 2/2] Append to any relation value that is already a list --- src/FluentCMS.Core/Builtin/Tools/EntityTools.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/FluentCMS.Core/Builtin/Tools/EntityTools.cs b/src/FluentCMS.Core/Builtin/Tools/EntityTools.cs index 3b5db765a..e2591c8ea 100644 --- a/src/FluentCMS.Core/Builtin/Tools/EntityTools.cs +++ b/src/FluentCMS.Core/Builtin/Tools/EntityTools.cs @@ -240,7 +240,8 @@ private static async Task RelateAsync(ToolInvocation call, Cancellat var fact = fields[field!] as JsonObject ?? EmptyFact(); var related = relatedId!.Value.ToString(); - if (relation.Type == "list") + // A value that is already an array holds several references, whatever field type stores them. + if (relation.Type == "list" || fact[FactMetadata.Value] is JsonArray) { var list = fact[FactMetadata.Value] as JsonArray ?? []; if (!list.Any(item => item?.GetValue() is { } existing && Guid.TryParse(existing, out var parsed) && parsed == relatedId))