From 0948f5dd63b645f8229bf14ea73a489d34958681 Mon Sep 17 00:00:00 2001 From: Amir Pournasserian Date: Sun, 18 Oct 2026 19:05:25 -0400 Subject: [PATCH 1/4] Add the tool and resource conventions with the read-only schema and change set tools --- CONTRIBUTING.md | 2 + .../Builtin/Tools/ChangeSetTools.cs | 66 +++++ .../Builtin/Tools/SchemaTools.cs | 128 +++++++++ src/FluentCMS.Core/Builtin/Tools/ToolJson.cs | 118 +++++++++ src/FluentCMS.Core/Tools/AgentResource.cs | 73 ++++++ src/FluentCMS.Core/Tools/AgentTool.cs | 59 +++++ src/FluentCMS.Core/Tools/IResourceProvider.cs | 11 + src/FluentCMS.Core/Tools/IToolProvider.cs | 11 + .../Tools/ToolArgumentsReader.cs | 129 ++++++++++ src/FluentCMS.Core/Tools/ToolErrorCodes.cs | 10 + src/FluentCMS.Core/Tools/ToolNames.cs | 16 ++ src/FluentCMS.Core/Tools/ToolResult.cs | 73 ++++++ src/FluentCMS.Core/Tools/ToolSchemas.cs | 55 ++++ src/FluentCMS.Mcp/CoreTools.cs | 117 +++++++++ .../McpServiceCollectionExtensions.cs | 9 +- .../Mcp/AgentAuthenticationTests.cs | 4 +- .../Mcp/BuiltInToolsTests.cs | 242 ++++++++++++++++++ .../Mcp/McpAgents.cs | 8 + .../Mcp/McpServerTests.cs | 4 +- .../Tools/ToolConventionsTests.cs | 107 ++++++++ .../Tools/ToolProviderConformanceTests.cs | 111 ++++++++ 21 files changed, 1346 insertions(+), 7 deletions(-) create mode 100644 src/FluentCMS.Core/Builtin/Tools/ChangeSetTools.cs create mode 100644 src/FluentCMS.Core/Builtin/Tools/SchemaTools.cs create mode 100644 src/FluentCMS.Core/Builtin/Tools/ToolJson.cs create mode 100644 src/FluentCMS.Core/Tools/AgentResource.cs create mode 100644 src/FluentCMS.Core/Tools/AgentTool.cs create mode 100644 src/FluentCMS.Core/Tools/IResourceProvider.cs create mode 100644 src/FluentCMS.Core/Tools/IToolProvider.cs create mode 100644 src/FluentCMS.Core/Tools/ToolArgumentsReader.cs create mode 100644 src/FluentCMS.Core/Tools/ToolErrorCodes.cs create mode 100644 src/FluentCMS.Core/Tools/ToolNames.cs create mode 100644 src/FluentCMS.Core/Tools/ToolResult.cs create mode 100644 src/FluentCMS.Core/Tools/ToolSchemas.cs create mode 100644 src/FluentCMS.Mcp/CoreTools.cs create mode 100644 tests/FluentCMS.IntegrationTests/Mcp/BuiltInToolsTests.cs create mode 100644 tests/FluentCMS.Tests/Tools/ToolConventionsTests.cs create mode 100644 tests/FluentCMS.Tests/Tools/ToolProviderConformanceTests.cs diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c46977f39..d4ada07e4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -94,6 +94,8 @@ Every pluggable part of the product is an interface in `FluentCMS.Core` marked ` Field types (`IFieldType` in `FluentCMS.Core.Schema`) are the first. A field type has a name that definitions store, so it never changes once released, and it checks the settings a definition gives a field of its type, returning errors for missing, invalid and unknown settings instead of throwing. `FieldSettingsReader` does that bookkeeping for built-ins and plugins alike. A type whose values point at entries of another content type, or at one field of an entity type such as `product.price`, also implements `ReferenceTargets`; `IContentTypeDirectory` refuses a definition whose targets do not exist in the site, or whose field targets are not fields of an entity type, so a plugin type gets the same checks as the built-in `reference`. Such a type also lists the references each stored value makes (`References`), with a JSON Pointer to each one's place; entries write them to the reference index that where used (`IWhereUsed`, CNT-05) reads, and refuse a reference to an entry that is missing or of another type. Every type also describes its values for the schema document agents read (`DescribeValue`, SCH-06): a JSON Schema for one value, an example that satisfies it and one sentence about it. It checks each value an entry stores in a field of its type (`ValidateValue`), accepting what it describes and returning `value_invalid` errors for anything else; `IEntryDirectory` stores no version with a value its type refuses. To add one, implement the interface, then derive a test class from `FieldTypeConformanceTests` in `tests/FluentCMS.Tests/Schema` with settings the type must accept and settings it must reject, and values it must accept and reject; a type outside Core also overrides `AddFieldTypes` to register its assembly. +Agent tools and resources (`IToolProvider` and `IResourceProvider` in `FluentCMS.Core.Tools`, MCP-02, MCP-03) are next. A tool has a snake_case name, which agents and the tool call log know it by, so it never changes once released. Its description, and the description of every argument in its input schema, are written for the model that reads them: what the tool does, when to call it and what to call next. Build the schema with `ToolSchemas`, which describes each argument with an example and refuses arguments it does not name. Read the arguments with `ToolArgumentsReader`, which reports a missing, malformed or unknown argument by name before the tool reads anything. Return a `ToolResult`: the value as JSON, or the errors of a `Result`, each with a code and a message that says what to change, plus an optional hint naming the call that finds a valid value. The MCP server sends a failure as a tool error (`isError`) with `{"errors", "hint"}` in both the structured content and the text, never as a protocol error, so the agent reads it and tries again. A resource has a fixed URI or a template such as `fluentcms://schema/{slug}`. To add a provider, implement the interface, then derive a test class from `ToolProviderConformanceTests` in `tests/FluentCMS.Tests/Tools`. Two tools with one name stop the host when it starts. + ## Code style `.editorconfig` is the source of truth: UTF-8, LF line endings, four spaces in C#, two in JSON and YAML, file-scoped namespaces. Nullable reference types and implicit usings are enabled project-wide. Prefer clear code over clever code. Comments explain why, not what. Do not add XML documentation comments unless an issue asks for them. diff --git a/src/FluentCMS.Core/Builtin/Tools/ChangeSetTools.cs b/src/FluentCMS.Core/Builtin/Tools/ChangeSetTools.cs new file mode 100644 index 000000000..a9441d859 --- /dev/null +++ b/src/FluentCMS.Core/Builtin/Tools/ChangeSetTools.cs @@ -0,0 +1,66 @@ +using FluentCMS.Core.ChangeSets; +using FluentCMS.Core.Results; +using FluentCMS.Core.Tools; +using Microsoft.Extensions.DependencyInjection; + +namespace FluentCMS.Core.Builtin.Tools; + +// Reading change sets (CHG-01, CHG-03): a set's state and operations, and what each operation changes field by field. +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."; + + public IReadOnlyList Tools { get; } = + [ + new AgentTool( + "get_change_set", + "Get a change set", + "Returns one change set: its state, rationale, sources, who opened it and its operations in order, each naming the " + + "item, the version it was based on and the version it proposes. States run open, checks_running, then checks_failed " + + "or awaiting_review, approved, published; a set can also be rejected (with a reason) or reverted. To see what the " + + "operations change, call get_change_set_diff.", + ToolSchemas.Object(ToolSchemas.Uuid("id", "The change set's id.")), + readOnly: true, + GetAsync), + new AgentTool( + "get_change_set_diff", + "Get a change set's diff", + "Returns what each operation of a change set changes, in operation order: the item's properties and fields that were " + + "added, removed or changed, each with its value before and after. Unchanged values are left out, so an empty list " + + "means the operation changes nothing there.", + ToolSchemas.Object(ToolSchemas.Uuid("id", "The change set's id.")), + readOnly: true, + DiffAsync), + ]; + + private static async Task GetAsync(ToolCall 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 set = await call.Services.GetRequiredService().GetAsync(id!.Value, cancellationToken); + return ToolResult.From(set, ToolJson.ChangeSet, Hint(set)); + } + + private static async Task DiffAsync(ToolCall 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 diff = await call.Services.GetRequiredService().DiffAsync(id!.Value, cancellationToken); + return ToolResult.From(diff, ToolJson.Diff, Hint(diff)); + } + + private static string? Hint(Result result) + { + return result.Errors.FirstOrDefault()?.Code == ChangeSetErrorCodes.ChangeSetNotFound ? FindIds : null; + } +} diff --git a/src/FluentCMS.Core/Builtin/Tools/SchemaTools.cs b/src/FluentCMS.Core/Builtin/Tools/SchemaTools.cs new file mode 100644 index 000000000..bb50688aa --- /dev/null +++ b/src/FluentCMS.Core/Builtin/Tools/SchemaTools.cs @@ -0,0 +1,128 @@ +using System.Text.Json.Nodes; +using FluentCMS.Core.Results; +using FluentCMS.Core.Schema; +using FluentCMS.Core.Tools; +using Microsoft.Extensions.DependencyInjection; + +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. +public sealed class SchemaTools : IToolProvider, IResourceProvider +{ + private const string FindSlugs = "Call list_content_types for the slugs of the site's content types."; + private const string JsonMediaType = "application/schema+json"; + + public IReadOnlyList Tools { get; } = + [ + new AgentTool( + "list_content_types", + "List content types", + "Lists every content type of the site with its id, slug, name and current version. Call this first to find the slug " + + "the other schema tools take; read one type's fields with get_content_type, or the schema to write entries against " + + "with get_schema_document.", + ToolSchemas.Object(), + readOnly: true, + ListAsync), + new AgentTool( + "get_content_type", + "Get a content type", + "Returns one content type with its fields: each field's name, type, whether it is required, its description and its " + + "settings. Without a version it returns the current one; earlier versions stay readable. To write an entry, the " + + "schema document from get_schema_document says more: every field's allowed values with an example.", + ToolSchemas.Object( + ToolSchemas.String("slug", "The content type's slug, as list_content_types shows it.", "product"), + ToolSchemas.PositiveInteger("version", "A version to read instead of the current one.", 1, required: false)), + readOnly: true, + GetAsync), + new AgentTool( + "get_schema_document", + "Get the schema document", + "Returns the JSON Schema (draft 2020-12) that entries follow, with a description and an example for every field and " + + "one example entry. With a slug it covers that content type at its current version; without one, every type of " + + "the site under $defs, keyed by slug. On an entity type every field is a fact that carries its source, owner and validity.", + ToolSchemas.Object( + ToolSchemas.String("slug", "A content type's slug, to read only that type's schema.", "product", required: false)), + readOnly: true, + SchemaDocumentAsync), + ]; + + public IReadOnlyList Resources { get; } = + [ + new AgentResource( + "fluentcms://schema", + "schema_document", + "The schema document of the site: the JSON Schema that entries of every content type follow, under $defs keyed by slug.", + JsonMediaType, + async (read, cancellationToken) => Result.Success( + (await read.Services.GetRequiredService().GetSiteAsync(cancellationToken)).ToJsonString())), + new AgentResource( + "fluentcms://schema/{slug}", + "content_type_schema", + "The JSON Schema that entries of one content type follow, at its current version, with a description and an example for every field.", + JsonMediaType, + async (read, cancellationToken) => + { + var document = await read.Services.GetRequiredService().GetTypeAsync(read.Parameters["slug"], cancellationToken); + return document.IsSuccess ? Result.Success(document.Value.ToJsonString()) : Result.Failure(document.Errors); + }), + ]; + + private static async Task ListAsync(ToolCall call, CancellationToken cancellationToken) + { + var arguments = new ToolArgumentsReader(call.Arguments); + if (arguments.Errors() is { Count: > 0 } errors) + { + return ToolResult.Failure(errors); + } + + var types = await call.Services.GetRequiredService().ListAsync(cancellationToken); + return ToolResult.Success(new JsonObject { ["contentTypes"] = new JsonArray([.. types.Select(ToolJson.Summary)]) }); + } + + private static async Task GetAsync(ToolCall call, CancellationToken cancellationToken) + { + var arguments = new ToolArgumentsReader(call.Arguments); + var slug = arguments.RequiredString("slug", "product"); + var version = arguments.OptionalPositiveInteger("version"); + if (arguments.Errors() is { Count: > 0 } errors) + { + return ToolResult.Failure(errors); + } + + var directory = call.Services.GetRequiredService(); + var type = version is { } number + ? await directory.GetVersionAsync(slug!, number, cancellationToken) + : await directory.GetAsync(slug!, cancellationToken); + + return ToolResult.From(type, ToolJson.ContentType, Hint(type)); + } + + private static async Task SchemaDocumentAsync(ToolCall call, CancellationToken cancellationToken) + { + var arguments = new ToolArgumentsReader(call.Arguments); + var slug = arguments.OptionalString("slug", "product"); + if (arguments.Errors() is { Count: > 0 } errors) + { + return ToolResult.Failure(errors); + } + + var documents = call.Services.GetRequiredService(); + if (slug is null) + { + return ToolResult.Success(await documents.GetSiteAsync(cancellationToken)); + } + + var document = await documents.GetTypeAsync(slug, cancellationToken); + return ToolResult.From(document, value => value, Hint(document)); + } + + private static string? Hint(Result result) + { + return result.Errors.FirstOrDefault()?.Code switch + { + SchemaErrorCodes.ContentTypeNotFound => FindSlugs, + SchemaErrorCodes.VersionNotFound => "Leave out the version to read the current one.", + _ => null, + }; + } +} diff --git a/src/FluentCMS.Core/Builtin/Tools/ToolJson.cs b/src/FluentCMS.Core/Builtin/Tools/ToolJson.cs new file mode 100644 index 000000000..d31cf9b11 --- /dev/null +++ b/src/FluentCMS.Core/Builtin/Tools/ToolJson.cs @@ -0,0 +1,118 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using FluentCMS.Core.ChangeSets; +using FluentCMS.Core.Identity; +using FluentCMS.Core.Schema; + +namespace FluentCMS.Core.Builtin.Tools; + +// How the built-in tools show the model to an agent. Names of states and kinds are snake_case, as the audit log's operations are. +internal static class ToolJson +{ + public static string Name(TEnum value) + where TEnum : struct, Enum + { + return JsonNamingPolicy.SnakeCaseLower.ConvertName(value.ToString()); + } + + public static JsonObject Summary(ContentTypeSummary type) + { + return new JsonObject + { + ["id"] = type.Id.ToString(), + ["slug"] = type.Slug, + ["name"] = type.Name, + ["version"] = type.Version, + }; + } + + public static JsonObject ContentType(ContentType type) + { + var fields = new JsonArray(); + foreach (var field in type.Definition.Fields) + { + fields.Add(new JsonObject + { + ["name"] = field.Name, + ["type"] = field.Type, + ["required"] = field.Required, + ["description"] = field.Description, + ["settings"] = Node(field.Settings), + }); + } + + return new JsonObject + { + ["id"] = type.Id.ToString(), + ["slug"] = type.Slug, + ["name"] = type.Name, + ["version"] = type.Version, + ["entity"] = type.Definition.IsEntity, + ["description"] = type.Definition.Description, + ["fields"] = fields, + }; + } + + public static JsonObject ChangeSet(ChangeSet set) + { + var (kind, name) = set.Initiator switch + { + AgentInitiator agent => ("agent", agent.DisplayName), + _ => ("user", set.Initiator.DisplayName), + }; + + return new JsonObject + { + ["id"] = set.Id.ToString(), + ["state"] = Name(set.State), + ["rationale"] = set.Rationale, + ["sources"] = new JsonArray([.. set.Sources.Select(source => JsonValue.Create(source))]), + ["sessionId"] = set.SessionId, + ["initiator"] = new JsonObject { ["kind"] = kind, ["name"] = name }, + ["rejectionReason"] = set.RejectionReason, + ["operations"] = new JsonArray([.. set.Operations.Select(Operation)]), + }; + } + + public static JsonObject Diff(ChangeSetDiff diff) + { + var items = new JsonArray(); + foreach (var item in diff.Items) + { + var json = Operation(item.Operation); + json["properties"] = Values(item.Properties); + json["fields"] = Values(item.Fields); + items.Add(json); + } + + return new JsonObject { ["changeSetId"] = diff.ChangeSetId.ToString(), ["items"] = items }; + } + + private static JsonObject Operation(ChangeOperation operation) + { + return new JsonObject + { + ["kind"] = Name(operation.Kind), + ["itemKind"] = Name(operation.ItemKind), + ["itemId"] = operation.ItemId.ToString(), + ["baseVersion"] = operation.BaseVersion, + ["proposedVersion"] = operation.ProposedVersion, + }; + } + + private static JsonArray Values(IReadOnlyList values) + { + return new JsonArray([.. values.Select(value => new JsonObject + { + ["name"] = value.Name, + ["change"] = Name(value.Change), + ["before"] = Node(value.Before), + ["after"] = Node(value.After), + })]); + } + + private static JsonNode? Node(JsonElement? value) + { + return value is { } element ? JsonNode.Parse(element.GetRawText()) : null; + } +} diff --git a/src/FluentCMS.Core/Tools/AgentResource.cs b/src/FluentCMS.Core/Tools/AgentResource.cs new file mode 100644 index 000000000..5c86886cf --- /dev/null +++ b/src/FluentCMS.Core/Tools/AgentResource.cs @@ -0,0 +1,73 @@ +using System.Text.RegularExpressions; +using FluentCMS.Core.Results; + +namespace FluentCMS.Core.Tools; + +// One resource, at a fixed URI or at a URI template whose {name} parts each match one path segment (RFC 6570, level 1). +// Its description is written for an LLM reader, as a tool's is (MCP-02, MCP-03). +public sealed partial class AgentResource +{ + private readonly Func>> _read; + private readonly Regex _pattern; + private readonly IReadOnlyList _parameters; + + public AgentResource( + string uriTemplate, + string name, + string description, + string mimeType, + Func>> read) + { + ArgumentException.ThrowIfNullOrWhiteSpace(uriTemplate); + if (!ToolNames.IsName(name)) + { + throw new ArgumentException($"'{name}' is not a resource name; use lowercase words joined by underscores, such as 'schema_document'.", nameof(name)); + } + + ArgumentException.ThrowIfNullOrWhiteSpace(description); + ArgumentException.ThrowIfNullOrWhiteSpace(mimeType); + ArgumentNullException.ThrowIfNull(read); + + UriTemplate = uriTemplate; + Name = name; + Description = description; + MimeType = mimeType; + _read = read; + _parameters = [.. Parameter().Matches(uriTemplate).Select(match => match.Groups[1].Value)]; + _pattern = new Regex( + "^" + string.Concat(Parameter().Split(uriTemplate).Select((part, index) => index % 2 == 0 ? Regex.Escape(part) : $"(?<{part}>[^/?#]+)")) + "$", + RegexOptions.CultureInvariant); + } + + public string UriTemplate { get; } + + public string Name { get; } + + public string Description { get; } + + public string MimeType { get; } + + public bool IsTemplated => _parameters.Count > 0; + + // The template's parameters for a URI it matches, unescaped; null for any other URI. + public IReadOnlyDictionary? Match(string uri) + { + ArgumentNullException.ThrowIfNull(uri); + + var match = _pattern.Match(uri); + return match.Success + ? _parameters.ToDictionary(name => name, name => Uri.UnescapeDataString(match.Groups[name].Value), StringComparer.Ordinal) + : null; + } + + public Task> ReadAsync(ResourceRead read, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(read); + return _read(read, cancellationToken); + } + + [GeneratedRegex("\\{([a-z][a-zA-Z0-9]*)\\}")] + private static partial Regex Parameter(); +} + +public sealed record ResourceRead(string Uri, IReadOnlyDictionary Parameters, IServiceProvider Services); diff --git a/src/FluentCMS.Core/Tools/AgentTool.cs b/src/FluentCMS.Core/Tools/AgentTool.cs new file mode 100644 index 000000000..9ab8a1397 --- /dev/null +++ b/src/FluentCMS.Core/Tools/AgentTool.cs @@ -0,0 +1,59 @@ +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace FluentCMS.Core.Tools; + +// One tool. Its description and the description of every argument are written for an LLM reader: what the tool does, when to +// 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; + + public AgentTool( + string name, + string title, + string description, + JsonObject inputSchema, + bool readOnly, + Func> invoke) + { + if (!ToolNames.IsName(name)) + { + throw new ArgumentException($"'{name}' is not a tool name; use lowercase words joined by underscores, such as 'get_content_type'.", nameof(name)); + } + + ArgumentException.ThrowIfNullOrWhiteSpace(title); + ArgumentException.ThrowIfNullOrWhiteSpace(description); + ArgumentNullException.ThrowIfNull(inputSchema); + ArgumentNullException.ThrowIfNull(invoke); + + Name = name; + Title = title; + Description = description; + InputSchema = inputSchema; + ReadOnly = readOnly; + _invoke = invoke; + } + + // Agents and the tool call log know a tool by its name, so it never changes once released. + public string Name { get; } + + public string Title { get; } + + public string Description { get; } + + public JsonObject InputSchema { get; } + + // 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) + { + ArgumentNullException.ThrowIfNull(call); + return _invoke(call, cancellationToken); + } +} + +// 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); diff --git a/src/FluentCMS.Core/Tools/IResourceProvider.cs b/src/FluentCMS.Core/Tools/IResourceProvider.cs new file mode 100644 index 000000000..c71662fc5 --- /dev/null +++ b/src/FluentCMS.Core/Tools/IResourceProvider.cs @@ -0,0 +1,11 @@ +using FluentCMS.Core.Extensions; + +namespace FluentCMS.Core.Tools; + +// What agents read by URI over MCP: the schema, and later the active rules and an entry or entity (MCP-03, EXT-01). Every +// implementation must pass ToolProviderConformanceTests in tests/FluentCMS.Tests/Tools. +[ExtensionPoint] +public interface IResourceProvider +{ + IReadOnlyList Resources { get; } +} diff --git a/src/FluentCMS.Core/Tools/IToolProvider.cs b/src/FluentCMS.Core/Tools/IToolProvider.cs new file mode 100644 index 000000000..6c4faa3d0 --- /dev/null +++ b/src/FluentCMS.Core/Tools/IToolProvider.cs @@ -0,0 +1,11 @@ +using FluentCMS.Core.Extensions; + +namespace FluentCMS.Core.Tools; + +// The tools of one capability, which agents call over MCP (MCP-01, MCP-02, EXT-01). Built-ins under FluentCMS.Core.Builtin and +// plugins implement it alike, and every implementation must pass ToolProviderConformanceTests in tests/FluentCMS.Tests/Tools. +[ExtensionPoint] +public interface IToolProvider +{ + IReadOnlyList Tools { get; } +} diff --git a/src/FluentCMS.Core/Tools/ToolArgumentsReader.cs b/src/FluentCMS.Core/Tools/ToolArgumentsReader.cs new file mode 100644 index 000000000..95c371f39 --- /dev/null +++ b/src/FluentCMS.Core/Tools/ToolArgumentsReader.cs @@ -0,0 +1,129 @@ +using System.Text.Json; +using FluentCMS.Core.Results; + +namespace FluentCMS.Core.Tools; + +// Reads a tool's arguments and collects what is wrong, by argument name, so every tool words its errors alike. Every argument a +// tool asks for counts as known; anything else is reported as unknown, so an agent's typo comes back as an error instead of +// being ignored (UX-03), as FieldSettingsReader does for field settings. A tool reads all its arguments before calling Errors(). +public sealed class ToolArgumentsReader +{ + private readonly JsonElement _arguments; + private readonly List _known = []; + private readonly List _errors = []; + + public ToolArgumentsReader(JsonElement arguments) + { + _arguments = arguments; + + if (arguments.ValueKind is not (JsonValueKind.Object or JsonValueKind.Undefined or JsonValueKind.Null)) + { + _errors.Add(new Error(ToolErrorCodes.ArgumentsNotObject, "The arguments must be a JSON object with one property per argument.")); + } + } + + public string? RequiredString(string name, string example) + { + return String(name, example, required: true); + } + + public string? OptionalString(string name, string example) + { + return String(name, example, required: false); + } + + private string? String(string name, string example, bool required) + { + if (Read(name) is not { } value) + { + if (required) + { + Missing(name, $"a string such as '{example}'"); + } + + return null; + } + + if (value.ValueKind == JsonValueKind.String && value.GetString() is { } text && !string.IsNullOrWhiteSpace(text)) + { + return text; + } + + Invalid(name, $"must be a non-empty string such as '{example}'"); + return null; + } + + public Guid? RequiredGuid(string name) + { + 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)) + { + return id; + } + + Invalid(name, "must be a UUID string such as '0192f3a1-7c2e-7a4b-9d3e-1f2a3b4c5d6e'"); + return null; + } + + public int? OptionalPositiveInteger(string name) + { + if (Read(name) is not { } value) + { + return null; + } + + 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; + } + + public IReadOnlyList Errors() + { + var errors = new List(_errors); + + if (_arguments.ValueKind == JsonValueKind.Object) + { + var known = string.Join(", ", _known.Select(name => $"'{name}'")); + errors.AddRange(_arguments.EnumerateObject() + .Where(argument => !_known.Contains(argument.Name, StringComparer.Ordinal)) + .Select(argument => new Error( + ToolErrorCodes.ArgumentUnknown, + _known.Count == 0 + ? $"This tool takes no arguments, and '{argument.Name}' was given." + : $"'{argument.Name}' is not an argument of this tool; it takes {known}."))); + } + + return errors; + } + + private JsonElement? Read(string name) + { + _known.Add(name); + + return _arguments.ValueKind == JsonValueKind.Object + && _arguments.TryGetProperty(name, out var value) + && value.ValueKind != JsonValueKind.Null + ? value + : null; + } + + + private void Missing(string name, string form) + { + _errors.Add(new Error(ToolErrorCodes.ArgumentMissing, $"'{name}' is required: give {form}.")); + } + + private void Invalid(string name, string requirement) + { + _errors.Add(new Error(ToolErrorCodes.ArgumentInvalid, $"'{name}' {requirement}.")); + } +} diff --git a/src/FluentCMS.Core/Tools/ToolErrorCodes.cs b/src/FluentCMS.Core/Tools/ToolErrorCodes.cs new file mode 100644 index 000000000..4cd9021d7 --- /dev/null +++ b/src/FluentCMS.Core/Tools/ToolErrorCodes.cs @@ -0,0 +1,10 @@ +namespace FluentCMS.Core.Tools; + +public static class ToolErrorCodes +{ + public const string ArgumentsNotObject = "arguments_not_object"; + public const string ArgumentMissing = "argument_missing"; + public const string ArgumentInvalid = "argument_invalid"; + public const string ArgumentUnknown = "argument_unknown"; + public const string ResourceNotFound = "resource_not_found"; +} diff --git a/src/FluentCMS.Core/Tools/ToolNames.cs b/src/FluentCMS.Core/Tools/ToolNames.cs new file mode 100644 index 000000000..508533a82 --- /dev/null +++ b/src/FluentCMS.Core/Tools/ToolNames.cs @@ -0,0 +1,16 @@ +using System.Text.RegularExpressions; + +namespace FluentCMS.Core.Tools; + +public static partial class ToolNames +{ + public const int MaxLength = 64; + + public static bool IsName(string? name) + { + return name is { Length: > 0 and <= MaxLength } && Pattern().IsMatch(name); + } + + [GeneratedRegex("^[a-z][a-z0-9]*(_[a-z0-9]+)*$")] + private static partial Regex Pattern(); +} diff --git a/src/FluentCMS.Core/Tools/ToolResult.cs b/src/FluentCMS.Core/Tools/ToolResult.cs new file mode 100644 index 000000000..d8aff1bc9 --- /dev/null +++ b/src/FluentCMS.Core/Tools/ToolResult.cs @@ -0,0 +1,73 @@ +using System.Text.Json.Nodes; +using FluentCMS.Core.Results; + +namespace FluentCMS.Core.Tools; + +// What a tool returns: a JSON value, or the errors that say what to change and, where it helps, the call that finds a valid +// value (UX-03). An expected failure is a result, never an exception, so the agent reads it and tries again. +public sealed class ToolResult +{ + private ToolResult(JsonNode? value, IReadOnlyList errors, string? hint) + { + Value = value; + Errors = errors; + Hint = hint; + } + + public bool IsSuccess => Errors.Count == 0; + + public JsonNode? Value { get; } + + public IReadOnlyList Errors { get; } + + public string? Hint { get; } + + public static ToolResult Success(JsonNode? value) + { + return new ToolResult(value, [], null); + } + + public static ToolResult Failure(IEnumerable errors, string? hint = null) + { + ArgumentNullException.ThrowIfNull(errors); + + var list = errors.ToList(); + if (list.Count == 0) + { + throw new ArgumentException("A failed tool result names at least one error.", nameof(errors)); + } + + return new ToolResult(null, list, hint); + } + + public static ToolResult From(Result result, Func project, string? hint = null) + { + ArgumentNullException.ThrowIfNull(result); + ArgumentNullException.ThrowIfNull(project); + + return result.IsSuccess ? Success(project(result.Value)) : Failure(result.Errors, hint); + } + + // The JSON an agent reads either way: the value itself, or {"errors": [{"code", "message"}], "hint"}. + public JsonNode? ToJson() + { + if (IsSuccess) + { + return Value?.DeepClone(); + } + + var errors = new JsonArray(); + foreach (var error in Errors) + { + errors.Add(new JsonObject { ["code"] = error.Code, ["message"] = error.Message }); + } + + var failure = new JsonObject { ["errors"] = errors }; + if (Hint is not null) + { + failure["hint"] = Hint; + } + + return failure; + } +} diff --git a/src/FluentCMS.Core/Tools/ToolSchemas.cs b/src/FluentCMS.Core/Tools/ToolSchemas.cs new file mode 100644 index 000000000..ef755f361 --- /dev/null +++ b/src/FluentCMS.Core/Tools/ToolSchemas.cs @@ -0,0 +1,55 @@ +using System.Text.Json.Nodes; + +namespace FluentCMS.Core.Tools; + +// Builds tool input schemas (JSON Schema, draft 2020-12) the way every tool states them: an object that names and describes +// each argument, with an example, and refuses any other argument. +public static class ToolSchemas +{ + public static JsonObject Object(params ToolArgument[] arguments) + { + var properties = new JsonObject(); + var required = new JsonArray(); + + foreach (var argument in arguments) + { + var schema = argument.Schema.DeepClone().AsObject(); + schema["description"] = argument.Description; + properties[argument.Name] = schema; + + if (argument.Required) + { + required.Add(argument.Name); + } + } + + return new JsonObject + { + ["type"] = "object", + ["properties"] = properties, + ["required"] = required, + ["additionalProperties"] = false, + }; + } + + public static ToolArgument String(string name, string description, string example, bool required = true) + { + return new ToolArgument(name, description, required, new JsonObject { ["type"] = "string", ["minLength"] = 1, ["examples"] = new JsonArray(example) }); + } + + public static ToolArgument Uuid(string name, string description, bool required = true) + { + return new ToolArgument( + name, + description, + required, + new JsonObject { ["type"] = "string", ["format"] = "uuid", ["examples"] = new JsonArray("0192f3a1-7c2e-7a4b-9d3e-1f2a3b4c5d6e") }); + } + + 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) }); + } +} + +public sealed record ToolArgument(string Name, string Description, bool Required, JsonObject Schema); diff --git a/src/FluentCMS.Mcp/CoreTools.cs b/src/FluentCMS.Mcp/CoreTools.cs new file mode 100644 index 000000000..f60ae01a3 --- /dev/null +++ b/src/FluentCMS.Mcp/CoreTools.cs @@ -0,0 +1,117 @@ +using System.Text.Encodings.Web; +using System.Text.Json; +using FluentCMS.Core.Tools; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using ModelContextProtocol; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; + +namespace FluentCMS.Mcp; + +// Puts every tool and resource the Core providers register on the server, built-in and plugin alike (EXT-02). A name used twice +// stops the host at startup rather than letting one tool hide another. +internal sealed class CoreToolsSetup(IEnumerable tools, IEnumerable resources) : IConfigureOptions +{ + public void Configure(McpServerOptions options) + { + options.ToolCollection ??= []; + foreach (var tool in tools.SelectMany(provider => provider.Tools)) + { + if (!options.ToolCollection.TryAdd(new CoreTool(tool))) + { + throw new InvalidOperationException($"Two tools are named '{tool.Name}'; every tool needs a name of its own."); + } + } + + options.ResourceCollection ??= []; + foreach (var resource in resources.SelectMany(provider => provider.Resources)) + { + if (!options.ResourceCollection.TryAdd(new CoreResource(resource))) + { + throw new InvalidOperationException($"Two resources are named '{resource.Name}' or share the URI '{resource.UriTemplate}'."); + } + } + } +} + +// A failure is a tool error the agent reads and acts on, never a protocol error (UX-03). The JSON goes out as structured +// content and, for clients that read only text, as the text of the result. +internal sealed class CoreTool(AgentTool tool) : McpServerTool +{ + // The text is read by a model, not put in a page, so quotes and apostrophes stay as they are. + private static readonly JsonSerializerOptions Text = new() { Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping }; + + public override Tool ProtocolTool { get; } = new() + { + Name = tool.Name, + Title = tool.Title, + Description = tool.Description, + InputSchema = JsonSerializer.SerializeToElement(tool.InputSchema), + Annotations = new ToolAnnotations + { + Title = tool.Title, + ReadOnlyHint = tool.ReadOnly, + DestructiveHint = false, + OpenWorldHint = false, + }, + }; + + public override IReadOnlyList Metadata => []; + + public override async ValueTask InvokeAsync(RequestContext request, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + + var arguments = JsonSerializer.SerializeToElement(request.Params?.Arguments ?? new Dictionary()); + var result = await tool.InvokeAsync(new ToolCall(arguments, request.Services!), cancellationToken); + var node = result.ToJson(); + var json = JsonSerializer.SerializeToElement(node); + + return new CallToolResult + { + Content = [new TextContentBlock { Text = node?.ToJsonString(Text) ?? "null" }], + StructuredContent = json.ValueKind == JsonValueKind.Object ? json : null, + IsError = !result.IsSuccess, + }; + } +} + +internal sealed class CoreResource(AgentResource resource) : McpServerResource +{ + public override ResourceTemplate ProtocolResourceTemplate { get; } = new() + { + Name = resource.Name, + UriTemplate = resource.UriTemplate, + Description = resource.Description, + MimeType = resource.MimeType, + }; + + public override IReadOnlyList Metadata => []; + + public override bool IsMatch(string uri) + { + return resource.Match(uri) is not null; + } + + public override async ValueTask ReadAsync(RequestContext request, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + + var uri = request.Params.Uri; + var parameters = resource.Match(uri) + ?? throw new McpProtocolException($"The resource '{resource.Name}' does not serve '{uri}'.", McpErrorCode.ResourceNotFound); + + var read = await resource.ReadAsync(new ResourceRead(uri, parameters, request.Services!), cancellationToken); + if (!read.IsSuccess) + { + // A resource read has no error result, so an expected failure is a protocol error that still says what to change. + throw new McpProtocolException(string.Join(" ", read.Errors.Select(error => error.Message)), McpErrorCode.ResourceNotFound); + } + + return new ReadResourceResult + { + Contents = [new TextResourceContents { Uri = uri, MimeType = resource.MimeType, Text = read.Value }], + }; + } +} diff --git a/src/FluentCMS.Mcp/McpServiceCollectionExtensions.cs b/src/FluentCMS.Mcp/McpServiceCollectionExtensions.cs index fd273ab4f..ecd445ff7 100644 --- a/src/FluentCMS.Mcp/McpServiceCollectionExtensions.cs +++ b/src/FluentCMS.Mcp/McpServiceCollectionExtensions.cs @@ -1,7 +1,9 @@ using FluentCMS.Core.Identity; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; using ModelContextProtocol.AspNetCore; using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; namespace FluentCMS.Mcp; @@ -37,6 +39,10 @@ public static IServiceCollection AddFluentCmsMcpStdio(this IServiceCollection se private static IMcpServerBuilder AddServer(IServiceCollection services) { + // Built when the host starts, so two tools with one name stop it there instead of at the first call. + services.AddSingleton, CoreToolsSetup>(); + services.AddOptions().ValidateOnStart(); + return services.AddMcpServer(options => { // Without this the SDK names the server after the entry assembly. @@ -45,9 +51,6 @@ private static IMcpServerBuilder AddServer(IServiceCollection services) Name = ServerName, Version = typeof(McpServiceCollectionExtensions).Assembly.GetName().Version?.ToString(3) ?? "0.0.0", }; - - // Tools arrive once agents authenticate; until then clients find an empty list rather than no tools capability. - options.Capabilities = new ServerCapabilities { Tools = new ToolsCapability() }; }).WithRequestFilters(filters => filters.AddCallToolFilter(ToolCallRecording.Filter)); } } diff --git a/tests/FluentCMS.IntegrationTests/Mcp/AgentAuthenticationTests.cs b/tests/FluentCMS.IntegrationTests/Mcp/AgentAuthenticationTests.cs index 5039b9ffd..d2a13963b 100644 --- a/tests/FluentCMS.IntegrationTests/Mcp/AgentAuthenticationTests.cs +++ b/tests/FluentCMS.IntegrationTests/Mcp/AgentAuthenticationTests.cs @@ -25,7 +25,7 @@ public async Task An_agent_with_a_key_connects_over_http_and_every_call_runs_as_ await using var client = await McpAgents.ConnectOverHttpAsync(host, issued.Key); - Assert.Equal(["whoami"], (await client.ListToolsAsync()).Select(tool => tool.Name)); + Assert.Contains("whoami", await McpAgents.ToolNamesAsync(client)); Assert.Equal($"agent {issued.Credential.Id} pipeline-agent", await McpAgents.WhoAmIAsync(client)); } @@ -168,7 +168,7 @@ public async Task A_key_revoked_during_a_stdio_session_is_refused_from_its_next_ }); await using var client = await stdio.ConnectAsync(); - Assert.Empty(await client.ListToolsAsync()); + Assert.Equal(McpAgents.BuiltInTools, await McpAgents.ToolNamesAsync(client)); await McpAgents.RevokeAsync(database.Configuration, issued.Credential.Id); var refused = await Assert.ThrowsAnyAsync(() => client.ListToolsAsync().AsTask()); diff --git a/tests/FluentCMS.IntegrationTests/Mcp/BuiltInToolsTests.cs b/tests/FluentCMS.IntegrationTests/Mcp/BuiltInToolsTests.cs new file mode 100644 index 000000000..88efd5ec6 --- /dev/null +++ b/tests/FluentCMS.IntegrationTests/Mcp/BuiltInToolsTests.cs @@ -0,0 +1,242 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using FluentCMS.Core.ChangeSets; +using FluentCMS.Core.Extensions; +using FluentCMS.Core.Results; +using FluentCMS.Core.Schema; +using FluentCMS.Core.Tools; +using FluentCMS.Host; +using FluentCMS.Infrastructure.Persistence; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol; +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; + +namespace FluentCMS.IntegrationTests.Mcp; + +public sealed class BuiltInToolsTests +{ + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task An_agent_reads_content_types_and_the_schema_document_with_the_schema_tools(string provider) + { + using var install = new TemporaryInstall(); + await using var database = await TemporaryDatabase.CreateAsync(provider); + await using var host = await AdminHost.StartAsync(install, settings: database.Configuration); + var administrator = await host.CreateFirstAdministratorAsync(); + await CreateTypesAsync(host, administrator); + var issued = await McpAgents.IssueAsync(host, administrator); + await using var client = await McpAgents.ConnectOverHttpAsync(host, issued.Key); + + var tools = await client.ListToolsAsync(); + Assert.Equal(McpAgents.BuiltInTools, tools.Select(tool => tool.Name).Order(StringComparer.Ordinal)); + Assert.All(tools, tool => + { + Assert.False(string.IsNullOrWhiteSpace(tool.Description)); + Assert.Equal("object", tool.ProtocolTool.InputSchema.GetProperty("type").GetString()); + Assert.True(tool.ProtocolTool.Annotations?.ReadOnlyHint); + }); + + var list = await CallAsync(client, "list_content_types"); + Assert.Equal(["category", "product"], list["contentTypes"]!.AsArray().Select(type => (string?)type!["slug"])); + Assert.Equal(2, (int)list["contentTypes"]![1]!["version"]!); + + var current = await CallAsync(client, "get_content_type", new() { ["slug"] = "product" }); + var first = await CallAsync(client, "get_content_type", new() { ["slug"] = "product", ["version"] = 1 }); + Assert.Equal(2, (int)current["version"]!); + Assert.Equal(["title", "price"], current["fields"]!.AsArray().Select(field => (string?)field!["name"])); + Assert.Equal("number", (string?)current["fields"]![1]!["type"]); + Assert.Equal(["title"], first["fields"]!.AsArray().Select(field => (string?)field!["name"])); + + var site = await CallAsync(client, "get_schema_document"); + var product = await CallAsync(client, "get_schema_document", new() { ["slug"] = "product" }); + var documents = await host.AsUserAsync( + administrator, + async schema => (await schema.GetSiteAsync(), (await schema.GetTypeAsync("product")).Value)); + Assert.True(JsonNode.DeepEquals(documents.Site, site)); + Assert.True(JsonNode.DeepEquals(documents.Product, product)); + } + + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task An_agent_reads_a_change_set_and_its_diff_with_the_change_set_tools(string provider) + { + using var install = new TemporaryInstall(); + await using var database = await TemporaryDatabase.CreateAsync(provider); + await using var host = await AdminHost.StartAsync(install, settings: database.Configuration); + var administrator = await host.CreateFirstAdministratorAsync(); + var product = await CreateTypesAsync(host, administrator); + var changeSet = await host.AsUserAsync(administrator, async directory => + { + var opened = (await directory.OpenAsync(new NewChangeSet("Add a price to products.", ["https://example.com/spec"]))).Value; + var update = ChangeOperation.Create(OperationKind.Update, ItemKind.Schema, product.Id, 1, 2).Value; + return (await directory.AddOperationAsync(opened.Id, update)).Value; + }); + var issued = await McpAgents.IssueAsync(host, administrator); + await using var client = await McpAgents.ConnectOverHttpAsync(host, issued.Key); + + var set = await CallAsync(client, "get_change_set", new() { ["id"] = changeSet.Id.ToString() }); + var diff = await CallAsync(client, "get_change_set_diff", new() { ["id"] = changeSet.Id.ToString() }); + + Assert.Equal("open", (string?)set["state"]); + Assert.Equal("Add a price to products.", (string?)set["rationale"]); + Assert.Equal(("user", "Test administrator"), ((string?)set["initiator"]!["kind"], (string?)set["initiator"]!["name"])); + var operation = Assert.Single(set["operations"]!.AsArray())!; + Assert.Equal(("update", "schema", product.Id.ToString(), 1, 2), ((string?)operation["kind"], (string?)operation["itemKind"], (string?)operation["itemId"], (int)operation["baseVersion"]!, (int)operation["proposedVersion"]!)); + + Assert.Equal(changeSet.Id.ToString(), (string?)diff["changeSetId"]); + var field = Assert.Single(Assert.Single(diff["items"]!.AsArray())!["fields"]!.AsArray())!; + Assert.Equal(("price", "added"), ((string?)field["name"], (string?)field["change"])); + Assert.Null(field["before"]); + Assert.Equal("number", (string?)field["after"]!["type"]); + } + + [Fact] + public async Task An_unknown_item_or_a_wrong_argument_is_a_tool_error_that_says_what_to_change() + { + using var install = new TemporaryInstall(); + await using var host = await AdminHost.StartAsync(install); + var administrator = await host.CreateFirstAdministratorAsync(); + await CreateTypesAsync(host, administrator); + var issued = await McpAgents.IssueAsync(host, administrator); + await using var client = await McpAgents.ConnectOverHttpAsync(host, issued.Key); + + var unknownType = await FailAsync(client, "get_content_type", new() { ["slug"] = "produkt" }); + var unknownVersion = await FailAsync(client, "get_content_type", new() { ["slug"] = "product", ["version"] = 9 }); + var unknownSet = await FailAsync(client, "get_change_set_diff", new() { ["id"] = Guid.NewGuid().ToString() }); + var wrong = await FailAsync(client, "get_change_set", new() { ["id"] = "42", ["include"] = "operations" }); + + Assert.Equal(SchemaErrorCodes.ContentTypeNotFound, Code(unknownType)); + Assert.Equal("Call list_content_types for the slugs of the site's content types.", (string?)unknownType["hint"]); + Assert.Equal(SchemaErrorCodes.VersionNotFound, Code(unknownVersion)); + Assert.Contains("its current version is 2", (string?)unknownVersion["errors"]![0]!["message"]); + Assert.Equal(ChangeSetErrorCodes.ChangeSetNotFound, Code(unknownSet)); + Assert.Equal( + [ToolErrorCodes.ArgumentInvalid, ToolErrorCodes.ArgumentUnknown], + wrong["errors"]!.AsArray().Select(error => (string?)error!["code"])); + } + + [Fact] + public async Task The_schema_resources_serve_the_schema_document_and_refuse_an_unknown_slug() + { + using var install = new TemporaryInstall(); + await using var host = await AdminHost.StartAsync(install); + var administrator = await host.CreateFirstAdministratorAsync(); + await CreateTypesAsync(host, administrator); + var issued = await McpAgents.IssueAsync(host, administrator); + await using var client = await McpAgents.ConnectOverHttpAsync(host, issued.Key); + + var resources = await client.ListResourcesAsync(); + var templates = await client.ListResourceTemplatesAsync(); + var site = await client.ReadResourceAsync("fluentcms://schema"); + var product = await client.ReadResourceAsync("fluentcms://schema/product"); + var unknown = await Assert.ThrowsAnyAsync(() => client.ReadResourceAsync("fluentcms://schema/produkt").AsTask()); + + Assert.Equal(["fluentcms://schema"], resources.Select(resource => resource.Uri)); + Assert.Equal(["fluentcms://schema/{slug}"], templates.Select(template => template.UriTemplate)); + var siteText = Assert.IsType(Assert.Single(site.Contents)); + Assert.Equal("application/schema+json", siteText.MimeType); + Assert.NotNull(JsonNode.Parse(siteText.Text)!["$defs"]!["product"]); + Assert.Equal( + "urn:fluentcms:content-type:product:2", + (string?)JsonNode.Parse(Assert.IsType(Assert.Single(product.Contents)).Text)!["$id"]); + Assert.Contains("There is no content type 'produkt'", unknown.Message); + } + + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task The_tools_answer_over_stdio_as_over_http(string provider) + { + await using var database = await TemporaryDatabase.CreateAsync(provider); + var issued = await McpAgents.IssueAsync(database.Configuration); + await using var stdio = await InProcessStdio.StartAsync(database.Configuration, issued.Key); + await using var client = await stdio.ConnectAsync(); + + var list = await CallAsync(client, "list_content_types"); + var unknown = await FailAsync(client, "get_content_type", new() { ["slug"] = "product" }); + + Assert.Contains("whoami", await McpAgents.ToolNamesAsync(client)); + Assert.Empty(list["contentTypes"]!.AsArray()); + Assert.Equal(SchemaErrorCodes.ContentTypeNotFound, Code(unknown)); + } + + [Fact] + public async Task Two_tools_with_one_name_stop_the_host_at_startup() + { + using var install = new TemporaryInstall(); + var app = FluentCmsHost.BuildWebApplication([], builder => + { + builder.WebHost.UseUrls("http://127.0.0.1:0"); + builder.Configuration.AddInMemoryCollection(install.Configuration); + builder.Services.AddFluentCmsExtension(); + }); + + await using (app) + { + var failure = await Assert.ThrowsAnyAsync(() => app.StartAsync()); + Assert.Contains("Two tools are named 'list_content_types'", failure.ToString()); + } + } + + private static async Task CreateTypesAsync(AdminHost host, Guid administrator) + { + return await host.AsUserAsync(administrator, async directory => + { + var title = new FieldDefinition("title", "text", required: true); + await directory.CreateAsync(new NewContentType("Category", "category", new ContentTypeDefinition([title]))); + await directory.CreateAsync(new NewContentType("Product", "product", new ContentTypeDefinition([title]))); + return (await directory.ReviseAsync("product", 1, new ContentTypeDefinition([title, new FieldDefinition("price", "number")]))).Value; + }); + } + + private static async Task CallAsync(McpClient client, string tool, Dictionary? arguments = null) + { + var result = await client.CallToolAsync(tool, arguments); + Assert.False(result.IsError ?? false, Text(result)); + return Structured(result); + } + + private static async Task FailAsync(McpClient client, string tool, Dictionary arguments) + { + var result = await client.CallToolAsync(tool, arguments); + Assert.True(result.IsError, Text(result)); + return Structured(result); + } + + // The structured content and the text carry the same JSON, for clients that read only one of them. + private static JsonObject Structured(CallToolResult result) + { + var structured = JsonNode.Parse(result.StructuredContent!.Value.GetRawText())!.AsObject(); + Assert.True(JsonNode.DeepEquals(structured, JsonNode.Parse(Text(result)))); + return structured; + } + + private static string Text(CallToolResult result) + { + return Assert.IsType(Assert.Single(result.Content)).Text; + } + + private static string? Code(JsonObject failure) + { + return (string?)failure["errors"]![0]!["code"]; + } + + private sealed class DuplicateTools : IToolProvider + { + public IReadOnlyList Tools { get; } = + [ + new AgentTool( + "list_content_types", + "Another list", + "A second tool with a built-in tool's name, which the host must refuse when it starts.", + ToolSchemas.Object(), + readOnly: true, + (_, _) => Task.FromResult(ToolResult.Success(null))), + ]; + } +} diff --git a/tests/FluentCMS.IntegrationTests/Mcp/McpAgents.cs b/tests/FluentCMS.IntegrationTests/Mcp/McpAgents.cs index bb8190c27..36e0bb6eb 100644 --- a/tests/FluentCMS.IntegrationTests/Mcp/McpAgents.cs +++ b/tests/FluentCMS.IntegrationTests/Mcp/McpAgents.cs @@ -18,6 +18,14 @@ 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 async Task ToolNamesAsync(McpClient client) + { + return [.. (await client.ListToolsAsync()).Select(tool => tool.Name).Order(StringComparer.Ordinal)]; + } + // Reports who a call runs as, so a test can see the initiator the services would record. public static McpServerTool WhoAmI { get; } = McpServerTool.Create( (IServiceProvider services) => services.GetRequiredService().Current switch diff --git a/tests/FluentCMS.IntegrationTests/Mcp/McpServerTests.cs b/tests/FluentCMS.IntegrationTests/Mcp/McpServerTests.cs index 62a6c8dfc..48dd8857c 100644 --- a/tests/FluentCMS.IntegrationTests/Mcp/McpServerTests.cs +++ b/tests/FluentCMS.IntegrationTests/Mcp/McpServerTests.cs @@ -23,7 +23,7 @@ public async Task A_client_connects_over_http_at_mcp_and_gets_no_session(string Assert.Equal(protocolVersion, client.NegotiatedProtocolVersion); AssertFluentCmsServer(client); - Assert.Empty(await client.ListToolsAsync()); + Assert.Equal(McpAgents.BuiltInTools, await McpAgents.ToolNamesAsync(client)); Assert.Null(client.SessionId); // A stateless server offers no stream to GET. Assert.Equal(HttpStatusCode.MethodNotAllowed, stream.StatusCode); @@ -52,7 +52,7 @@ public async Task Fluentcms_mcp_serves_stdio_on_the_database_the_web_host_uses(s AssertFluentCmsServer(client); Assert.Equal(http.ServerInfo.Version, client.ServerInfo.Version); - Assert.Empty(await client.ListToolsAsync()); + Assert.Equal(McpAgents.BuiltInTools, await McpAgents.ToolNamesAsync(client)); } Assert.Equal(0, await stdio.CloseInputAndWaitForExitAsync()); diff --git a/tests/FluentCMS.Tests/Tools/ToolConventionsTests.cs b/tests/FluentCMS.Tests/Tools/ToolConventionsTests.cs new file mode 100644 index 000000000..c89ef7116 --- /dev/null +++ b/tests/FluentCMS.Tests/Tools/ToolConventionsTests.cs @@ -0,0 +1,107 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using FluentCMS.Core.Results; +using FluentCMS.Core.Tools; + +namespace FluentCMS.Tests.Tools; + +public sealed class ToolConventionsTests +{ + [Fact] + public void The_reader_returns_the_arguments_it_is_asked_for() + { + var reader = Reader("""{"slug": "product", "version": 2, "id": "0192f3a1-7c2e-7a4b-9d3e-1f2a3b4c5d6e"}"""); + + Assert.Equal("product", reader.RequiredString("slug", "product")); + Assert.Equal(2, reader.OptionalPositiveInteger("version")); + Assert.Equal(Guid.Parse("0192f3a1-7c2e-7a4b-9d3e-1f2a3b4c5d6e"), reader.RequiredGuid("id")); + Assert.Null(reader.OptionalString("summary", "short")); + Assert.Empty(reader.Errors()); + } + + [Fact] + public void A_missing_malformed_or_unknown_argument_is_reported_by_name() + { + var reader = Reader("""{"version": 0, "id": "not-a-uuid", "slg": "product", "summary": " "}"""); + + reader.RequiredString("slug", "product"); + reader.OptionalPositiveInteger("version"); + reader.RequiredGuid("id"); + reader.OptionalString("summary", "short"); + var errors = reader.Errors(); + + Assert.Equal( + [ + (ToolErrorCodes.ArgumentMissing, "'slug' is required: give a string such as 'product'."), + (ToolErrorCodes.ArgumentInvalid, "'version' must be a whole number greater than zero."), + (ToolErrorCodes.ArgumentInvalid, "'id' must be a UUID string such as '0192f3a1-7c2e-7a4b-9d3e-1f2a3b4c5d6e'."), + (ToolErrorCodes.ArgumentInvalid, "'summary' must be a non-empty string such as 'short'."), + (ToolErrorCodes.ArgumentUnknown, "'slg' is not an argument of this tool; it takes 'slug', 'version', 'id', 'summary'."), + ], + errors.Select(error => (error.Code, error.Message))); + } + + [Fact] + public void A_tool_without_arguments_accepts_none_and_null_and_refuses_a_value_that_is_not_an_object() + { + Assert.Empty(Reader("{}").Errors()); + Assert.Empty(new ToolArgumentsReader(default).Errors()); + Assert.Empty(Reader("null").Errors()); + Assert.Equal("This tool takes no arguments, and 'slug' was given.", Assert.Single(Reader("""{"slug": "product"}""").Errors()).Message); + Assert.Equal(ToolErrorCodes.ArgumentsNotObject, Assert.Single(Reader("[1]").Errors()).Code); + } + + [Fact] + public void A_result_is_the_value_or_the_errors_with_a_hint() + { + var success = ToolResult.Success(new JsonObject { ["slug"] = "product" }); + var failure = ToolResult.From( + Result.Failure("content_type_not_found", "There is no content type 'produkt' in this site."), + value => value, + "Call list_content_types for the slugs."); + + Assert.Equal("""{"slug":"product"}""", success.ToJson()!.ToJsonString()); + Assert.False(failure.IsSuccess); + Assert.True(JsonNode.DeepEquals( + JsonNode.Parse("""{"errors":[{"code":"content_type_not_found","message":"There is no content type 'produkt' in this site."}],"hint":"Call list_content_types for the slugs."}"""), + failure.ToJson())); + Assert.Throws(() => ToolResult.Failure([])); + } + + [Fact] + public void A_resource_template_matches_one_segment_per_parameter_and_unescapes_it() + { + var resource = new AgentResource( + "fluentcms://schema/{slug}", + "content_type_schema", + "The schema of one content type, for the tests.", + "application/json", + (_, _) => Task.FromResult(Result.Success("{}"))); + + Assert.True(resource.IsTemplated); + Assert.Equal("product-page", resource.Match("fluentcms://schema/product-page")!["slug"]); + Assert.Equal("a b", resource.Match("fluentcms://schema/a%20b")!["slug"]); + Assert.Null(resource.Match("fluentcms://schema/product/fields")); + Assert.Null(resource.Match("fluentcms://schema/")); + Assert.Null(resource.Match("fluentcms://schemas/product")); + } + + [Theory] + [InlineData("get_content_type", true)] + [InlineData("list", true)] + [InlineData("get2_items", true)] + [InlineData("Get_content_type", false)] + [InlineData("get-content-type", false)] + [InlineData("get__type", false)] + [InlineData("_get", false)] + [InlineData("", false)] + public void Tool_names_are_lowercase_words_joined_by_underscores(string name, bool valid) + { + Assert.Equal(valid, ToolNames.IsName(name)); + } + + private static ToolArgumentsReader Reader(string json) + { + return new ToolArgumentsReader(JsonDocument.Parse(json).RootElement); + } +} diff --git a/tests/FluentCMS.Tests/Tools/ToolProviderConformanceTests.cs b/tests/FluentCMS.Tests/Tools/ToolProviderConformanceTests.cs new file mode 100644 index 000000000..6b110c7df --- /dev/null +++ b/tests/FluentCMS.Tests/Tools/ToolProviderConformanceTests.cs @@ -0,0 +1,111 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using FluentCMS.Core.Builtin.Tools; +using FluentCMS.Core.Tools; +using FluentCMS.Tests.Schema; + +namespace FluentCMS.Tests.Tools; + +// The contract every tool and resource provider meets, built in or not (EXT-N1, MCP-02): an agent can read what each tool does +// and what each argument takes, and an argument the tool does not know comes back as an error before anything is read. +// Derive one class per provider and return it from Provider. +public abstract class ToolProviderConformanceTests +{ + protected abstract object Provider { get; } + + private IReadOnlyList Tools => (Provider as IToolProvider)?.Tools ?? []; + + private IReadOnlyList Resources => (Provider as IResourceProvider)?.Resources ?? []; + + [Fact] + public void The_provider_offers_tools_or_resources_with_names_of_their_own() + { + Assert.True(Tools.Count + Resources.Count > 0, "A provider offers at least one tool or resource."); + Assert.Equal(Tools.Count, Tools.Select(tool => tool.Name).Distinct(StringComparer.Ordinal).Count()); + Assert.Equal(Resources.Count, Resources.Select(resource => resource.Name).Distinct(StringComparer.Ordinal).Count()); + Assert.All(Tools, tool => Assert.True(ToolNames.IsName(tool.Name), tool.Name)); + } + + [Fact] + public void Every_tool_and_resource_is_described_for_an_agent() + { + Assert.All(Tools, tool => + { + Assert.False(string.IsNullOrWhiteSpace(tool.Title), tool.Name); + Assert.True(tool.Description.Length >= 60, $"{tool.Name} says in a sentence or two what it does and when to call it."); + }); + Assert.All(Resources, resource => Assert.True(resource.Description.Length >= 40, resource.Name)); + } + + [Fact] + public async Task Every_input_schema_is_an_object_that_describes_its_arguments_and_refuses_others() + { + foreach (var tool in Tools) + { + var schema = tool.InputSchema; + Assert.Equal("object", (string?)schema["type"]); + Assert.False((bool?)schema["additionalProperties"] ?? true, $"{tool.Name} refuses arguments it does not name."); + + var properties = schema["properties"]?.AsObject() ?? []; + foreach (var (name, property) in properties) + { + Assert.False(string.IsNullOrWhiteSpace((string?)property?["description"]), $"{tool.Name}.{name} has a description."); + var examples = property?["examples"]?.AsArray(); + Assert.True(examples is { Count: > 0 }, $"{tool.Name}.{name} has an example."); + foreach (var example in examples!) + { + Assert.Empty(await JsonSchemaValidation.ErrorsAsync(property!, example)); + } + } + + Assert.All(schema["required"]?.AsArray() ?? [], required => Assert.True(properties.ContainsKey((string)required!), tool.Name)); + } + } + + [Fact] + public async Task An_unknown_argument_is_an_error_before_any_service_is_read() + { + var arguments = JsonDocument.Parse("""{"no_such_argument": 1}""").RootElement; + + foreach (var tool in Tools) + { + var result = await tool.InvokeAsync(new ToolCall(arguments, new NoServices())); + + Assert.False(result.IsSuccess, tool.Name); + Assert.Contains(result.Errors, error => error.Code == ToolErrorCodes.ArgumentUnknown && error.Message.Contains("no_such_argument")); + Assert.Equal(["errors"], result.ToJson()!.AsObject().Select(property => property.Key).Where(key => key != "hint")); + } + } + + [Fact] + public void Every_resource_has_a_uri_with_a_scheme_and_a_fixed_one_matches_itself() + { + Assert.All(Resources, resource => + { + Assert.Contains("://", resource.UriTemplate); + if (!resource.IsTemplated) + { + Assert.Empty(resource.Match(resource.UriTemplate)!); + } + }); + } + + // A tool that reads a service before checking its arguments fails the test above with this exception. + private sealed class NoServices : IServiceProvider + { + public object? GetService(Type serviceType) + { + throw new InvalidOperationException($"The tool read {serviceType.Name} before checking its arguments."); + } + } +} + +public sealed class SchemaToolsConformanceTests : ToolProviderConformanceTests +{ + protected override object Provider { get; } = new SchemaTools(); +} + +public sealed class ChangeSetToolsConformanceTests : ToolProviderConformanceTests +{ + protected override object Provider { get; } = new ChangeSetTools(); +} From b9fbc68bfc5def69c230fd9a51ee381aa69975d0 Mon Sep 17 00:00:00 2001 From: Amir Pournasserian Date: Sun, 18 Oct 2026 19:10:31 -0400 Subject: [PATCH 2/4] Check resource names once and keep not found apart from other read failures --- .../Tools/ToolArgumentsReader.cs | 42 +++++++++---------- src/FluentCMS.Mcp/CoreTools.cs | 8 +++- 2 files changed, 27 insertions(+), 23 deletions(-) diff --git a/src/FluentCMS.Core/Tools/ToolArgumentsReader.cs b/src/FluentCMS.Core/Tools/ToolArgumentsReader.cs index 95c371f39..35232c217 100644 --- a/src/FluentCMS.Core/Tools/ToolArgumentsReader.cs +++ b/src/FluentCMS.Core/Tools/ToolArgumentsReader.cs @@ -32,27 +32,6 @@ public ToolArgumentsReader(JsonElement arguments) return String(name, example, required: false); } - private string? String(string name, string example, bool required) - { - if (Read(name) is not { } value) - { - if (required) - { - Missing(name, $"a string such as '{example}'"); - } - - return null; - } - - if (value.ValueKind == JsonValueKind.String && value.GetString() is { } text && !string.IsNullOrWhiteSpace(text)) - { - return text; - } - - Invalid(name, $"must be a non-empty string such as '{example}'"); - return null; - } - public Guid? RequiredGuid(string name) { if (Read(name) is not { } value) @@ -105,6 +84,27 @@ public IReadOnlyList Errors() return errors; } + private string? String(string name, string example, bool required) + { + if (Read(name) is not { } value) + { + if (required) + { + Missing(name, $"a string such as '{example}'"); + } + + return null; + } + + if (value.ValueKind == JsonValueKind.String && value.GetString() is { } text && !string.IsNullOrWhiteSpace(text)) + { + return text; + } + + Invalid(name, $"must be a non-empty string such as '{example}'"); + return null; + } + private JsonElement? Read(string name) { _known.Add(name); diff --git a/src/FluentCMS.Mcp/CoreTools.cs b/src/FluentCMS.Mcp/CoreTools.cs index f60ae01a3..64857e363 100644 --- a/src/FluentCMS.Mcp/CoreTools.cs +++ b/src/FluentCMS.Mcp/CoreTools.cs @@ -25,9 +25,10 @@ public void Configure(McpServerOptions options) } options.ResourceCollection ??= []; + var names = new HashSet(StringComparer.Ordinal); foreach (var resource in resources.SelectMany(provider => provider.Resources)) { - if (!options.ResourceCollection.TryAdd(new CoreResource(resource))) + if (!names.Add(resource.Name) || !options.ResourceCollection.TryAdd(new CoreResource(resource))) { throw new InvalidOperationException($"Two resources are named '{resource.Name}' or share the URI '{resource.UriTemplate}'."); } @@ -106,7 +107,10 @@ public override async ValueTask ReadAsync(RequestContext error.Message)), McpErrorCode.ResourceNotFound); + var notFound = read.Errors.All(error => error.Code.EndsWith("_not_found", StringComparison.Ordinal)); + throw new McpProtocolException( + string.Join(" ", read.Errors.Select(error => error.Message)), + notFound ? McpErrorCode.ResourceNotFound : McpErrorCode.InvalidParams); } return new ReadResourceResult From fb7fd24ca716389eef33888413abb77974330d86 Mon Sep 17 00:00:00 2001 From: Amir Pournasserian Date: Sun, 18 Oct 2026 19:22:53 -0400 Subject: [PATCH 3/4] Add the write tools for change sets and content types --- CONTRIBUTING.md | 2 +- src/FluentCMS.Core/Agents/AgentPermissions.cs | 7 +- .../Agents/IAgentPermissions.cs | 5 +- .../Builtin/Tools/ChangeSetTarget.cs | 104 ++++++++ .../Builtin/Tools/ChangeSetTools.cs | 76 +++++- .../Builtin/Tools/ProposedWrite.cs | 64 +++++ .../Builtin/Tools/SchemaTools.cs | 137 +++++++++- .../ChangeSets/IChangeSetDirectory.cs | 3 + src/FluentCMS.Core/Tools/AgentTool.cs | 8 +- .../Tools/ToolArgumentsReader.cs | 76 +++++- src/FluentCMS.Core/Tools/ToolSchemas.cs | 22 ++ .../ChangeSets/ChangeSetDirectory.cs | 12 +- src/FluentCMS.Mcp/CoreTools.cs | 2 +- .../Mcp/BuiltInToolsTests.cs | 2 +- .../Mcp/McpAgents.cs | 6 +- .../Mcp/WriteToolsTests.cs | 246 ++++++++++++++++++ .../Tools/ToolConventionsTests.cs | 21 ++ .../Tools/ToolProviderConformanceTests.cs | 2 +- 18 files changed, 764 insertions(+), 31 deletions(-) create mode 100644 src/FluentCMS.Core/Builtin/Tools/ChangeSetTarget.cs create mode 100644 src/FluentCMS.Core/Builtin/Tools/ProposedWrite.cs create mode 100644 tests/FluentCMS.IntegrationTests/Mcp/WriteToolsTests.cs diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 26cddc82b..2a160bc05 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 nothing is live 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..a913c4e88 --- /dev/null +++ b/src/FluentCMS.Core/Builtin/Tools/ChangeSetTarget.cs @@ -0,0 +1,104 @@ +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 ?? [])); + } + + 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..6dd1e5e80 --- /dev/null +++ b/src/FluentCMS.Core/Builtin/Tools/ProposedWrite.cs @@ -0,0 +1,64 @@ +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 set = await target.AddAsync(services.GetRequiredService(), operation(type.Value), 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(operation(type.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 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..7ce572ce7 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. Nothing is live 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..1769769a4 100644 --- a/src/FluentCMS.Core/Tools/ToolArgumentsReader.cs +++ b/src/FluentCMS.Core/Tools/ToolArgumentsReader.cs @@ -32,39 +32,72 @@ 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); + } + public IReadOnlyList Errors() { var errors = new List(_errors); @@ -105,6 +138,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")); From bcadb18d76331e3f6b574dc6c30018a317a6d25c Mon Sep 17 00:00:00 2001 From: Amir Pournasserian Date: Sun, 18 Oct 2026 19:26:49 -0400 Subject: [PATCH 4/4] Say what publish gates and drop a misleading target error --- CONTRIBUTING.md | 2 +- src/FluentCMS.Core/Builtin/Tools/ChangeSetTarget.cs | 6 ++++++ src/FluentCMS.Core/Builtin/Tools/ProposedWrite.cs | 5 +++-- src/FluentCMS.Core/Builtin/Tools/SchemaTools.cs | 2 +- src/FluentCMS.Core/Tools/ToolArgumentsReader.cs | 6 ++++++ 5 files changed, 17 insertions(+), 4 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2a160bc05..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`. 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 nothing is live until a person approves and publishes the set; no tool approves, rejects or publishes. +Agents reach the MCP server (ADR-0007) through either mode. The web host serves streamable HTTP at `/mcp` in stateless mode: each request stands alone, no session id is issued, and a GET answers 405 because the server never opens a stream to the client. `fluentcms mcp` serves the same server over standard input and output to an agent that starts it as a local process, for example with `dotnet run --project src/FluentCMS.Host -- mcp` as its command. It reads the same settings as the web host, so pass it the web host's `Database__ConnectionString` and `DataProtection__KeysDirectory` to work on the same database and key ring. Relative paths resolve against the process's working directory, so give an agent absolute ones. Standard output carries only protocol messages, log lines go to standard error, and the process stops when its standard input closes. The stdio mode runs with the local user's database access, so it is meant for development and single-user setups. Clients on protocol revision 2026-07-28 connect with `server/discover`, earlier ones with `initialize`. Agents read content types, the schema document and change sets with the read-only tools, and propose changes with `open_change_set`, `create_content_type`, `revise_content_type` and `submit_change_set`. A write stores an unpublished version and its operation in the agent's change set together, and the version is not published until a person approves and publishes the set; no tool approves, rejects or publishes. 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/Builtin/Tools/ChangeSetTarget.cs b/src/FluentCMS.Core/Builtin/Tools/ChangeSetTarget.cs index a913c4e88..94adf41e1 100644 --- a/src/FluentCMS.Core/Builtin/Tools/ChangeSetTarget.cs +++ b/src/FluentCMS.Core/Builtin/Tools/ChangeSetTarget.cs @@ -66,6 +66,12 @@ private ChangeSetTarget(Guid? changeSetId, string? sessionId, NewChangeSet? open 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 diff --git a/src/FluentCMS.Core/Builtin/Tools/ProposedWrite.cs b/src/FluentCMS.Core/Builtin/Tools/ProposedWrite.cs index 6dd1e5e80..ee086c0e3 100644 --- a/src/FluentCMS.Core/Builtin/Tools/ProposedWrite.cs +++ b/src/FluentCMS.Core/Builtin/Tools/ProposedWrite.cs @@ -35,7 +35,8 @@ public static async Task RunAsync( return ToolResult.Failure(type.Errors, Hint(type.Errors)); } - var set = await target.AddAsync(services.GetRequiredService(), operation(type.Value), cancellationToken); + var proposed = operation(type.Value); + var set = await target.AddAsync(services.GetRequiredService(), proposed, cancellationToken); if (!set.IsSuccess) { return ToolResult.Failure(set.Errors); @@ -47,7 +48,7 @@ public static async Task RunAsync( { ["contentType"] = ToolJson.ContentType(type.Value), ["changeSet"] = ToolJson.ChangeSet(set.Value), - ["next"] = $"The {ToolJson.Name(operation(type.Value).Kind)} is in change set {set.Value.Id}, which stays open for more " + ["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.", }); } diff --git a/src/FluentCMS.Core/Builtin/Tools/SchemaTools.cs b/src/FluentCMS.Core/Builtin/Tools/SchemaTools.cs index 7ce572ce7..3db536dec 100644 --- a/src/FluentCMS.Core/Builtin/Tools/SchemaTools.cs +++ b/src/FluentCMS.Core/Builtin/Tools/SchemaTools.cs @@ -50,7 +50,7 @@ public sealed class SchemaTools : IToolProvider, IResourceProvider "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. Nothing is live until a person approves and publishes the set. Every problem with the definition comes back " + + "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([ diff --git a/src/FluentCMS.Core/Tools/ToolArgumentsReader.cs b/src/FluentCMS.Core/Tools/ToolArgumentsReader.cs index 1769769a4..3e51944e2 100644 --- a/src/FluentCMS.Core/Tools/ToolArgumentsReader.cs +++ b/src/FluentCMS.Core/Tools/ToolArgumentsReader.cs @@ -98,6 +98,12 @@ public ToolArgumentsReader(JsonElement arguments) 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);