diff --git a/.changeset/typed-fields-mcp.md b/.changeset/typed-fields-mcp.md new file mode 100644 index 00000000..9d9e1a87 --- /dev/null +++ b/.changeset/typed-fields-mcp.md @@ -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. diff --git a/packages/mcp-server/README.md b/packages/mcp-server/README.md index bb2329cd..76d0b97a 100644 --- a/packages/mcp-server/README.md +++ b/packages/mcp-server/README.md @@ -21,7 +21,7 @@ import { createCloudhalMcpHandler } from "@cloudhal/mcp-server/http"; // fet ## Tool reference -**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 | |---|---|---|---|---| @@ -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… | @@ -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 (/<prefix>/{slug}, default /<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/<slug>; null goes back to the default /<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,… | diff --git a/packages/mcp-server/src/collection-fields.ts b/packages/mcp-server/src/collection-fields.ts new file mode 100644 index 00000000..6c8970e8 --- /dev/null +++ b/packages/mcp-server/src/collection-fields.ts @@ -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; +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; + 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, + }; +} + +/** 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) => + 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 { + const fields = site.collections?.[key]?.fields; + if (!fields?.length) return {}; + return { + fields, + fieldsSchema: fieldsJsonSchema(fields, locale, site.defaultLocale) as unknown as JsonSchema, + }; +} diff --git a/packages/mcp-server/src/collection-tools.ts b/packages/mcp-server/src/collection-tools.ts index 438f605d..1aaff606 100644 --- a/packages/mcp-server/src/collection-tools.ts +++ b/packages/mcp-server/src/collection-tools.ts @@ -4,7 +4,7 @@ * 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, @@ -12,6 +12,12 @@ import { 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, @@ -92,6 +98,7 @@ function settingsOf(a: Record, current: CollectionSettings): Co for (const k of ["icon", "feed", "sitemap", "sort"] as const) if (a[k] !== undefined) (next as Record)[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; } @@ -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")}`; } @@ -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"], ); @@ -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, @@ -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 @@ -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 (//{slug}, default //{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, @@ -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 //{slug}", { diff --git a/packages/mcp-server/src/common.ts b/packages/mcp-server/src/common.ts index 02179b61..dae48416 100644 --- a/packages/mcp-server/src/common.ts +++ b/packages/mcp-server/src/common.ts @@ -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"; @@ -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": diff --git a/packages/mcp-server/src/entry-fields.ts b/packages/mcp-server/src/entry-fields.ts new file mode 100644 index 00000000..82fa2147 --- /dev/null +++ b/packages/mcp-server/src/entry-fields.ts @@ -0,0 +1,233 @@ +/** + * RFC-0041 §3, §8: typed custom field values over MCP. `create_entry` and `update_entry` check + * them in `precheck` with the store's own 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 call that the store would refuse never reaches a person. `update_entry` takes + * `fields` as a merge patch (`null` removes a key), and its approval prompt names each typed change + * readably: labels, option labels, dates in their zone, values quoted and truncated. + */ +import type { Asset, ContentEntry, FieldDefinition, Site } from "@cloudhal/core-contracts"; +import { + ASSET_ID_PATTERN, + checkFieldValues, + checkRequiredToPublish, + convertContext, + type FieldIssue, + fieldDefinitions, + fieldLabel, + normalizeThroughMarkers, + optionLabel, + refuseFieldIssues, +} from "@cloudhal/core-ts/fields"; +import { markdownAssetIds } from "@cloudhal/core-ts/media"; +import { type JsonSchema, quote, type ToolContext } from "./common.ts"; + +type Values = Record; + +/** `create_entry`'s `fields`: a static schema cannot vary by site, so it points to list_collections. */ +export const FIELDS_INPUT: JsonSchema = { + type: "object", + description: + "Typed custom fields of the collection: read its fieldsSchema from list_collections first (keys, types, allowed values, limits). A collection without field definitions takes any JSON here.", +}; + +/** `update_entry`'s `fields`: a merge patch. */ +export const FIELDS_PATCH: JsonSchema = { + type: "object", + description: + "Typed custom fields to change, as a merge patch: given keys are set, null removes a key, others stay. The collection's fieldsSchema (list_collections) lists the keys and allowed values.", +}; + +/** The stored `fields` with `patch` applied: `null` removes a key. */ +export function mergeFields(current: Values | undefined, patch: Values): Values { + const next: Values = { ...(current ?? {}) }; + for (const [key, value] of Object.entries(patch)) { + if (value === null) delete next[key]; + else next[key] = value; + } + return next; +} + +const isTranslation = (e: Pick): boolean => + !!e.translation && e.translation.group !== e.id && e.translation.source?.siteId === undefined; + +/** The same-site original of a translation (as the store reads it). */ +async function originalOf( + ctx: ToolContext, + entry: Pick, +): Promise { + if (!isTranslation(entry)) return undefined; + const original = await ctx.store.getContentEntry(entry.translation?.group as string); + return original?.siteId === entry.siteId ? original : undefined; +} + +const ASSET_ID = new RegExp(ASSET_ID_PATTERN); + +/** `fields.` → asset ids an image or rich-text value refers to. */ +function assetRefs(defs: readonly FieldDefinition[], values: Values | undefined): Set { + const out = new Set(); + for (const def of defs) { + const value = values?.[def.key]; + if (typeof value !== "string") continue; + if (def.type === "image" && ASSET_ID.test(value)) out.add(`${def.key}\n${value}`); + else if (def.type === "richText" && value.includes("asset:")) + for (const id of markdownAssetIds(value)) out.add(`${def.key}\n${id}`); + } + return out; +} + +function assetProblem(asset: Asset | undefined, def: FieldDefinition, siteId: string) { + if (!asset || asset.siteId !== siteId) return "is not a file of this site"; + if (def.type === "image" && asset.kind !== "image") + return `is a ${asset.kind} file, not an image`; + if (asset.status === "retired") return "was retired and cannot be newly used"; + return undefined; +} + +/** New references (not held by the stored entry under the same key) name usable files of the site. */ +async function assetIssues( + ctx: ToolContext, + check: { + siteId: string; + defs: readonly FieldDefinition[]; + values: Values; + stored?: Values | undefined; + }, +): Promise { + const held = assetRefs(check.defs, check.stored); + const byKey = new Map(check.defs.map((d) => [d.key, d])); + const out: FieldIssue[] = []; + for (const ref of assetRefs(check.defs, check.values)) { + if (held.has(ref)) continue; + const [key = "", assetId = ""] = ref.split("\n"); + const problem = assetProblem( + await ctx.store.getAsset(assetId), + byKey.get(key) as FieldDefinition, + check.siteId, + ); + if (problem) + out.push({ path: `fields/${key}`, code: "invalid_value", message: `${assetId} ${problem}` }); + } + return out; +} + +/** What an entry write would store, for the check. */ +export interface FieldsCheck { + site: Site; + /** The entry as it would be written (its `fields` after the patch). */ + next: Pick; + /** The stored entry (absent for a new one). */ + stored?: Pick | undefined; +} + +/** + * The store's field checks, run before anyone is asked: throws `invalid_fields`, `unknown_field`, + * `field_not_localized`, `required_field` or `too_large` with every issue at its path. + */ +export async function checkEntryFields(ctx: ToolContext, check: FieldsCheck): Promise { + const { site, stored } = check; + let next = check.next; + const defs = fieldDefinitions(site, next.collection); + // While a field operation runs, a write goes through its marker (as the store normalizes it). + if (defs.some((d) => d.migration)) { + const fields = normalizeThroughMarkers(defs, next.fields, { + convert: convertContext(site), + translation: isTranslation(next), + }); + if (fields) next = { ...next, fields }; + } + const values = next.fields ?? {}; + const previous = stored?.collection === next.collection ? (stored.fields ?? {}) : undefined; + if (defs.length === 0) { + if (!previous || JSON.stringify(previous) !== JSON.stringify(values)) + refuseFieldIssues(checkFieldValues([], { values })); + return; + } + const original = await originalOf(ctx, next); + const issues = checkFieldValues(defs, { + values, + previous, + original: original ? { id: original.id, values: original.fields ?? {} } : undefined, + blocks: ctx.store.blocks, + }); + const invalid = new Set(issues.map((i) => i.path.split("/").slice(0, 2).join("/"))); + const assets = await assetIssues(ctx, { siteId: site.id, defs, values, stored: previous }); + issues.push(...assets.filter((i) => !invalid.has(i.path))); + issues.push(...checkRequiredToPublish(site, { ...next, fields: values }, original)); + refuseFieldIssues(issues); +} + +/** "5 Nov 2026" (a calendar day: no zone). */ +function day(value: string): string { + const d = new Date(`${value}T00:00:00Z`); + if (Number.isNaN(d.getTime())) return quote(value); + return new Intl.DateTimeFormat("en-GB", { + day: "numeric", + month: "short", + year: "numeric", + timeZone: "UTC", + }).format(d); +} + +/** "5 Nov 2026 19:30 Istanbul" (the wall clock in the value's own zone). */ +function instant(value: unknown): string { + const v = value as { at?: unknown; timeZone?: unknown }; + if (typeof v?.at !== "string" || typeof v.timeZone !== "string") return quote(JSON.stringify(v)); + const at = new Date(v.at); + if (Number.isNaN(at.getTime())) return quote(v.at); + try { + const parts = new Intl.DateTimeFormat("en-GB", { + day: "numeric", + month: "short", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + hourCycle: "h23", + timeZone: v.timeZone, + }).format(at); + const city = (v.timeZone.split("/").pop() ?? v.timeZone).replace(/_/g, " "); + return `${parts.replace(",", "")} ${city}`; + } catch { + return `${quote(v.at)} ${quote(v.timeZone)}`; + } +} + +/** A value as a person reads it in an approval prompt (agent text quoted and truncated). */ +export function describeFieldValue( + def: FieldDefinition | undefined, + value: unknown, + locale: string, +): string { + if (!def) return `"${quote(JSON.stringify(value) ?? "")}"`; + switch (def.type) { + case "number": + return typeof value === "number" ? String(value) : `"${quote(String(value))}"`; + case "boolean": + return value === true ? "yes" : "no"; + case "date": + return typeof value === "string" ? day(value) : `"${quote(String(value))}"`; + case "datetime": + return instant(value); + case "select": { + const list = Array.isArray(value) ? value : [value]; + return list.map((v) => quote(optionLabel(def, String(v), locale, locale))).join(", "); + } + default: + return `"${quote(typeof value === "string" ? value : JSON.stringify(value))}"`; + } +} + +/** "startsAt → 5 Nov 2026 19:30 Istanbul, format → Talk, Workshop, notes removed". */ +export function describeFieldPatch(site: Site, collection: string, patch: Values): string { + const defs = new Map(fieldDefinitions(site, collection).map((d) => [d.key, d])); + const locale = site.defaultLocale; + return Object.entries(patch) + .map(([key, value]) => { + const def = defs.get(key); + const name = def ? `"${quote(fieldLabel(def, locale, locale), 40)}" (${key})` : key; + return value === null + ? `${name} removed` + : `${name} → ${describeFieldValue(def, value, locale)}`; + }) + .join(", "); +} diff --git a/packages/mcp-server/src/entry-tools.ts b/packages/mcp-server/src/entry-tools.ts index 7c569a06..f35b2032 100644 --- a/packages/mcp-server/src/entry-tools.ts +++ b/packages/mcp-server/src/entry-tools.ts @@ -26,6 +26,13 @@ import { unknownBlocks, unknownBlocksOf, } from "./content-shared.ts"; +import { + checkEntryFields, + describeFieldPatch, + FIELDS_INPUT, + FIELDS_PATCH, + mergeFields, +} from "./entry-fields.ts"; import { entryTerms, reportDeltas } from "./entry-terms.ts"; import { menuHint, menuItemsHiddenBy } from "./menu-tools.ts"; import { assignmentDeltas, describeDeltas, TAGS_INPUT, TERMS_INPUT, termLookup } from "./terms.ts"; @@ -110,7 +117,7 @@ const createEntryTool: ToolDefinition = { name: "create_entry", title: "Create article", description: - "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`; see list_taxonomies and list_terms). Snapshots afterwards.", + "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`; see list_taxonomies and list_terms). Typed custom fields go in `fields`, following the collection's fieldsSchema from list_collections; invalid values are refused with their paths. Snapshots afterwards.", inputSchema: obj( { siteId: id, @@ -125,7 +132,7 @@ const createEntryTool: ToolDefinition = { terms: TERMS_INPUT, tags: TAGS_INPUT, coverImage: COVER_INPUT, - fields: { type: "object", description: "Collection-specific fields" }, + fields: FIELDS_INPUT, }, ["siteId", "slug", "title", "body"], ), @@ -141,9 +148,24 @@ const createEntryTool: ToolDefinition = { }, scopes: ["content:write"], own: "entry", - // RFC-0040 §5: only collections of the registry (a new one goes through create_collection). - precheck: (a, ctx) => - requireRegistered(ctx, a.siteId as string, (a.collection as string | undefined) ?? "posts"), + // RFC-0040 §5: only collections of the registry (a new one goes through create_collection); + // RFC-0041 §8: field values are checked before anyone is asked. + precheck: async (a, ctx) => { + const collection = (a.collection as string | undefined) ?? "posts"; + await requireRegistered(ctx, a.siteId as string, collection); + const site = await ctx.store.getSite(a.siteId as string); + if (!site) return; + await checkEntryFields(ctx, { + site, + next: { + id: "", + siteId: site.id, + collection, + status: "draft", + fields: (a.fields as Record | undefined) ?? {}, + }, + }); + }, handler: async (a, ctx) => { const site = await requireSite(ctx, a.siteId as string); await entryLimitCheck(ctx, "create", undefined, { @@ -189,7 +211,7 @@ const updateEntryTool: ToolDefinition = { name: "update_entry", title: "Update article", description: - "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. Review and published articles need a primary term in every required taxonomy. Requires the current `rev`. Changing a published article's slug leaves a redirect. Snapshots afterwards.", + "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`, a merge patch: null removes a key; see the collection's fieldsSchema in list_collections) of an entry. Review and published articles need a primary term in every required taxonomy and every field marked needed to publish. Requires the current `rev`. Changing a published article's slug leaves a redirect. Snapshots afterwards.", inputSchema: obj( { siteId: id, @@ -207,6 +229,7 @@ const updateEntryTool: ToolDefinition = { description: "Cover image; null removes it", }, status: { type: "string", enum: ["draft", "review", "published", "archived"] }, + fields: FIELDS_PATCH, }, ["siteId", "entryId", "rev"], ), @@ -219,15 +242,18 @@ const updateEntryTool: ToolDefinition = { }, scopes: ["content:write"], own: "entry", - // Own-limited callers (Authors, Contributors and their agents) only touch their own articles. + // Own-limited callers (Authors, Contributors and their agents) only touch their own articles; + // field values (RFC-0041 §8) are checked before anyone is asked. precheck: async (a, ctx) => { - if (!ctx.limit) return; const entry = await ctx.store.getContentEntry(a.entryId as string); if (!entry || entry.siteId !== a.siteId) return; - await entryLimitCheck(ctx, "update", entry, { - author: entry.author, - status: (a.status as ContentEntry["status"] | undefined) ?? entry.status, - }); + const status = (a.status as ContentEntry["status"] | undefined) ?? entry.status; + if (ctx.limit) await entryLimitCheck(ctx, "update", entry, { author: entry.author, status }); + if (a.fields === undefined && status === entry.status) return; + const site = await ctx.store.getSite(entry.siteId); + if (!site) return; + const fields = mergeFields(entry.fields, (a.fields as Record) ?? {}); + await checkEntryFields(ctx, { site, next: { ...entry, status, fields }, stored: entry }); }, approvalPrompt: async (a, ctx) => { const entry = await ctx.store.getContentEntry(a.entryId as string); @@ -247,6 +273,10 @@ const updateEntryTool: ToolDefinition = { : "cover image" : "", ].filter(Boolean); + if (a.fields && site && Object.keys(a.fields).length) + changes.push( + `fields: ${describeFieldPatch(site, entry.collection, a.fields as Record)}`, + ); if (a.terms || a.tags) { try { const { terms } = await entryTerms(ctx, site as Site, entry, a); @@ -295,6 +325,9 @@ const updateEntryTool: ToolDefinition = { ...(a.title ? { title: a.title as string } : {}), ...(a.summary !== undefined ? { summary: a.summary as string } : {}), ...(a.body !== undefined ? { body: { ...entry.body, value: a.body as string } } : {}), + ...(a.fields !== undefined + ? { fields: mergeFields(entry.fields, a.fields as Record) } + : {}), ...(a.slug ? { slug: a.slug as string } : {}), tags, ...(terms ? { terms } : {}), diff --git a/packages/mcp-server/src/field-hints.ts b/packages/mcp-server/src/field-hints.ts new file mode 100644 index 00000000..f91512f1 --- /dev/null +++ b/packages/mcp-server/src/field-hints.ts @@ -0,0 +1,39 @@ +/** + * RFC-0041: what an agent does next after a typed-field refusal. Every refusal keeps its issues in + * `details.issues`, each at its slash path (`fields/startsAt/at`), so the hint only names the tool. + */ +import type { InvariantCode } from "@cloudhal/core-ts"; + +const OPERATIONS = "rename_field, change_field_type, set_field_localized or remove_field"; + +const HINTS: Partial> = { + invalid_fields: + "fix the values at the paths in details.issues; list_collections shows the collection's fieldsSchema (types, allowed values, limits)", + unknown_field: + "use one of the collection's field keys (list_collections → fieldsSchema); a new field is added with update_collection, which a person approves", + field_not_localized: + "that field is shared by every language version: set it on the original entry named in the message, and leave it out of the translation", + required_field: + "give the fields named in details.issues a value (update_entry with fields), or keep the entry a draft", + field_key_reserved: + "pick another key: built-in entry properties (title, slug, body, …) cannot be field keys", + field_key_duplicate: "every field key appears once per collection", + field_option_duplicate: "every option value appears once per select field", + field_bounds: "a number field's minimum must not be above its maximum", + too_many_fields: + "a collection has at most 30 fields, a site 200 and a select 50 options; remove some first", + field_change_needs_operation: `that change could lose or reinterpret stored values: use ${OPERATIONS} (each shows a dry run and waits for a person; try dryRun: true first)`, + operation_in_progress: + "a person can continue or revert it in Studio, or call the same operation tool again with that operationId (it needs a new approval)", + field_values_unconvertible: + 'details.report.samples shows the values; change them first (update_entry), or call again with onInvalid: "clear" to drop them (the person approving sees how many)', + field_operation_incomplete: + "call the operation tool again with its operationId to finish it (a new approval), or ask a person to continue it in Studio", +}; + +/** The next step for a field refusal; undefined for codes that are not about fields. */ +export function fieldHint(code: InvariantCode, details: Readonly>) { + if (code === "too_large" && Array.isArray(details.issues)) + return "field values are at most 256 KB per entry and collection settings 256 KB per site; shorten or drop values"; + return HINTS[code]; +} diff --git a/packages/mcp-server/src/field-tools.ts b/packages/mcp-server/src/field-tools.ts new file mode 100644 index 00000000..7bc6c8c3 --- /dev/null +++ b/packages/mcp-server/src/field-tools.ts @@ -0,0 +1,360 @@ +/** + * RFC-0041 §6, §8: the four field operations for agents — `rename_field`, `change_field_type`, + * `set_field_localized` and `remove_field`. Each needs `site:write` and `content:write` (it + * rewrites entries), always waits for a person who may change site settings (`person`-classed, so + * `bypass` waits too), and shows that person the dry run: entries touched, values moved, converted + * or cleared, samples before and after. `precheck` runs the same dry run, so a call that would + * clear values without `onInvalid: "clear"` never reaches anyone. + * + * An operation that hits its budget answers `done: false` with its `operationId`. A person + * continues it in Studio, or the agent calls the same tool again with that `operationId`, which + * needs a new approval (ADR-0043 open question 9: approvals stay single use). + */ +import type { FieldOption } from "@cloudhal/core-contracts"; +import { SiteSchema } from "@cloudhal/core-contracts"; +import { + continueFieldOperation, + type FieldOperation, + type FieldOperationName, + type FieldOperationReport, + type FieldOperationResult, + fieldOperationStatus, + InvariantError, + runFieldOperation, +} from "@cloudhal/core-ts"; +import { FIELD_TYPES, type FieldType, fieldLabel } from "@cloudhal/core-ts/fields"; +import { + id, + type JsonSchema, + obj, + quote, + requireSite, + SLUG_PATTERN, + str, + type ToolContext, + type ToolDefinition, + ToolError, + toToolError, +} from "./common.ts"; + +const DEFS = SiteSchema.$defs as Record; +const KEY = DEFS.FieldKey as JsonSchema; + +const COMMON = { + siteId: id, + collection: str("Collection key (list_collections)", { pattern: SLUG_PATTERN, maxLength: 64 }), + operationId: str( + "Continue the unfinished operation this tool answered with done: false (repeat the same call with it; a person approves again)", + { pattern: id.pattern }, + ), + dryRun: { + type: "boolean", + description: "Only report what would change, without asking anyone or changing anything", + }, +} satisfies Record; + +const COUNTS = obj({ + touched: { type: "integer" }, + moved: { type: "integer" }, + converted: { type: "integer" }, + cleared: { type: "integer" }, + removed: { type: "integer" }, + unconvertible: { type: "integer" }, + unchanged: { type: "integer" }, + entries: { type: "integer" }, +}); + +const OUTPUT: JsonSchema = obj( + { + prompt: { type: "string" }, + dryRun: { type: "boolean" }, + done: { type: "boolean" }, + operationId: { type: "string" }, + report: { ...COUNTS, additionalProperties: true }, + rewritten: { type: "integer" }, + skipped: { type: "array", items: { type: "object" } }, + status: { type: "object" }, + hint: { type: "string" }, + }, + ["prompt", "dryRun", "done"], +); + +interface Spec { + name: FieldOperationName; + title: string; + description: string; + input: Record; + required: string[]; + destructive: boolean; + operation: (collection: string, a: Record) => FieldOperation; +} + +/** What the operation does, for the prompt's first line. */ +function headline(op: FieldOperation, report: FieldOperationReport, locale: string): string { + const def = report.definition.before; + const field = `"${quote(fieldLabel(def, locale, locale), 40)}" (${def.key}) of ${op.collection}`; + switch (op.op) { + case "rename_field": + return `Rename the field ${field} to ${op.to}?`; + case "change_field_type": + return `Change the field ${field} from ${def.type} to ${op.type}${op.multiple ? " (multiple)" : ""}?`; + case "set_field_localized": + return op.localized + ? `Make the field ${field} localized (each language version gets its own value)?` + : `Make the field ${field} shared by all language versions (translations' own copies are deleted)?`; + case "remove_field": + return `Remove the field ${field} and its values from every entry?`; + } +} + +const sample = (v: unknown) => + v === undefined ? "nothing" : `"${quote(typeof v === "string" ? v : JSON.stringify(v), 40)}"`; + +/** The approval prompt, built from the dry run (RFC-0041 §6 step 1). */ +export function describeOperation( + op: FieldOperation, + report: FieldOperationReport, + locale: string, +): string { + const counts = [ + `${report.touched} of ${report.entries} entries change`, + report.moved ? `${report.moved} values moved` : "", + report.converted ? `${report.converted} converted` : "", + report.cleared ? `${report.cleared} cleared` : "", + report.removed ? `${report.removed} removed` : "", + report.unconvertible ? `${report.unconvertible} cannot be converted` : "", + report.unchanged ? `${report.unchanged} unchanged` : "", + ].filter(Boolean); + const lines = [headline(op, report, locale), ` ${counts.join(", ")}.`]; + for (const s of report.samples.slice(0, 3)) + lines.push(` ${s.entryId} (${s.locale}): ${sample(s.before)} → ${sample(s.after)}`); + if (op.op === "rename_field" || op.op === "change_field_type") + lines.push( + ` Theme code reading entry.fields.${report.key} directly stops working; themes using entryFields keep working.`, + ); + lines.push( + " Snapshots are taken before and after, and it can be reverted. Only the draft changes: the live site keeps the old field until the next publish.", + ); + return lines.join("\n"); +} + +async function locale(ctx: ToolContext, siteId: string): Promise { + return (await requireSite(ctx, siteId)).defaultLocale; +} + +/** The unfinished operation `operationId` names, checked against this call. */ +async function continued(ctx: ToolContext, siteId: string, op: FieldOperation, opId: string) { + let status: Awaited>; + try { + status = await fieldOperationStatus(ctx.store, siteId, opId); + } catch (e) { + throw toToolError(e); + } + if (status.operation.op !== op.op || status.operation.collection !== op.collection) + throw new ToolError( + "invalid", + `${opId} is a ${status.operation.op} on ${status.operation.collection}; call that tool with it`, + ); + return status; +} + +async function dryRun(ctx: ToolContext, siteId: string, op: FieldOperation) { + try { + return ( + await runFieldOperation(ctx.store, siteId, op, { actor: ctx.principal.actor, dryRun: true }) + ).report; + } catch (e) { + throw toToolError(e); + } +} + +/** The precheck: the dry run's refusal, or an operation id that is not this call's. */ +async function precheck(spec: Spec, a: Record, ctx: ToolContext) { + const siteId = a.siteId as string; + const site = await requireSite(ctx, siteId); + const op = spec.operation(a.collection as string, a); + if (typeof a.operationId === "string") { + await continued(ctx, siteId, op, a.operationId); + return; + } + // Another operation still rewriting the collection is the refusal that says what to do. + const running = site.collections?.[op.collection]?.fields?.find((d) => d.migration)?.migration; + if (running) + throw toToolError( + new InvariantError( + "operation_in_progress", + `field operation ${running.id} is still rewriting ${op.collection}`, + { operationId: running.id, collection: op.collection }, + ), + ); + const report = await dryRun(ctx, siteId, op); + if (report.refusal) + throw toToolError(new InvariantError(report.refusal.code, report.refusal.message, { report })); +} + +async function prompt(spec: Spec, a: Record, ctx: ToolContext): Promise { + const siteId = a.siteId as string; + const op = spec.operation(a.collection as string, a); + if (typeof a.operationId === "string") { + const status = await continued(ctx, siteId, op, a.operationId); + return `Continue the field operation ${status.operationId} (${op.op} on ${op.collection}, started earlier)? ${status.pending} entries are still to rewrite.`; + } + return describeOperation(op, await dryRun(ctx, siteId, op), await locale(ctx, siteId)); +} + +function answer(r: FieldOperationResult, promptText: string) { + return { + prompt: promptText, + dryRun: r.dryRun, + done: r.done, + ...(r.operationId ? { operationId: r.operationId } : {}), + report: r.report, + rewritten: r.rewritten, + skipped: r.skipped, + ...(!r.dryRun && !r.done + ? { + hint: `The operation stopped at its budget. A person can continue it in Studio, or call ${r.report.op} again with operationId "${r.operationId}" (it needs a new approval).`, + } + : {}), + }; +} + +async function handle(spec: Spec, a: Record, ctx: ToolContext) { + const siteId = a.siteId as string; + const op = spec.operation(a.collection as string, a); + const options = { actor: ctx.principal.actor, ...(ctx.now ? { now: ctx.now } : {}) }; + try { + if (typeof a.operationId === "string") { + const status = await continued(ctx, siteId, op, a.operationId); + // A dry run of a continuation only says where the operation stands. + if (a.dryRun === true) + return { + prompt: await prompt(spec, a, ctx), + dryRun: true, + done: status.state !== "running", + operationId: status.operationId, + status, + }; + const r = await continueFieldOperation(ctx.store, siteId, status.operationId, options); + return answer(r, describeOperation(op, r.report, await locale(ctx, siteId))); + } + const r = await runFieldOperation(ctx.store, siteId, op, { + ...options, + dryRun: a.dryRun === true, + }); + return answer(r, describeOperation(op, r.report, await locale(ctx, siteId))); + } catch (e) { + throw toToolError(e); + } +} + +function operationTool(spec: Spec): ToolDefinition { + return { + name: spec.name, + title: spec.title, + description: `${spec.description} Needs a person who may change site settings in every mode; the approval shows the dry run (entries touched, values changed, samples). dryRun: true reports without asking. Snapshots before and after; revertible; only the draft changes until the next publish. A run that stops at its budget answers done: false with an operationId: call again with it (a new approval) or let a person continue it in Studio.`, + inputSchema: obj({ ...COMMON, ...spec.input }, ["siteId", "collection", ...spec.required]), + outputSchema: OUTPUT, + annotations: { + readOnlyHint: false, + destructiveHint: spec.destructive, + idempotentHint: false, + openWorldHint: false, + }, + scopes: ["site:write", "content:write"], + approverScopes: ["site:settings"], + requiresApproval: true, + classify: (a) => (a.dryRun === true ? "read" : "person"), + precheck: (a, ctx) => precheck(spec, a, ctx), + approvalPrompt: (a, ctx) => prompt(spec, a, ctx), + handler: (a, ctx) => handle(spec, a, ctx), + }; +} + +const SPECS: Spec[] = [ + { + name: "rename_field", + title: "Rename field", + description: + "Rename a typed custom field's key (RFC-0041 §6): every entry's value moves from `from` to `to`; nothing is converted.", + input: { + from: { ...KEY, description: "The field's current key" }, + to: { ...KEY, description: "The new key" }, + }, + required: ["from", "to"], + destructive: false, + operation: (collection, a) => ({ + op: "rename_field", + collection, + from: a.from as string, + to: a.to as string, + }), + }, + { + name: "change_field_type", + title: "Change field type", + description: + "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↔datetime, single↔multiple select). Values that cannot be converted refuse the call unless onInvalid is \"clear\".", + input: { + key: { ...KEY, description: "The field's key" }, + type: str("The new type", { enum: [...FIELD_TYPES] }), + options: { + type: "array", + description: + "A new select's options (default: the field's own when it already is a select)", + minItems: 1, + maxItems: 50, + items: { $ref: "#/$defs/FieldOption" }, + $defs: { FieldOption: DEFS.FieldOption, Slug: DEFS.Slug, Locale: DEFS.Locale }, + }, + multiple: { type: "boolean", description: "For a select: the value is a list of options" }, + onInvalid: str("Drop values that cannot be converted (the person approving sees how many)", { + enum: ["clear"], + }), + }, + required: ["key", "type"], + destructive: true, + operation: (collection, a) => ({ + op: "change_field_type", + collection, + key: a.key as string, + type: a.type as FieldType, + ...(a.options ? { options: a.options as FieldOption[] } : {}), + ...(typeof a.multiple === "boolean" ? { multiple: a.multiple } : {}), + ...(a.onInvalid === "clear" ? { onInvalid: "clear" as const } : {}), + }), + }, + { + name: "set_field_localized", + title: "Set field localization", + description: + "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 copies are deleted) (RFC-0041 §5, §6).", + input: { + key: { ...KEY, description: "The field's key" }, + localized: { type: "boolean", description: "true: per language version; false: shared" }, + }, + required: ["key", "localized"], + destructive: false, + operation: (collection, a) => ({ + op: "set_field_localized", + collection, + key: a.key as string, + localized: a.localized as boolean, + }), + }, + { + name: "remove_field", + title: "Remove field", + description: + "Remove a typed custom field (RFC-0041 §6): its value is deleted from every entry, then its definition.", + input: { key: { ...KEY, description: "The field's key" } }, + required: ["key"], + destructive: true, + operation: (collection, a) => ({ op: "remove_field", collection, key: a.key as string }), + }, +]; + +/** rename_field … remove_field, after the collection tools. */ +export function fieldTools(): ToolDefinition[] { + return SPECS.map(operationTool); +} diff --git a/packages/mcp-server/src/server-instructions.ts b/packages/mcp-server/src/server-instructions.ts index e897321a..0dd172e0 100644 --- a/packages/mcp-server/src/server-instructions.ts +++ b/packages/mcp-server/src/server-instructions.ts @@ -11,6 +11,8 @@ Categories and tags: before you file an article, call list_taxonomies (which tax Collections: entries live in collections (posts, and others such as events or team). Call list_collections before create_entry and write only into those; a new kind of content needs create_collection first (a person approves it, so try dryRun: true and explain why), and update_collection renames one. +Typed fields: a collection may define custom fields (list_collections → \`fields\` and \`fieldsSchema\`, e.g. an event's date or a job's salary). Read its fieldsSchema before create_entry or update_entry and send values that fit it (update_entry's \`fields\` is a merge patch: null removes a key); invalid values are refused with their paths. A field marked "set on the original only" is shared by every language version: never set it on a translation. update_collection adds fields and edits labels, help and options; renaming a key, changing a type or localization, or removing a field that holds values go through rename_field, change_field_type, set_field_localized or remove_field. They always wait for a person who sees the dry run, so call them with dryRun: true first and explain the counts. + Entry URLs: an entry lives at its collection's permalink (get_site → \`collections\`; //{slug} by default). set_collection_permalink moves a whole collection (e.g. posts to /blog/{slug}) and leaves a 301 from the old URLs; it always waits for a person, so call it with dryRun: true first and show them the URLs that move. Corrections: when a published article changes in substance, say so with add_correction (correction, clarification or update) instead of editing silently. Notices that are live cannot be removed or reworded by you, and publishing a new one always waits for a person. diff --git a/packages/mcp-server/src/tools.ts b/packages/mcp-server/src/tools.ts index e4908147..ca25bdd7 100644 --- a/packages/mcp-server/src/tools.ts +++ b/packages/mcp-server/src/tools.ts @@ -37,6 +37,7 @@ import { type ToolContext, type ToolDefinition, ToolError, toToolError } from ". import { designTools } from "./design-tools.ts"; import { elevationTools } from "./elevation-tools.ts"; import { entryTools } from "./entry-tools.ts"; +import { fieldTools } from "./field-tools.ts"; import { menuTools } from "./menu-tools.ts"; import { pageRemovalTools, siteAndPageTools } from "./page-tools.ts"; import { permalinkTools } from "./permalink-tools.ts"; @@ -89,6 +90,8 @@ export function defineTools(): ToolDefinition[] { ...taxonomyTools(), // RFC-0040 §5: the collection registry; new collections are a person's decision. ...collectionTools(), + // RFC-0041 §8: field operations rewrite entries; always a person's decision. + ...fieldTools(), // RFC-0038 §5: where a collection's entries live (always a person's decision). ...permalinkTools(), ...menuTools(), diff --git a/packages/mcp-server/test/e2e.test.ts b/packages/mcp-server/test/e2e.test.ts index f713c333..f35ea287 100644 --- a/packages/mcp-server/test/e2e.test.ts +++ b/packages/mcp-server/test/e2e.test.ts @@ -72,7 +72,7 @@ describe("MCP client ↔ Cloudhal server", () => { const { client } = await connect({ store, audit, elicitation: true }); const { tools } = await client.listTools(); expect(tools.map((t) => t.name).slice(0, 3)).toEqual(["list_sites", "get_site", "list_pages"]); - expect(tools).toHaveLength(105); + expect(tools).toHaveLength(109); const publish = tools.find((t) => t.name === "publish"); expect(publish?.annotations).toMatchObject({ destructiveHint: true, readOnlyHint: false }); expect(publish?.inputSchema).toMatchObject({ type: "object" }); diff --git a/packages/mcp-server/test/fields.test.ts b/packages/mcp-server/test/fields.test.ts new file mode 100644 index 00000000..35692436 --- /dev/null +++ b/packages/mcp-server/test/fields.test.ts @@ -0,0 +1,423 @@ +/** + * RFC-0041 §8 over MCP: `list_collections` shows field definitions and their JSON Schema; + * `create_entry` / `update_entry` refuse invalid values in precheck with their paths (before a + * person is asked), `update_entry` takes a merge patch and names typed changes in its approval; + * `update_collection` takes free definition edits and refuses risky ones naming the operation tool; + * the four operation tools wait for a person holding `site:settings` in every mode, show the dry + * run, run after approval, and continue an unfinished operation only with a new approval. + */ +import { type AgentMode, AuditLog, PolicyEngine } from "@cloudhal/agent-runtime"; +import type { ContentEntry, FieldDefinition, Site, Translation } from "@cloudhal/core-contracts"; +import { runFieldOperation, SiteGraphStore, seedStarterSite } from "@cloudhal/core-ts"; +import { MemoryBlobStore, migrate, migrations } from "@cloudhal/storage"; +import { BetterSqlite3Driver } from "@cloudhal/storage/sqlite"; +import { beforeEach, describe, expect, it } from "vitest"; +import { defineTools, runTool, type ToolContext, toolPolicies } from "../src/tools.ts"; + +const agent = { kind: "agent" as const, id: "agent_fields01", name: "Organiser" }; +const person = { kind: "human" as const, id: "user_fields01", name: "Halil" }; +const tools = defineTools(); +const byName = (n: string) => tools.find((t) => t.name === n) as (typeof tools)[number]; +const policy = new PolicyEngine(toolPolicies(tools)); +const L = (label: string, help?: string) => ({ en: { label, ...(help ? { help } : {}) } }); +const OPERATIONS = ["rename_field", "change_field_type", "set_field_localized", "remove_field"]; + +const FIELDS: [FieldDefinition, ...FieldDefinition[]] = [ + { key: "speaker", type: "text", labels: L("Speaker", "Who talks"), localized: true }, + { + key: "startsAt", + type: "datetime", + labels: L("Starts at"), + localized: false, + requiredToPublish: true, + }, + { key: "day", type: "date", labels: L("Day"), localized: false }, + { + key: "format", + type: "select", + labels: L("Format"), + localized: false, + multiple: true, + options: [ + { value: "talk", labels: { en: "Talk", tr: "Konuşma" } }, + { value: "workshop", labels: { en: "Workshop" } }, + ], + }, + { key: "seats", type: "text", labels: L("Seats"), localized: false }, + { key: "poster", type: "image", labels: L("Poster"), localized: false }, +]; + +interface World { + store: SiteGraphStore; + audit: AuditLog; + site: Site; +} + +async function world(): Promise { + const sql = await BetterSqlite3Driver.open(":memory:"); + await migrate(sql, migrations); + const store = new SiteGraphStore({ sql, blobs: new MemoryBlobStore() }); + const seeded = await seedStarterSite(store, { name: "Events", slug: "events" }); + const site = await store.updateSite({ + ...seeded, + locales: ["en", "tr"], + timeZone: "Europe/Istanbul", + collections: { + events: { labels: { en: { singular: "Event", plural: "Events" } }, fields: FIELDS }, + }, + }); + return { store, audit: new AuditLog(sql), site }; +} + +function call(w: World, name: string, mode: AgentMode = "edits") { + const ctx: ToolContext = { + store: w.store, + principal: { actor: agent, scopes: ["*"], mode, ...(mode === "bypass" ? { local: true } : {}) }, + }; + return (args: Record, confirmed = false) => + runTool(byName(name), { siteId: w.site.id, ...args }, ctx, { + policy, + audit: w.audit, + confirmed, + }); +} + +async function rejection( + p: Promise, +): Promise<{ code: string; message: string; details: Record }> { + try { + await p; + } catch (e) { + return e as { code: string; message: string; details: Record }; + } + throw new Error("expected a rejection"); +} + +const paths = (e: { details: Record }) => + (e.details.issues as { path: string }[]).map((i) => i.path); + +function event(w: World, fields: Record, extra: Partial = {}) { + return w.store.createContentEntry({ + siteId: w.site.id, + collection: "events", + slug: `e-${Math.random().toString(36).slice(2, 8)}`, + locale: "en", + status: "draft", + title: "Launch", + body: { format: "markdown", value: "Body" }, + fields, + tags: [], + author: person, + ...extra, + } as never) as Promise; +} + +describe("typed custom fields over MCP (RFC-0041 §8)", () => { + let w: World; + beforeEach(async () => { + w = await world(); + }); + + it("list_collections returns the definitions and the fields schema in the asked locale", async () => { + const r = await call(w, "list_collections")({ locale: "tr" }); + const list = (r.result as { collections: Record[] }).collections; + const events = list.find((c) => c.key === "events") as Record; + expect(events.fields).toEqual(FIELDS); + const schema = events.fieldsSchema as { + properties: Record; + additionalProperties: boolean; + }; + expect(schema.additionalProperties).toBe(false); + expect(schema.properties.speaker?.title).toBe("Speaker"); + expect(schema.properties.speaker?.description).toContain("Who talks"); + expect(schema.properties.format?.description).toContain("talk (Konuşma)"); + expect(schema.properties.seats?.description).toContain("Set on the original only"); + expect(schema.properties.startsAt?.description).toContain("Needed to publish"); + // Collections without definitions carry neither. + const posts = list.find((c) => c.key === "posts") as Record; + expect(posts.fields).toBeUndefined(); + expect(posts.fieldsSchema).toBeUndefined(); + }); + + it("create_entry refuses invalid values in precheck, each at its path", async () => { + const bad = await rejection( + call( + w, + "create_entry", + "manual", + )({ + collection: "events", + slug: "launch", + title: "Launch", + body: "Soon.", + fields: { day: "2026-02-30", format: ["talk", "talk"], poster: "asset_missing01" }, + }), + ); + expect(bad.code).toBe("invalid_fields"); + expect(paths(bad)).toEqual( + expect.arrayContaining(["fields/day", "fields/format", "fields/poster"]), + ); + expect(bad.message).toContain("list_collections"); + const unknown = await rejection( + call( + w, + "create_entry", + )({ + collection: "events", + slug: "launch", + title: "Launch", + body: "Soon.", + fields: { venue: "Hall" }, + }), + ); + expect(unknown.code).toBe("unknown_field"); + expect(paths(unknown)).toEqual(["fields/venue"]); + // Refused before anyone is asked: the audit row is a denial. + const rows = await w.audit.list({ siteId: w.site.id }); + expect(rows.every((row) => row.decision === "deny")).toBe(true); + const ok = await call( + w, + "create_entry", + )({ + collection: "events", + slug: "launch", + title: "Launch", + body: "Soon.", + fields: { speaker: "Ada", day: "2026-11-05" }, + }); + expect((ok.result as { entry: ContentEntry }).entry.fields).toEqual({ + speaker: "Ada", + day: "2026-11-05", + }); + }); + + it("update_entry merges fields (null removes), and checks requiredToPublish and translations", async () => { + const e = await event(w, { speaker: "Ada", seats: "120" }); + const r = await call( + w, + "update_entry", + )({ + entryId: e.id, + rev: e.rev, + fields: { seats: null, day: "2026-11-05" }, + }); + const saved = (r.result as { entry: ContentEntry }).entry; + expect(saved.fields).toEqual({ speaker: "Ada", day: "2026-11-05" }); + + const publish = await rejection( + call(w, "update_entry")({ entryId: e.id, rev: saved.rev, status: "review" }), + ); + expect(publish.code).toBe("required_field"); + expect(paths(publish)).toEqual(["fields/startsAt"]); + + const group = (await w.store.translationGroup(w.site, e.id)).members[0]; + const tr = await event( + w, + { speaker: "Ada TR" }, + { + locale: "tr", + slug: e.slug, + translation: { + group: e.id, + method: "human", + source: { id: e.id, locale: "en", rev: saved.rev, hash: group?.hash as string }, + } as Translation, + }, + ); + const shared = await rejection( + call(w, "update_entry")({ entryId: tr.id, rev: tr.rev, fields: { day: "2027-01-01" } }), + ); + expect(shared.code).toBe("field_not_localized"); + expect(shared.message).toContain(e.id); + }); + + it("update_entry's approval names typed changes readably", async () => { + const e = await event(w, { speaker: "Ada", seats: "120" }); + const r = await call( + w, + "update_entry", + "manual", + )({ + entryId: e.id, + rev: e.rev, + fields: { + startsAt: { at: "2026-11-05T16:30:00Z", timeZone: "Europe/Istanbul" }, + format: ["talk", "workshop"], + day: "2026-11-05", + seats: null, + speaker: "x".repeat(200), + }, + }); + expect(r.decision.kind).toBe("approval-required"); + const prompt = r.approvalPrompt as string; + expect(prompt).toContain('"Starts at" (startsAt) → 5 Nov 2026 19:30 Istanbul'); + expect(prompt).toContain('"Format" (format) → Talk, Workshop'); + expect(prompt).toContain('"Day" (day) → 5 Nov 2026'); + expect(prompt).toContain('"Seats" (seats) removed'); + expect(prompt).toContain("…"); + expect(prompt).not.toContain("x".repeat(80)); + }); + + it("update_collection takes free edits and refuses risky ones naming the operation tool", async () => { + const added = [ + ...FIELDS.map((d) => (d.key === "speaker" ? { ...d, labels: L("Speaker name") } : d)), + { key: "price", type: "number", labels: L("Price"), localized: false, minimum: 0 }, + ]; + const ask = await call( + w, + "update_collection", + "bypass", + )({ collection: "events", fields: added }); + expect(ask.decision.kind).toBe("approval-required"); + expect(ask.approvalPrompt).toContain('add "Price" (price), number'); + expect(ask.approvalPrompt).toContain('change "Speaker name" (speaker)'); + const done = await call(w, "update_collection")({ collection: "events", fields: added }, true); + expect((done.result as { site: Site }).site.collections?.events?.fields?.at(-1)?.key).toBe( + "price", + ); + + await event(w, { seats: "120" }); + const retyped = added.map((d) => (d.key === "seats" ? { ...d, type: "number" } : d)); + const risky = await rejection( + call(w, "update_collection")({ collection: "events", fields: retyped }), + ); + expect(risky.code).toBe("field_change_needs_operation"); + expect(risky.message).toContain("change_field_type"); + const dropped = added.filter((d) => d.key !== "seats"); + const drop = await rejection( + call(w, "update_collection")({ collection: "events", fields: dropped }), + ); + expect(drop.code).toBe("field_change_needs_operation"); + expect(drop.message).toContain("remove_field"); + }); + + it("operation tools: site:write + content:write, approver site:settings, waits even in bypass", async () => { + await event(w, { seats: "120" }); + for (const name of OPERATIONS) { + const tool = byName(name); + expect(tool.scopes, name).toEqual(["site:write", "content:write"]); + expect(tool.approverScopes, name).toEqual(["site:settings"]); + expect(tool.requiresApproval, name).toBe(true); + } + expect(byName("remove_field").annotations.destructiveHint).toBe(true); + expect(byName("change_field_type").annotations.destructiveHint).toBe(true); + expect(byName("rename_field").annotations.destructiveHint).toBe(false); + for (const mode of ["edits", "auto", "bypass"] as const) { + const r = await call( + w, + "rename_field", + mode, + )({ collection: "events", from: "seats", to: "capacity" }); + expect(r.decision.kind, mode).toBe("approval-required"); + expect(r.approvalPrompt).toContain('Rename the field "Seats" (seats) of events to capacity?'); + expect(r.approvalPrompt).toContain("1 values moved"); + expect(r.approvalPrompt).toContain('"120" → "120"'); + expect(r.approvalPrompt).toContain("entry.fields.seats"); + } + // A key without content:write is refused. + const ctx: ToolContext = { + store: w.store, + principal: { actor: agent, scopes: ["site:write"], mode: "edits" }, + }; + const denied = await runTool( + byName("remove_field"), + { siteId: w.site.id, collection: "events", key: "seats" }, + ctx, + { policy }, + ); + expect(denied.decision.kind).toBe("deny"); + expect((await w.store.getSite(w.site.id))?.collections?.events?.fields?.length).toBe(6); + }); + + it("dry run reports without asking; the approved run rewrites, snapshots and reports", async () => { + const e = await event(w, { seats: "120" }); + const dry = await call( + w, + "rename_field", + )({ + collection: "events", + from: "seats", + to: "capacity", + dryRun: true, + }); + expect(dry.decision.kind).toBe("allow"); + expect(dry.result).toMatchObject({ + dryRun: true, + done: false, + report: { moved: 1, touched: 1 }, + }); + expect((await w.store.getContentEntry(e.id))?.fields).toEqual({ seats: "120" }); + + const run = await call(w, "rename_field")( + { collection: "events", from: "seats", to: "capacity" }, + true, + ); + expect(run.result).toMatchObject({ dryRun: false, done: true, report: { moved: 1 } }); + expect((run.result as { operationId: string }).operationId).toMatch(/^snap_/); + expect((await w.store.getContentEntry(e.id))?.fields).toEqual({ capacity: "120" }); + const keys = (await w.store.getSite(w.site.id))?.collections?.events?.fields?.map((d) => d.key); + expect(keys).toContain("capacity"); + }); + + it("refusals: unconvertible values never reach a person; unknown fields; running operations", async () => { + await event(w, { seats: "lots" }); + const ask = call(w, "change_field_type", "bypass"); + const unconvertible = await rejection( + ask({ collection: "events", key: "seats", type: "number" }), + ); + expect(unconvertible.code).toBe("field_values_unconvertible"); + expect(unconvertible.message).toContain('onInvalid: "clear"'); + expect(unconvertible.details.report).toBeDefined(); + const clear = await ask({ + collection: "events", + key: "seats", + type: "number", + onInvalid: "clear", + }); + expect(clear.decision.kind).toBe("approval-required"); + expect(clear.approvalPrompt).toContain("1 cleared"); + + const unknown = await rejection( + call(w, "remove_field")({ collection: "events", key: "venue" }), + ); + expect(unknown.code).toBe("unknown_field"); + }); + + it("an unfinished operation continues only with a new approval", async () => { + for (let i = 0; i < 3; i++) await event(w, { seats: String(i) }); + const started = await runFieldOperation( + w.store, + w.site.id, + { op: "rename_field", collection: "events", from: "seats", to: "capacity" }, + { actor: agent, budget: { entries: 1 } }, + ); + expect(started.done).toBe(false); + const args = { collection: "events", from: "seats", to: "capacity" }; + const again = await rejection(call(w, "rename_field")(args)); + expect(again.code).toBe("operation_in_progress"); + expect(again.message).toContain("operationId"); + + const resume = { ...args, operationId: started.operationId }; + const status = await call(w, "rename_field")({ ...resume, dryRun: true }); + expect(status.result).toMatchObject({ + dryRun: true, + done: false, + status: { state: "running" }, + }); + const ask = await call(w, "rename_field", "bypass")(resume); + expect(ask.decision.kind).toBe("approval-required"); + expect(ask.approvalPrompt).toContain(`Continue the field operation ${started.operationId}`); + const wrong = await rejection( + call( + w, + "remove_field", + )({ collection: "events", key: "seats", operationId: started.operationId }), + ); + expect(wrong.code).toBe("invalid"); + + const done = await call(w, "rename_field")(resume, true); + expect(done.result).toMatchObject({ done: true, operationId: started.operationId }); + const entries = await w.store.listContentEntries(w.site.id, { collection: "events" }); + expect(entries.every((e) => "capacity" in (e.fields ?? {}))).toBe(true); + }); +}); diff --git a/packages/mcp-server/test/tool-order.test.ts b/packages/mcp-server/test/tool-order.test.ts index d4728208..82675c62 100644 --- a/packages/mcp-server/test/tool-order.test.ts +++ b/packages/mcp-server/test/tool-order.test.ts @@ -58,6 +58,10 @@ const ORDER = [ "list_collections", "create_collection", "update_collection", + "rename_field", + "change_field_type", + "set_field_localized", + "remove_field", "set_collection_permalink", "list_menus", "get_menu", diff --git a/packages/mcp-server/test/tools.test.ts b/packages/mcp-server/test/tools.test.ts index b4bbb0d7..b22929db 100644 --- a/packages/mcp-server/test/tools.test.ts +++ b/packages/mcp-server/test/tools.test.ts @@ -95,6 +95,10 @@ describe("Cloudhal tools (SDK-independent)", () => { "list_collections", "create_collection", "update_collection", + "rename_field", + "change_field_type", + "set_field_localized", + "remove_field", "set_collection_permalink", "list_menus", "get_menu",