From 0948f5dd63b645f8229bf14ea73a489d34958681 Mon Sep 17 00:00:00 2001 From: Amir Pournasserian Date: Sun, 18 Oct 2026 19:05:25 -0400 Subject: [PATCH 1/2] 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/2] 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