Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,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.
Expand Down
66 changes: 66 additions & 0 deletions src/FluentCMS.Core/Builtin/Tools/ChangeSetTools.cs
Original file line number Diff line number Diff line change
@@ -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<AgentTool> 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<ToolResult> 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<IChangeSetDirectory>().GetAsync(id!.Value, cancellationToken);
return ToolResult.From(set, ToolJson.ChangeSet, Hint(set));
}

private static async Task<ToolResult> 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<IChangeSetDirectory>().DiffAsync(id!.Value, cancellationToken);
return ToolResult.From(diff, ToolJson.Diff, Hint(diff));
}

private static string? Hint<T>(Result<T> result)
{
return result.Errors.FirstOrDefault()?.Code == ChangeSetErrorCodes.ChangeSetNotFound ? FindIds : null;
}
}
128 changes: 128 additions & 0 deletions src/FluentCMS.Core/Builtin/Tools/SchemaTools.cs
Original file line number Diff line number Diff line change
@@ -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<AgentTool> 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<AgentResource> 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<string>.Success(
(await read.Services.GetRequiredService<ISchemaDocuments>().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<ISchemaDocuments>().GetTypeAsync(read.Parameters["slug"], cancellationToken);
return document.IsSuccess ? Result<string>.Success(document.Value.ToJsonString()) : Result<string>.Failure(document.Errors);
}),
];

private static async Task<ToolResult> 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<IContentTypeDirectory>().ListAsync(cancellationToken);
return ToolResult.Success(new JsonObject { ["contentTypes"] = new JsonArray([.. types.Select(ToolJson.Summary)]) });
}

private static async Task<ToolResult> 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<IContentTypeDirectory>();
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<ToolResult> 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<ISchemaDocuments>();
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<T>(Result<T> result)
{
return result.Errors.FirstOrDefault()?.Code switch
{
SchemaErrorCodes.ContentTypeNotFound => FindSlugs,
SchemaErrorCodes.VersionNotFound => "Leave out the version to read the current one.",
_ => null,
};
}
}
118 changes: 118 additions & 0 deletions src/FluentCMS.Core/Builtin/Tools/ToolJson.cs
Original file line number Diff line number Diff line change
@@ -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>(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<ValueDiff> 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;
}
}
73 changes: 73 additions & 0 deletions src/FluentCMS.Core/Tools/AgentResource.cs
Original file line number Diff line number Diff line change
@@ -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<ResourceRead, CancellationToken, Task<Result<string>>> _read;
private readonly Regex _pattern;
private readonly IReadOnlyList<string> _parameters;

public AgentResource(
string uriTemplate,
string name,
string description,
string mimeType,
Func<ResourceRead, CancellationToken, Task<Result<string>>> 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<string, string>? 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<Result<string>> 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<string, string> Parameters, IServiceProvider Services);
Loading
Loading