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
11 changes: 11 additions & 0 deletions .changeset/typed-fields-mcp.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"@cloudhal/mcp-server": minor
---

Typed custom fields, step 4 (RFC-0041 §8 / ADR-0043): MCP.

- **`list_collections`** returns each collection's field definitions (`fields`) and `fieldsSchema`, the JSON Schema of an entry's values with labels and help in the asked `locale` (default: the site's).
- **`create_entry` / `update_entry`** check field values in `precheck` with the store's checker (changed keys only, unknown keys, a translation's copy of a non-localized value, `requiredToPublish` for review and published, image and `asset:` references), so a refused call never reaches a person; each issue keeps its path (`fields/startsAt/at`). `update_entry` takes `fields` as a merge patch (`null` removes a key), and its approval names typed changes readably ("Starts at (startsAt) → 5 Nov 2026 19:30 Istanbul", option labels, values quoted and truncated).
- **`update_collection`** takes `fields` (the whole list) for free definition edits; changes that could lose values are refused with `field_change_needs_operation`, naming the operation tool.
- **New tools `rename_field`, `change_field_type`, `set_field_localized`, `remove_field`** (`site:write` + `content:write`): always a person holding `site:settings`, in every mode including `bypass`; the approval shows the dry run (entries touched, values moved, converted or cleared, samples), `dryRun: true` reports without asking, and a call that would clear values without `onInvalid: "clear"` is refused before anyone is asked. A run that stops at its budget answers `done: false` with an `operationId`; calling the same tool again with it continues the operation after a new approval.
- Hints for every field error code, and server instructions telling agents to read `fieldsSchema` before writing entries and to use the operation tools for risky changes.
10 changes: 7 additions & 3 deletions packages/mcp-server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import { createCloudhalMcpHandler } from "@cloudhal/mcp-server/http"; // fet
## Tool reference

<!-- tools:start (generated by scripts/mcp-tools-doc.ts; do not edit) -->
**105 tools**, in the order the server lists them.
**109 tools**, in the order the server lists them.

| Tool | Scopes | Kind | Approval | What it does |
|---|---|---|---|---|
Expand All @@ -39,7 +39,7 @@ import { createCloudhalMcpHandler } from "@cloudhal/mcp-server/http"; // fet
| `list_entries` | `content:read` | read | — | Content entries, newest first. |
| `get_entry` | `content:read` | read | — | One content entry with its body and current revision. |
| `create_entry` | `content:write` | write | by the key's mode | Create a draft content entry (markdown body) in an existing collection (list_collections; a new one needs create_collection first), optionally filed under existing terms (`terms`;… |
| `update_entry` | `content:write` | write | by the key's mode | Update title, summary, body, slug, status (draft → review → published) or terms (`terms`: add, remove or set categories and tags by id, slug or name) of an entry. |
| `update_entry` | `content:write` | write | by the key's mode | Update title, summary, body, slug, status (draft → review → published), terms (`terms`: add, remove or set categories and tags by id, slug or name) or typed custom fields (`fields`… |
| `add_correction` | `content:write` | write | by the key's mode | Append a correction, clarification or update notice to an article (RFC-0019 §2) when a published story changed in substance. |
| `delete_entry` | `content:write` | write (destructive) | by the key's mode | Delete a content entry. |
| `list_translations` | `content:read` | read | — | Every language version of a page or entry (its translation group): locale, status, method (original, human, machine, machine-reviewed), whether it is stale (its source changed sin… |
Expand Down Expand Up @@ -79,7 +79,11 @@ import { createCloudhalMcpHandler } from "@cloudhal/mcp-server/http"; // fet
| `delete_redirect` | `site:write` | write (destructive) | always a person | Delete a redirect; its old path then answers 404 unless something serves it. |
| `list_collections` | `content:read` | read | — | The site's collections (RFC-0040): declared ones, ones that already have entries, and posts. |
| `create_collection` | `site:write` | write | always a person | Declare a new collection (RFC-0040), e.g. events, team or jobs, with its names in the site's default locale (labels), an optional icon, permalink (/&lt;prefix>/{slug}, default /&lt;key>… |
| `update_collection` | `site:write` | write | always a person | Change a collection's names (merged per locale), icon, feed, sitemap or default sort (RFC-0040). |
| `update_collection` | `site:write` | write | always a person | Change a collection's names (merged per locale), icon, feed, sitemap, default sort (RFC-0040) or its typed custom fields (`fields`, the whole list; RFC-0041). |
| `rename_field` | `site:write`, `content:write` | write | always a person | Rename a typed custom field's key (RFC-0041 §6): every entry's value moves from `from` to `to`; nothing is converted. |
| `change_field_type` | `site:write`, `content:write` | write (destructive) | always a person | Change a typed custom field's type, or a select's multiple (RFC-0041 §6), converting every value by a closed table (text↔richText, text↔number, text↔select, text↔url, date↔datetim… |
| `set_field_localized` | `site:write`, `content:write` | write | always a person | Make a typed custom field localized (each language version has its own value; translations fall back to the original's) or shared (only the original holds it; translations' own co… |
| `remove_field` | `site:write`, `content:write` | write (destructive) | always a person | Remove a typed custom field (RFC-0041 §6): its value is deleted from every entry, then its definition. |
| `set_collection_permalink` | `site:write` | write | always a person | Move where a collection's entries live (RFC-0038): permalink "/blog/{slug}" puts collection "posts" at /blog/&lt;slug>; null goes back to the default /&lt;collection>/{slug}. |
| `list_menus` | `site:read` | read | — | The site's navigation menus (primary = header, footer, sidebar, custom keys) with id, rev, maxDepth, item count and how many items are hidden on the site. |
| `get_menu` | `site:read` | read | — | One menu: the stored tree (send its rev back when writing) and, per item for a locale, whether the site shows it (`state`), why not (`reason`: missing, unpublished, other-locale,… |
Expand Down
107 changes: 107 additions & 0 deletions packages/mcp-server/src/collection-fields.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
/**
* RFC-0041 §8 for the collection tools: `list_collections` shows each collection's field
* definitions and the JSON Schema of its values (in the caller's locale), and `update_collection`
* takes the whole `fields` list for free edits (§6). Changes that could lose values are refused by
* the store's guard (`field_change_needs_operation`) and go through the operation tools instead.
*/
import type { CollectionSettings, FieldDefinition, Site } from "@cloudhal/core-contracts";
import { SiteSchema } from "@cloudhal/core-contracts";
import { fieldLabel, fieldsJsonSchema } from "@cloudhal/core-ts/fields";
import { type JsonSchema, quote } from "./common.ts";

const DEFS = SiteSchema.$defs as Record<string, JsonSchema>;
const VARIANTS = (DEFS.FieldDefinition as JsonSchema).oneOf as JsonSchema[];

/** A definition variant without `migration`: only field operations write the marker. */
function withoutMarker(variant: JsonSchema): JsonSchema {
const { migration: _m, ...properties } = variant.properties as Record<string, unknown>;
return { ...variant, properties };
}

/** `update_collection`'s `fields`: the contract's definitions, minus the operation marker. */
export const FIELD_DEFINITIONS_INPUT: JsonSchema = {
type: "array",
maxItems: 30,
description:
"The collection's typed custom fields, the whole list in form order ([] removes them all). Free edits only: add fields, reorder, change labels and help, add select options or change their labels, toggle multiline, searchable or requiredToPublish, change maxLength/minimum/maximum. Renaming a key, changing a type, localized or multiple, and dropping a key or option still in use need rename_field, change_field_type, set_field_localized or remove_field.",
items: { $ref: "#/$defs/FieldDefinition" },
$defs: {
FieldDefinition: {
...DEFS.FieldDefinition,
oneOf: VARIANTS.map(withoutMarker),
},
FieldKey: DEFS.FieldKey,
FieldLabel: DEFS.FieldLabel,
FieldLabels: DEFS.FieldLabels,
FieldOption: DEFS.FieldOption,
Slug: DEFS.Slug,
Locale: DEFS.Locale,
},
};

/**
* The settings with `fields` replaced by the agent's list. A running operation's marker stays on
* its field (agents never write it), so editing labels meanwhile is not read as touching it.
*/
export function withFields(
current: CollectionSettings,
next: CollectionSettings,
fields: FieldDefinition[],
): CollectionSettings {
const markers = new Map((current.fields ?? []).map((d) => [d.key, d.migration]));
const { fields: _old, ...rest } = next;
if (fields.length === 0) return rest;
return {
...rest,
fields: fields.map((d) => {
const { migration: _m, ...def } = d;
const marker = markers.get(d.key);
return (marker ? { ...def, migration: marker } : def) as FieldDefinition;
}) as NonNullable<CollectionSettings["fields"]>,
};
}

/** JSON with sorted keys: stored definitions may list properties in another order. */
function canonical(value: unknown): string {
return JSON.stringify(value, (_k, v: unknown) =>
v && typeof v === "object" && !Array.isArray(v)
? Object.fromEntries(Object.entries(v).sort(([x], [y]) => x.localeCompare(y)))
: v,
);
}

/** One line for the approval prompt: which fields are added, changed, removed or reordered. */
export function describeFieldEdits(
before: readonly FieldDefinition[],
after: readonly FieldDefinition[],
locale: string,
): string | undefined {
const old = new Map(before.map((d) => [d.key, d]));
const now = new Map(after.map((d) => [d.key, d]));
const name = (d: FieldDefinition) => `"${quote(fieldLabel(d, locale, locale), 40)}" (${d.key})`;
const parts: string[] = [];
for (const d of after) {
const was = old.get(d.key);
if (!was) parts.push(`add ${name(d)}, ${d.type}${d.localized ? ", localized" : ""}`);
else if (canonical(was) !== canonical(d)) parts.push(`change ${name(d)}`);
}
for (const d of before) if (!now.has(d.key)) parts.push(`remove ${name(d)}`);
const kept = (list: readonly FieldDefinition[], other: Map<string, FieldDefinition>) =>
list.filter((d) => other.has(d.key)).map((d) => d.key);
if (kept(before, now).join() !== kept(after, old).join()) parts.push("reorder");
return parts.length ? ` fields: ${parts.join("; ")}` : undefined;
}

/** What `list_collections` adds per collection that has definitions. */
export function fieldsView(
site: Site,
key: string,
locale: string,
): { fields: FieldDefinition[]; fieldsSchema: JsonSchema } | Record<string, never> {
const fields = site.collections?.[key]?.fields;
if (!fields?.length) return {};
return {
fields,
fieldsSchema: fieldsJsonSchema(fields, locale, site.defaultLocale) as unknown as JsonSchema,
};
}
29 changes: 25 additions & 4 deletions packages/mcp-server/src/collection-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,20 @@
* settings in every mode (a new collection widens what agents may write). `requireRegistered` is
* the precheck of every tool that creates an entry: no entries in a collection nobody approved.
*/
import type { CollectionSettings, Site } from "@cloudhal/core-contracts";
import type { CollectionSettings, FieldDefinition, Site } from "@cloudhal/core-contracts";
import {
type CollectionInfo,
collectionSettings,
isRegistered,
MAX_COLLECTIONS,
} from "@cloudhal/core-ts/collections";
import { collectionPermalink } from "@cloudhal/core-ts/permalinks";
import {
describeFieldEdits,
FIELD_DEFINITIONS_INPUT,
fieldsView,
withFields,
} from "./collection-fields.ts";
import {
autoSnapshot,
id,
Expand Down Expand Up @@ -92,6 +98,7 @@ function settingsOf(a: Record<string, unknown>, current: CollectionSettings): Co
for (const k of ["icon", "feed", "sitemap", "sort"] as const)
if (a[k] !== undefined) (next as Record<string, unknown>)[k] = a[k];
if (typeof a.permalink === "string") next.permalink = a.permalink;
if (Array.isArray(a.fields)) return withFields(current, next, a.fields as FieldDefinition[]);
return next;
}

Expand Down Expand Up @@ -167,6 +174,12 @@ function describe(p: Plan, mode: "create" | "update"): string {
];
const other = Object.keys(p.settings.labels ?? {}).filter((l) => l !== p.site.defaultLocale);
if (other.length) lines.push(` also named in: ${other.join(", ")}`);
const fields = describeFieldEdits(
p.existing?.settings?.fields ?? [],
p.settings.fields ?? [],
p.site.defaultLocale,
);
if (fields) lines.push(fields);
return `${head}\n${lines.join("\n")}`;
}

Expand All @@ -178,6 +191,8 @@ const COLLECTION_OUT: JsonSchema = obj(
counts: { type: "object" },
permalink: { type: "string" },
taxonomies: { type: "array", items: { type: "string" } },
fields: { type: "array", items: { type: "object" } },
fieldsSchema: { type: "object" },
},
["key", "declared", "settings", "counts", "permalink", "taxonomies"],
);
Expand All @@ -186,8 +201,11 @@ const listCollectionsTool: ToolDefinition = {
name: "list_collections",
title: "List collections",
description:
"The site's collections (RFC-0040): declared ones, ones that already have entries, and posts. Each with its names per locale, icon, entry counts by status, its permalink template, feed/sitemap/sort settings (defaults filled in) and the taxonomies that apply. Entries may only be created in these collections; call create_collection for a new one.",
inputSchema: obj({ siteId: id }, ["siteId"]),
"The site's collections (RFC-0040): declared ones, ones that already have entries, and posts. Each with its names per locale, icon, entry counts by status, its permalink template, feed/sitemap/sort settings (defaults filled in) and the taxonomies that apply. A collection with typed custom fields (RFC-0041) also has `fields` (the definitions) and `fieldsSchema`, the JSON Schema of an entry's `fields` with labels and help in `locale` (default: the site's default locale): read it before create_entry or update_entry with fields. Entries may only be created in these collections; call create_collection for a new one.",
inputSchema: obj(
{ siteId: id, locale: str("BCP 47 locale of field labels and help; default the site's") },
["siteId"],
),
outputSchema: obj({ collections: { type: "array", items: COLLECTION_OUT } }, ["collections"]),
annotations: {
readOnlyHint: true,
Expand All @@ -202,9 +220,11 @@ const listCollectionsTool: ToolDefinition = {
ctx.store.listCollections(site.id),
ctx.store.listTaxonomies(site.id),
]);
const locale = (a.locale as string | undefined) ?? site.defaultLocale;
return {
collections: registry.map((c) => ({
...c,
...fieldsView(site, c.key, locale),
settings: collectionSettings(site, c.key),
permalink: collectionPermalink(site, c.key),
taxonomies: taxonomies
Expand All @@ -223,7 +243,7 @@ function writeTool(mode: "create" | "update"): ToolDefinition {
description:
mode === "create"
? "Declare a new collection (RFC-0040), e.g. events, team or jobs, with its names in the site's default locale (labels), an optional icon, permalink (/<prefix>/{slug}, default /<key>/{slug}), feed, sitemap and default sort. Its address may not overlap a page, archive, another collection, /search or a locale prefix. A person who may change site settings approves it in every mode; dryRun: true checks without asking. Then create_entry with this collection. Declaring a collection that already has entries names it."
: "Change a collection's names (merged per locale), icon, feed, sitemap or default sort (RFC-0040). The permalink changes only through set_collection_permalink (it moves URLs). A person who may change site settings approves it; dryRun: true checks without asking.",
: "Change a collection's names (merged per locale), icon, feed, sitemap, default sort (RFC-0040) or its typed custom fields (`fields`, the whole list; RFC-0041). Field edits that keep every stored value are free here; renaming a key, changing a type, localization or multiple, and dropping a key or option still in use are refused (field_change_needs_operation) and go through rename_field, change_field_type, set_field_localized or remove_field. The permalink changes only through set_collection_permalink (it moves URLs). A person who may change site settings approves it; dryRun: true checks without asking.",
inputSchema: obj(
{
siteId: id,
Expand All @@ -232,6 +252,7 @@ function writeTool(mode: "create" | "update"): ToolDefinition {
maxLength: 64,
}),
...SETTINGS_INPUT,
...(mode === "update" ? { fields: FIELD_DEFINITIONS_INPUT } : {}),
...(mode === "create"
? {
permalink: str("Template, e.g. /etkinlik/{slug}; default /<key>/{slug}", {
Expand Down
3 changes: 3 additions & 0 deletions packages/mcp-server/src/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import type { BodyDiff } from "@cloudhal/core-ts/body-blocks";
import type { FetchTransport } from "@cloudhal/core-ts/media";
import { ScheduleConflictError, ScheduleError } from "@cloudhal/schedules";
import type { CommentsDeps } from "./comments.ts";
import { fieldHint } from "./field-hints.ts";
import type { PageRenderer, RenderLimiter } from "./render.ts";
import { type RunContext, ScheduleRequestError, type SchedulesDeps } from "./schedules.ts";

Expand Down Expand Up @@ -374,6 +375,8 @@ export async function autoSnapshot(

/** What to do next, per invariant (RFC-0002 §3). */
function invariantHint(e: InvariantError): string | undefined {
const field = fieldHint(e.code, e.details);
if (field) return field;
const key = typeof e.details.taxonomy === "string" ? e.details.taxonomy : undefined;
switch (e.code) {
case "required_term":
Expand Down
Loading
Loading