From 4f3f52052b1aaf97f47e0d6bad5211c963ad7b6f Mon Sep 17 00:00:00 2001 From: Madraka <8898640+Madraka@users.noreply.github.com> Date: Fri, 23 Oct 2026 14:20:57 +0300 Subject: [PATCH] feat(mcp-server): listings, expiry and structured data for agents (RFC-0042 step 4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - list_collections returns list fields with their operators and the preset's path table; update_collection takes listFields, expiry and structuredData, checked in precheck, with approval lines in words (including how many published entries are already past an expiry). - list_entries takes where, orderBy, limit and cursor. - create_page / update_page check contentList queries in precheck. - New preview_structured_data (content:read, read-only): the JSON-LD an entry emits, from the renderer's entryStructuredData, plus issues. 109 → 110 tools. - Hints for invalid_query, structured_data_invalid, invalid_expiry, list_field_in_use and gates_unsupported; server instructions. - astro-renderer: ./structured-data subpath. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Madraka <8898640+Madraka@users.noreply.github.com> --- .changeset/collections-stage3-mcp.md | 14 + packages/astro-renderer/package.json | 5 + packages/mcp-server/README.md | 5 +- packages/mcp-server/src/collection-fields.ts | 2 +- .../mcp-server/src/collection-listings.ts | 280 ++++++++++ packages/mcp-server/src/collection-tools.ts | 46 +- packages/mcp-server/src/entry-list.ts | 98 ++++ packages/mcp-server/src/entry-tools.ts | 18 +- packages/mcp-server/src/field-hints.ts | 19 +- packages/mcp-server/src/page-tools.ts | 7 + packages/mcp-server/src/section-queries.ts | 52 ++ .../mcp-server/src/server-instructions.ts | 2 + .../mcp-server/src/structured-data-tools.ts | 111 ++++ packages/mcp-server/test/e2e.test.ts | 2 +- packages/mcp-server/test/listings.test.ts | 509 ++++++++++++++++++ packages/mcp-server/test/tool-order.test.ts | 1 + packages/mcp-server/test/tools.test.ts | 1 + 17 files changed, 1149 insertions(+), 23 deletions(-) create mode 100644 .changeset/collections-stage3-mcp.md create mode 100644 packages/mcp-server/src/collection-listings.ts create mode 100644 packages/mcp-server/src/entry-list.ts create mode 100644 packages/mcp-server/src/section-queries.ts create mode 100644 packages/mcp-server/src/structured-data-tools.ts create mode 100644 packages/mcp-server/test/listings.test.ts diff --git a/.changeset/collections-stage3-mcp.md b/.changeset/collections-stage3-mcp.md new file mode 100644 index 00000000..1f9f7537 --- /dev/null +++ b/.changeset/collections-stage3-mcp.md @@ -0,0 +1,14 @@ +--- +"@cloudhal/mcp-server": minor +"@cloudhal/astro-renderer": patch +--- + +Collections stage 3, step 4 (RFC-0042 §7 / ADR-0044): MCP. + +- **`list_collections`** returns, per collection, its `listFields` with each field's type, operators (`ops`) and whether it sorts, and, for a collection with a JSON-LD preset, `structuredData`: the type, every path it offers (required ones, "at least one of" groups, defaults, the field types, entry and site properties, constants and vocabulary each takes) and the preset's warnings. +- **`update_collection`** takes `listFields`, `expiry` and `structuredData` (`null` removes one). The store's dry run checks them in `precheck`, so `unknown_field`, `invalid_expiry`, `structured_data_invalid`, `list_field_in_use` and `gates_unsupported` refuse the call, with paths, before a person is asked. The approval says what changes in words ("Events leave listings, feeds, the sitemap and search (their pages stay) 1 hour after "Ends at" (endsAt); 2 published entries are already past it", "structured data: Event, maps startDate ← field startsAt, …", vocabulary warnings), and the answer lists the preset's warnings. Policy is unchanged (`site:write`, a person holding `site:settings` approves in every mode). +- **`list_entries`** takes `where`, `orderBy`, `limit` and `cursor`, run by core-ts's shared evaluator (`queryContentEntries`); with `limit` it answers `total` and `nextCursor`. Invalid queries are refused as `invalid_query` with their paths. +- **`create_page` / `update_page`** check a changed `contentList` section's `where`/`orderBy` in `precheck` (`invalid_query`, `sections/0/where/1/value`). +- **New read-only tool `preview_structured_data`** (`content:read`): the JSON-LD an entry's page emits under its collection's preset, built by the renderer's `entryStructuredData`, with the issues (no preset, a required value missing, over 32 KB) and the preset's warnings. 109 → 110 tools. +- Hints for `invalid_query`, `structured_data_invalid`, `invalid_expiry`, `list_field_in_use` and `gates_unsupported` (expiry and `availability` alike), and a "Listings, expiry and structured data" paragraph in the server instructions. +- `@cloudhal/astro-renderer`: new subpath `./structured-data` (`entryStructuredData`, `withStructuredData`), so tools use it without the package root. diff --git a/packages/astro-renderer/package.json b/packages/astro-renderer/package.json index c8d8bada..268738ce 100644 --- a/packages/astro-renderer/package.json +++ b/packages/astro-renderer/package.json @@ -35,6 +35,7 @@ "default": "./src/runtime.ts" }, "./preview": "./src/preview.ts", + "./structured-data": "./src/structured-data.ts", "./resolver": "./src/resolver.ts", "./markdown": "./src/markdown.ts", "./cloudflare-entry": "./src/cloudflare-entry.ts", @@ -180,6 +181,10 @@ "types": "./dist/src/preview.d.ts", "default": "./dist/src/preview.js" }, + "./structured-data": { + "types": "./dist/src/structured-data.d.ts", + "default": "./dist/src/structured-data.js" + }, "./resolver": { "types": "./dist/src/resolver.d.ts", "default": "./dist/src/resolver.js" diff --git a/packages/mcp-server/README.md b/packages/mcp-server/README.md index e95b996d..6db58c0f 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 -**109 tools**, in the order the server lists them. +**110 tools**, in the order the server lists them. | Tool | Scopes | Kind | Approval | What it does | |---|---|---|---|---| @@ -79,7 +79,8 @@ 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, default sort (RFC-0040) or its typed custom fields (`fields`, the whole list; RFC-0041). | +| `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), list fields, date-dr… | +| `preview_structured_data` | `content:read` | read | — | The JSON-LD document an entry's page emits under its collection's structured-data preset (RFC-0042: Event, JobPosting, Product, Recipe), built exactly as the site builds it, with… | | `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… | diff --git a/packages/mcp-server/src/collection-fields.ts b/packages/mcp-server/src/collection-fields.ts index 6c8970e8..0dee89c8 100644 --- a/packages/mcp-server/src/collection-fields.ts +++ b/packages/mcp-server/src/collection-fields.ts @@ -62,7 +62,7 @@ export function withFields( } /** JSON with sorted keys: stored definitions may list properties in another order. */ -function canonical(value: unknown): string { +export 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))) diff --git a/packages/mcp-server/src/collection-listings.ts b/packages/mcp-server/src/collection-listings.ts new file mode 100644 index 00000000..17432424 --- /dev/null +++ b/packages/mcp-server/src/collection-listings.ts @@ -0,0 +1,280 @@ +/** + * RFC-0042 §7 for the collection tools: `update_collection` takes a collection's `listFields`, + * `expiry` and `structuredData` (null removes one), the store's dry run checks them in precheck + * (`unknown_field`, `invalid_expiry`, `structured_data_invalid`, `list_field_in_use`, + * `gates_unsupported`), and the approval prompt says in words what changes, e.g. "Events leave + * listings, feeds and search 1 hour after "Ends at" (endsAt); 2 published entries are already past + * it". `list_collections` shows each list field's operators and the preset's paths. + */ +import type { + CollectionExpiry, + CollectionSettings, + FieldDefinition, + Site, + StructuredDataPreset, + StructuredDataSource, +} from "@cloudhal/core-contracts"; +import { SiteSchema } from "@cloudhal/core-contracts"; +import { + canOrderBy, + derivedGate, + FIELD_QUERY_OPS, + type FieldQueryOp, + fieldLabel, + originalsOf, +} from "@cloudhal/core-ts/fields"; +import { + checkStructuredData, + KIND_SOURCES, + PRESET_TYPES, + PRESETS, + type PresetType, + type StructuredDataIssue, + VOCABULARIES, +} from "@cloudhal/core-ts/structured-data"; +import { canonical } from "./collection-fields.ts"; +import { type JsonSchema, quote, type ToolContext } from "./common.ts"; + +const DEFS = SiteSchema.$defs as Record; +const SETTINGS_PROPS = (DEFS.CollectionSettings as { properties: Record }) + .properties; +const nullable = (ref: string, description: string, $defs: Record) => ({ + anyOf: [{ $ref: `#/$defs/${ref}` }, { type: "null" }], + description, + $defs, +}); + +/** One line per preset type: its paths, required ones marked with `*`. */ +function presetSummary(): string { + return PRESET_TYPES.map((type) => { + const paths = Object.entries(PRESETS[type].paths).map( + ([path, spec]) => `${path}${spec.required ? "*" : ""}`, + ); + return `${type}: ${paths.join(", ")}`; + }).join(". "); +} + +/** `update_collection`'s stage 3 settings (RFC-0042), each removable with null. */ +export const LISTINGS_INPUT: Record = { + listFields: { + anyOf: [SETTINGS_PROPS.listFields, { type: "null" }], + description: + "Up to 4 field keys that listings (contentList where/orderBy, list_entries) may filter and sort by, in column order; null removes them. A field contentList sections still use cannot be dropped (list_field_in_use)", + $defs: { FieldKey: DEFS.FieldKey }, + }, + expiry: nullable( + "CollectionExpiry", + 'A time gate every entry derives from a date or datetime field: {"field":"endsAt","action":"unlist"} (the page stays, the entry leaves listings, feeds, the sitemap and search) or "hide" (404 everywhere), afterMinutes of grace. Needs a site Worker that enforces derived gates (gates_unsupported otherwise). null removes it: expired entries come back at the next publish', + { CollectionExpiry: DEFS.CollectionExpiry, FieldKey: DEFS.FieldKey }, + ), + structuredData: nullable( + "StructuredDataPreset", + `A JSON-LD preset the entry pages emit instead of BlogPosting: a type and its properties, each mapped to a source ({"kind":"field","field":"startsAt"}, {"kind":"entry","property":"title"}, {"kind":"site","property":"name"} or {"kind":"constant","value":"EUR"}). Unmapped paths with a default read it; required values (*) come from the entry, never a constant. Paths: ${presetSummary()}. Check an entry's output with preview_structured_data. null removes it`, + { + StructuredDataPreset: DEFS.StructuredDataPreset, + StructuredDataSource: DEFS.StructuredDataSource, + EntryValueProperty: DEFS.EntryValueProperty, + SiteValueProperty: DEFS.SiteValueProperty, + FieldKey: DEFS.FieldKey, + }, + ), +}; + +const KEYS = ["listFields", "expiry", "structuredData"] as const; + +/** `next` with the agent's stage 3 settings applied: a value sets it, null removes it. */ +export function withListings( + next: CollectionSettings, + a: Record, +): CollectionSettings { + const out: Record = { ...next }; + for (const k of KEYS) { + if (a[k] === undefined) continue; + if (a[k] === null) delete out[k]; + else out[k] = a[k]; + } + return out as CollectionSettings; +} + +/** "30 minutes", "1 hour", "2 days". */ +function duration(minutes: number): string { + const unit = (n: number, one: string) => `${n} ${one}${n === 1 ? "" : "s"}`; + if (minutes % 1440 === 0) return unit(minutes / 1440, "day"); + if (minutes % 60 === 0) return unit(minutes / 60, "hour"); + return unit(minutes, "minute"); +} + +/** Published entries of the collection whose derived gate under `site` has already passed. */ +async function pastCount(ctx: ToolContext, site: Site, key: string): Promise { + const entries = await ctx.store.listContentEntries(site.id, { + collection: key, + status: ["published"], + }); + const original = originalsOf(entries); + const now = (ctx.now?.() ?? new Date()).getTime(); + return entries.filter((e) => { + const gate = derivedGate(site, e, original(e)); + return gate !== undefined && gate.at <= now; + }).length; +} + +function fieldName(defs: readonly FieldDefinition[] | undefined, key: string, locale: string) { + const def = defs?.find((d) => d.key === key); + return def ? `"${quote(fieldLabel(def, locale, locale), 40)}" (${key})` : key; +} + +async function expiryLine( + ctx: ToolContext, + site: Site, + key: string, + rule: CollectionExpiry, +): Promise { + const settings = site.collections?.[key]; + const plural = settings?.labels?.[site.defaultLocale]?.plural; + const who = plural ? quote(plural, 40) : `Entries of ${key}`; + const when = rule.afterMinutes ? `${duration(rule.afterMinutes)} after` : "at"; + const field = fieldName(settings?.fields, rule.field, site.defaultLocale); + const what = + rule.action === "hide" + ? `disappear from the site (404 on every route)` + : "leave listings, feeds, the sitemap and search (their pages stay)"; + const past = await pastCount(ctx, site, key); + const already = + past === 0 + ? "" + : `; ${past} published ${past === 1 ? "entry is" : "entries are"} already past it`; + return ` expiry: ${who} ${what} ${when} ${field}${already}`; +} + +function sourceText(source: StructuredDataSource): string { + switch (source.kind) { + case "field": + return `field ${source.field}`; + case "entry": + return `entry ${source.property}`; + case "site": + return `site ${source.property}`; + case "constant": + return `"${quote(source.value, 30)}"`; + default: + return "?"; + } +} + +function presetLine(preset: StructuredDataPreset): string { + const maps = Object.entries(preset.properties) + .filter((e): e is [string, StructuredDataSource] => e[1] !== undefined) + .map(([path, source]) => `${path} ← ${sourceText(source)}`); + const shown = maps.slice(0, 8).join(", "); + const more = maps.length > 8 ? ` and ${maps.length - 8} more` : ""; + return ` structured data: ${preset.type}, maps ${shown}${more}; entries without a required value keep BlogPosting`; +} + +/** The warnings (not refusals) of the collection's preset: select options outside a vocabulary. */ +export function presetWarnings(site: Site, key: string): StructuredDataIssue[] { + const only = site.collections?.[key]; + if (!only?.structuredData) return []; + return checkStructuredData({ ...site, collections: { [key]: only } }).filter((i) => i.warning); +} + +/** Approval lines for the stage 3 settings that change between `before` and `next`. */ +export async function describeListings( + ctx: ToolContext, + next: Site, + key: string, + before: CollectionSettings | undefined, +): Promise { + const after = next.collections?.[key] ?? {}; + const changed = (k: (typeof KEYS)[number]) => canonical(before?.[k]) !== canonical(after[k]); + const lines: string[] = []; + if (changed("listFields")) + lines.push( + after.listFields + ? ` list fields: ${after.listFields.map((f) => fieldName(after.fields, f, next.defaultLocale)).join(", ")}` + : " list fields: removed", + ); + if (changed("expiry")) + lines.push( + after.expiry + ? await expiryLine(ctx, next, key, after.expiry) + : " expiry: removed; entries past it come back at the next publish", + ); + if (changed("structuredData")) { + lines.push( + after.structuredData + ? presetLine(after.structuredData) + : " structured data: removed; entry pages go back to BlogPosting", + ); + for (const w of presetWarnings(next, key).slice(0, 3)) + lines.push(` structured data warning: ${w.message}`); + } + return lines; +} + +interface ListFieldView { + key: string; + type: FieldDefinition["type"]; + ops: readonly FieldQueryOp[]; + sortable: boolean; +} + +interface PathView { + path: string; + required?: true; + /** "At least one of" this group is required. */ + group?: string; + default?: StructuredDataSource; + fields: readonly string[]; + entry: readonly string[]; + site: readonly string[]; + constant: boolean; + vocabulary?: string[]; +} + +function pathsOf(type: PresetType): PathView[] { + return Object.entries(PRESETS[type].paths).map(([path, spec]) => { + const sources = KIND_SOURCES[spec.kind]; + return { + path, + ...(spec.required ? { required: true as const } : {}), + ...(spec.group ? { group: spec.group } : {}), + ...(spec.default ? { default: spec.default } : {}), + fields: [...sources.fields, ...(spec.alsoFields ?? [])], + entry: sources.entry, + site: sources.site, + constant: sources.constant && !spec.required, + ...(spec.vocabulary ? { vocabulary: VOCABULARIES[spec.vocabulary].map((v) => v.key) } : {}), + }; + }); +} + +/** What `list_collections` adds per collection for listings and structured data. */ +export function listingsView( + site: Site, + key: string, +): { + listFields?: ListFieldView[]; + structuredData?: { type: PresetType; paths: PathView[]; warnings: StructuredDataIssue[] }; +} { + const settings = site.collections?.[key]; + const defs = new Map((settings?.fields ?? []).map((d) => [d.key, d])); + const listFields = (settings?.listFields ?? []).flatMap((k) => { + const def = defs.get(k); + return def + ? [{ key: k, type: def.type, ops: FIELD_QUERY_OPS[def.type], sortable: canOrderBy(def.type) }] + : []; + }); + const preset = settings?.structuredData; + return { + ...(listFields.length ? { listFields } : {}), + ...(preset && PRESETS[preset.type] + ? { + structuredData: { + type: preset.type, + paths: pathsOf(preset.type), + warnings: presetWarnings(site, key), + }, + } + : {}), + }; +} diff --git a/packages/mcp-server/src/collection-tools.ts b/packages/mcp-server/src/collection-tools.ts index 1aaff606..3dc08958 100644 --- a/packages/mcp-server/src/collection-tools.ts +++ b/packages/mcp-server/src/collection-tools.ts @@ -18,6 +18,13 @@ import { fieldsView, withFields, } from "./collection-fields.ts"; +import { + describeListings, + LISTINGS_INPUT, + listingsView, + presetWarnings, + withListings, +} from "./collection-listings.ts"; import { autoSnapshot, id, @@ -33,6 +40,7 @@ import { } from "./common.ts"; import { SITE, SNAPSHOT } from "./content-shared.ts"; import { PERMALINK_PATTERN } from "./permalink-tools.ts"; +import { previewStructuredDataTool } from "./structured-data-tools.ts"; const ICONS = [ "article", @@ -98,8 +106,10 @@ 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; + const fielded = Array.isArray(a.fields) + ? withFields(current, next, a.fields as FieldDefinition[]) + : next; + return withListings(fielded, a); } function withCollection(site: Site, key: string, settings: CollectionSettings): Site { @@ -160,7 +170,7 @@ async function check(p: Plan, ctx: ToolContext): Promise { } /** What a person decides on. */ -function describe(p: Plan, mode: "create" | "update"): string { +async function describe(p: Plan, mode: "create" | "update", ctx: ToolContext): Promise { const label = p.settings.labels?.[p.site.defaultLocale]; const name = label ? `"${label.plural}" (one: "${label.singular}")` : `"${p.key}"`; const s = collectionSettings({ collections: { [p.key]: p.settings } }, p.key); @@ -180,6 +190,7 @@ function describe(p: Plan, mode: "create" | "update"): string { p.site.defaultLocale, ); if (fields) lines.push(fields); + lines.push(...(await describeListings(ctx, p.next, p.key, p.existing?.settings))); return `${head}\n${lines.join("\n")}`; } @@ -193,6 +204,8 @@ const COLLECTION_OUT: JsonSchema = obj( taxonomies: { type: "array", items: { type: "string" } }, fields: { type: "array", items: { type: "object" } }, fieldsSchema: { type: "object" }, + listFields: { type: "array", items: { type: "object" } }, + structuredData: { type: "object" }, }, ["key", "declared", "settings", "counts", "permalink", "taxonomies"], ); @@ -201,7 +214,7 @@ 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. 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.", + "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. RFC-0042: `listFields` names the fields list_entries and contentList sections may filter (`where`) and sort (`orderBy`) by, each with its operators (`ops`) and whether it sorts; settings.expiry is the collection's date-driven expiry; `structuredData` is the JSON-LD preset's type with every path it offers (required ones, defaults, the sources and vocabulary each takes) and its warnings. 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"], @@ -225,6 +238,7 @@ const listCollectionsTool: ToolDefinition = { collections: registry.map((c) => ({ ...c, ...fieldsView(site, c.key, locale), + ...listingsView(site, c.key), settings: collectionSettings(site, c.key), permalink: collectionPermalink(site, c.key), taxonomies: taxonomies @@ -243,7 +257,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, 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.", + : "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), list fields, date-driven expiry and JSON-LD preset (`listFields`, `expiry`, `structuredData`; RFC-0042; null removes one). 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, @@ -252,7 +266,7 @@ function writeTool(mode: "create" | "update"): ToolDefinition { maxLength: 64, }), ...SETTINGS_INPUT, - ...(mode === "update" ? { fields: FIELD_DEFINITIONS_INPUT } : {}), + ...(mode === "update" ? { fields: FIELD_DEFINITIONS_INPUT, ...LISTINGS_INPUT } : {}), ...(mode === "create" ? { permalink: str("Template, e.g. /etkinlik/{slug}; default //{slug}", { @@ -268,7 +282,13 @@ function writeTool(mode: "create" | "update"): ToolDefinition { mode === "create" ? ["siteId", "collection", "labels"] : ["siteId", "collection"], ), outputSchema: obj( - { prompt: { type: "string" }, dryRun: { type: "boolean" }, site: SITE, snapshot: SNAPSHOT }, + { + prompt: { type: "string" }, + dryRun: { type: "boolean" }, + site: SITE, + snapshot: SNAPSHOT, + warnings: { type: "array", items: { type: "object" } }, + }, ["prompt", "dryRun"], ), annotations: { @@ -283,13 +303,15 @@ function writeTool(mode: "create" | "update"): ToolDefinition { requiresApproval: true, classify: (a) => (a.dryRun === true ? "read" : "person"), precheck: async (a, ctx) => check(await planFor(a, ctx, mode), ctx), - approvalPrompt: async (a, ctx) => describe(await planFor(a, ctx, mode), mode), + approvalPrompt: async (a, ctx) => describe(await planFor(a, ctx, mode), mode, ctx), handler: async (a, ctx) => { const p = await planFor(a, ctx, mode); - const prompt = describe(p, mode); + const prompt = await describe(p, mode, ctx); + const warnings = presetWarnings(p.next, p.key); + const warned = warnings.length ? { warnings } : {}; if (a.dryRun === true) { await check(p, ctx); - return { prompt, dryRun: true }; + return { prompt, dryRun: true, ...warned }; } let site: Site; try { @@ -298,11 +320,11 @@ function writeTool(mode: "create" | "update"): ToolDefinition { throw toToolError(e); } const snapshot = await autoSnapshot(ctx, site.id, name); - return { prompt, dryRun: false, site, snapshot }; + return { prompt, dryRun: false, site, snapshot, ...warned }; }, }; } export function collectionTools(): ToolDefinition[] { - return [listCollectionsTool, writeTool("create"), writeTool("update")]; + return [listCollectionsTool, writeTool("create"), writeTool("update"), previewStructuredDataTool]; } diff --git a/packages/mcp-server/src/entry-list.ts b/packages/mcp-server/src/entry-list.ts new file mode 100644 index 00000000..f264d364 --- /dev/null +++ b/packages/mcp-server/src/entry-list.ts @@ -0,0 +1,98 @@ +/** + * RFC-0042 §4 "API and MCP" for `list_entries`: `where` and `orderBy` over a collection's list + * fields, run by core-ts's shared evaluator (`queryContentEntries`), and `limit`/`cursor` paging. + * Without `orderBy` the list keeps its order (last updated first); without `limit` it is unpaged, + * as before. Problems are refused as `invalid_query` with their paths (`where/0/value`). + */ +import type { ContentEntry, FieldCondition, FieldOrder } from "@cloudhal/core-contracts"; +import { SiteSchema } from "@cloudhal/core-contracts"; +import { type EntryQuery, InvariantError, queryContentEntries } from "@cloudhal/core-ts"; +import type { JsonSchema, ToolContext } from "./common.ts"; + +const DEFS = SiteSchema.$defs as Record; + +export const ENTRY_QUERY_INPUT: Record = { + where: { + type: "array", + minItems: 1, + maxItems: 4, + items: { $ref: "#/$defs/FieldCondition" }, + description: + 'Conditions on the collection\'s list fields (list_collections → listFields, with each field\'s ops), ANDed, e.g. [{"field":"startsAt","op":"gte","value":"now"},{"field":"format","op":"in","values":["talk"]}]. Values are strings read by the field\'s type: a number, YYYY-MM-DD, an RFC 3339 instant, true/false, an option value, or now', + $defs: { FieldCondition: DEFS.FieldCondition }, + }, + orderBy: { + $ref: "#/$defs/FieldOrder", + description: + 'Order by a number, date or datetime list field, e.g. {"field":"startsAt","direction":"asc"}; entries without a value come last', + $defs: { FieldOrder: DEFS.FieldOrder }, + }, + limit: { + type: "integer", + minimum: 1, + maximum: 100, + description: "Page size; the answer then has total and, when more follow, nextCursor", + }, + cursor: { + type: "string", + minLength: 1, + maxLength: 32, + description: "nextCursor from the previous page (same arguments otherwise)", + }, +}; + +const refuse = (path: string, message: string): never => { + throw new InvariantError("invalid_query", `${path}: ${message}`, { + issues: [{ path, code: "invalid_query", message }], + }); +}; + +/** An opaque cursor: the offset of the next page (the API's form). */ +const encodeCursor = (offset: number) => btoa(`o:${offset}`).replace(/=+$/, ""); +function decodeCursor(raw: string): number { + let text = ""; + try { + text = atob(raw); + } catch { + // refused below + } + const m = /^o:(\d{1,9})$/.exec(text); + return m ? Number(m[1]) : refuse("cursor", "not a cursor this list returned"); +} + +export interface EntryListAnswer { + entries: ContentEntry[]; + total?: number; + nextCursor?: string; +} + +const byUpdated = (x: ContentEntry, y: ContentEntry) => y.updatedAt.localeCompare(x.updatedAt); + +/** The entries of `base` matching the call's field query, ordered and paged. */ +export async function listEntries( + ctx: ToolContext, + siteId: string, + base: EntryQuery, + a: Record, +): Promise { + const where = a.where as FieldCondition[] | undefined; + const orderBy = a.orderBy as FieldOrder | undefined; + const limit = a.limit as number | undefined; + if (a.cursor !== undefined && limit === undefined) refuse("cursor", "a cursor needs limit"); + const offset = a.cursor === undefined ? 0 : decodeCursor(a.cursor as string); + const { entries } = await queryContentEntries(ctx.store, siteId, { + ...base, + ...(where ? { where } : {}), + ...(orderBy ? { orderBy } : {}), + now: (ctx.now?.() ?? new Date()).getTime(), + }); + const ordered = orderBy ? entries : entries.sort(byUpdated); + if (limit === undefined) return { entries: ordered }; + const page = ordered.slice(offset, offset + limit); + const next = offset + page.length; + return { + entries: page, + total: ordered.length, + ...(next < ordered.length ? { nextCursor: encodeCursor(next) } : {}), + }; +} diff --git a/packages/mcp-server/src/entry-tools.ts b/packages/mcp-server/src/entry-tools.ts index f35b2032..6c3f401a 100644 --- a/packages/mcp-server/src/entry-tools.ts +++ b/packages/mcp-server/src/entry-tools.ts @@ -33,6 +33,7 @@ import { FIELDS_PATCH, mergeFields, } from "./entry-fields.ts"; +import { ENTRY_QUERY_INPUT, listEntries } from "./entry-list.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"; @@ -41,7 +42,7 @@ const listEntriesTool: ToolDefinition = { name: "list_entries", title: "List articles", description: - 'Content entries, newest first. `collection` defaults to posts (unless the filter names one). `filter` selects by terms (RFC-0002 grammar): clauses joined by "+" (AND), "[a,b]" = either, "-" = none of, "primary:category:x" = primary term, "category:x>*" = x or a sub-category, "collection:posts"; values are term slugs (old slugs work) or ids. Example: "category:news+tag:[ai,llm]+-tag:sponsored".', + 'Content entries, newest first. `collection` defaults to posts (unless the filter names one). `filter` selects by terms (RFC-0002 grammar): clauses joined by "+" (AND), "[a,b]" = either, "-" = none of, "primary:category:x" = primary term, "category:x>*" = x or a sub-category, "collection:posts"; values are term slugs (old slugs work) or ids. Example: "category:news+tag:[ai,llm]+-tag:sponsored". `where`/`orderBy` filter and sort by the collection\'s list fields (RFC-0042; list_collections → listFields), e.g. upcoming events: where [{"field":"startsAt","op":"gte","value":"now"}], orderBy {"field":"startsAt","direction":"asc"}; `limit` pages the answer (total, nextCursor → cursor).', inputSchema: obj( { siteId: id, @@ -58,10 +59,18 @@ const listEntriesTool: ToolDefinition = { minItems: 1, uniqueItems: true, }, + ...ENTRY_QUERY_INPUT, }, ["siteId"], ), - outputSchema: obj({ entries: { type: "array", items: ENTRY } }, ["entries"]), + outputSchema: obj( + { + entries: { type: "array", items: ENTRY }, + total: { type: "integer" }, + nextCursor: { type: "string" }, + }, + ["entries"], + ), annotations: { readOnlyHint: true, destructiveHint: false, @@ -82,10 +91,7 @@ const listEntriesTool: ToolDefinition = { ...(a.status !== undefined ? { status: a.status as ContentEntry["status"][] } : {}), }; try { - const entries = (await ctx.store.listContentEntries(a.siteId as string, query)).sort((x, y) => - y.updatedAt.localeCompare(x.updatedAt), - ); - return { entries }; + return await listEntries(ctx, a.siteId as string, query, a); } catch (e) { mapError(e); } diff --git a/packages/mcp-server/src/field-hints.ts b/packages/mcp-server/src/field-hints.ts index f91512f1..c19d6470 100644 --- a/packages/mcp-server/src/field-hints.ts +++ b/packages/mcp-server/src/field-hints.ts @@ -1,5 +1,6 @@ /** - * RFC-0041: what an agent does next after a typed-field refusal. Every refusal keeps its issues in + * RFC-0041 and RFC-0042: what an agent does next after a typed-field, listing, expiry or + * structured-data 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"; @@ -29,10 +30,26 @@ const HINTS: Partial> = { '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", + // RFC-0042 (listings, expiry, structured data) + invalid_query: + "fix the conditions at the paths in details.issues: only the collection's listFields may be queried, with the operators list_collections shows per field (listFields[].ops); dates are YYYY-MM-DD, instants RFC 3339, or now", + structured_data_invalid: + "fix the mappings at the paths in details.issues: list_collections shows a preset's paths, which are required and the sources each takes (structuredData.paths); field sources need a field of a fitting type, and required values come from the entry, never a constant", + invalid_expiry: + "expiry.field must be a date or datetime field of the collection (list_collections → fields)", + list_field_in_use: + "contentList sections on the pages in details.pages still filter or sort by that field: change their where/orderBy first (update_page), then try again", }; +const GATES_EXPIRY = + "a collection expiry needs a site Worker that enforces derived gates: ask a person to deploy the current site (cloudhal deploy), then try again; until then leave expiry out (the rest of the change can go ahead without it)"; +const GATES_AVAILABILITY = + "the site's renderer has not confirmed it enforces time gates: ask a person to deploy the current site (cloudhal deploy) before setting embargoes or expiries"; + /** The next step for a field refusal; undefined for codes that are not about fields. */ export function fieldHint(code: InvariantCode, details: Readonly>) { + if (code === "gates_unsupported") + return Array.isArray(details.collections) ? GATES_EXPIRY : GATES_AVAILABILITY; 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/page-tools.ts b/packages/mcp-server/src/page-tools.ts index 1c01f3f6..4e75ff8b 100644 --- a/packages/mcp-server/src/page-tools.ts +++ b/packages/mcp-server/src/page-tools.ts @@ -26,6 +26,7 @@ import { unknownBlocksOf, } from "./content-shared.ts"; import { menuHint, menuItemsHiddenBy } from "./menu-tools.ts"; +import { checkSectionQueries } from "./section-queries.ts"; const listSitesTool: ToolDefinition = { name: "list_sites", @@ -141,6 +142,7 @@ const createPageTool: ToolDefinition = { openWorldHint: false, }, scopes: ["content:write"], + precheck: (a, ctx) => checkSectionQueries(ctx, a.siteId as string, a.sections), handler: async (a, ctx) => { assertNoHtmlSections(a.sections); const site = await requireSite(ctx, a.siteId as string); @@ -216,6 +218,11 @@ const updatePageTool: ToolDefinition = { openWorldHint: false, }, scopes: ["content:write"], + precheck: async (a, ctx) => { + const page = await ctx.store.getPage(a.pageId as string); + if (!page || page.siteId !== a.siteId) return; + await checkSectionQueries(ctx, page.siteId, a.sections, page.sections); + }, handler: async (a, ctx) => { assertNoHtmlSections(a.sections); const page = await ctx.store.getPage(a.pageId as string); diff --git a/packages/mcp-server/src/section-queries.ts b/packages/mcp-server/src/section-queries.ts new file mode 100644 index 00000000..50dacc4a --- /dev/null +++ b/packages/mcp-server/src/section-queries.ts @@ -0,0 +1,52 @@ +/** + * RFC-0042 §4 for `create_page` / `update_page`: a `contentList` section's `where`/`orderBy` is + * checked against its collection's list fields in precheck, before anyone is asked or anything is + * written, with the store's rule (only sections whose query changed; `sections/2/where/0/value`). + * The store checks again on write. + */ +import type { Section } from "@cloudhal/core-contracts"; +import { InvariantError } from "@cloudhal/core-ts"; +import { checkFieldQuery, type FieldQueryIssue, fieldDefinitions } from "@cloudhal/core-ts/fields"; +import { requireSite, type ToolContext, toToolError } from "./common.ts"; + +type ListSection = Extract; + +const queryText = (s: ListSection) => + JSON.stringify([s.collection, s.where ?? null, s.orderBy ?? null]); + +/** True when the section filters or sorts by a field, and did not do so the same way before. */ +function changed(section: ListSection, before: Section | undefined): boolean { + if (section.where === undefined && section.orderBy === undefined) return false; + return before?.kind !== "contentList" || queryText(section) !== queryText(before); +} + +/** Refuses (`invalid_query`, every issue at its path) a changed section query that cannot run. */ +export async function checkSectionQueries( + ctx: ToolContext, + siteId: string, + sections: unknown, + stored: readonly Section[] = [], +): Promise { + if (!Array.isArray(sections)) return; + const lists = (sections as Section[]).filter((s) => s?.kind === "contentList"); + if (lists.length === 0) return; + const site = await requireSite(ctx, siteId); + const before = new Map(stored.map((s) => [s.id, s])); + const issues: FieldQueryIssue[] = []; + (sections as Section[]).forEach((section, i) => { + if (section?.kind !== "contentList" || !changed(section, before.get(section.id))) return; + const listFields = site.collections?.[section.collection]?.listFields; + for (const issue of checkFieldQuery( + fieldDefinitions(site, section.collection), + listFields, + section, + )) + issues.push({ ...issue, path: `sections/${i}/${issue.path}` }); + }); + if (issues.length === 0) return; + const shown = issues + .slice(0, 5) + .map((i) => `${i.path}: ${i.message}`) + .join("; "); + throw toToolError(new InvariantError("invalid_query", shown, { issues })); +} diff --git a/packages/mcp-server/src/server-instructions.ts b/packages/mcp-server/src/server-instructions.ts index 2ef80352..965f97fb 100644 --- a/packages/mcp-server/src/server-instructions.ts +++ b/packages/mcp-server/src/server-instructions.ts @@ -13,6 +13,8 @@ Collections: entries live in collections (posts, and others such as events or te 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. +Listings, expiry and structured data: a collection may name up to four list fields (list_collections → \`listFields\`, each with its operators). Only those can filter and sort: list_entries and contentList sections take \`where\` (e.g. {"field":"startsAt","op":"gte","value":"now"}) and \`orderBy\` (number, date and datetime fields); anything else is refused as invalid_query with its path. A collection's \`expiry\` hides or unlists entries once a date field has passed, and \`structuredData\` maps fields to a JSON-LD type (Event, JobPosting, Product, Recipe). update_collection sets all three and a person who may change site settings approves it, so try dryRun: true first; check an entry's JSON-LD with preview_structured_data before asking for a publish. + 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/structured-data-tools.ts b/packages/mcp-server/src/structured-data-tools.ts new file mode 100644 index 00000000..38096ad4 --- /dev/null +++ b/packages/mcp-server/src/structured-data-tools.ts @@ -0,0 +1,111 @@ +/** + * RFC-0042 §7: `preview_structured_data` shows the JSON-LD an entry's page would emit under its + * collection's preset (the renderer's own `entryStructuredData`, so it is exactly what the site + * emits) and why it emits none: no preset, a required value missing, over the cap. Read-only, so an + * agent can check its mappings and values before a person publishes. + */ + +import { entryStructuredData } from "@cloudhal/astro-renderer/structured-data"; +import type { Asset, ContentEntry, Site } from "@cloudhal/core-contracts"; +import { primaryOrigin } from "@cloudhal/core-ts"; +import { presetWarnings } from "./collection-listings.ts"; +import { + id, + obj, + requireSite, + type ToolContext, + type ToolDefinition, + ToolError, +} from "./common.ts"; + +/** Every `assetId` a JSON value names (image fields, cover images, the site's logos). */ +function assetIds(value: unknown, out = new Set()): Set { + if (Array.isArray(value)) for (const v of value) assetIds(v, out); + else if (value && typeof value === "object") + for (const [k, v] of Object.entries(value)) { + if (k === "assetId" && typeof v === "string") out.add(v); + else assetIds(v, out); + } + return out; +} + +async function assetsFor( + ctx: ToolContext, + site: Site, + nodes: readonly unknown[], +): Promise { + const ids = new Set(); + for (const n of nodes) assetIds(n, ids); + const found = await Promise.all([...ids].map((i) => ctx.store.getAsset(i))); + return found.filter((a): a is Asset => a !== undefined && a.siteId === site.id); +} + +/** The site's public origin; without a domain, the preview server's, else a local placeholder. */ +function originOf(site: Site, ctx: ToolContext): string { + const primary = primaryOrigin(site); + if (primary) return primary; + return ctx.previewBaseUrl ? new URL(ctx.previewBaseUrl).origin : "http://localhost:4321"; +} + +export const previewStructuredDataTool: ToolDefinition = { + name: "preview_structured_data", + title: "Preview structured data", + description: + "The JSON-LD document an entry's page emits under its collection's structured-data preset (RFC-0042: Event, JobPosting, Product, Recipe), built exactly as the site builds it, with `issues`: why there is no document (no preset, a required value missing such as startDate, over 32 KB) and the preset's warnings (select options outside a vocabulary). Read-only; drafts work too. URLs use the site's primary domain (`origin`). Without a document the page keeps its BlogPosting.", + inputSchema: obj({ siteId: id, entryId: id }, ["siteId", "entryId"]), + outputSchema: obj( + { + preset: { type: ["string", "null"] }, + origin: { type: "string" }, + document: { type: ["object", "null"] }, + issues: { type: "array", items: { type: "object" } }, + }, + ["preset", "origin", "document", "issues"], + ), + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + scopes: ["content:read"], + handler: async (a, ctx) => { + const site = await requireSite(ctx, a.siteId as string); + const entry = await ctx.store.getContentEntry(a.entryId as string); + if (!entry || entry.siteId !== site.id) + throw new ToolError("not_found", `entry ${a.entryId} not found`); + const origin = originOf(site, ctx); + const preset = site.collections?.[entry.collection]?.structuredData; + if (!preset) + return { + preset: null, + origin, + document: null, + issues: [ + { + path: `collections/${entry.collection}/structuredData`, + code: "no_preset", + message: `the collection ${entry.collection} has no structured-data preset, so its pages emit BlogPosting; a person can add one with update_collection (structuredData)`, + }, + ], + }; + const group = entry.translation?.group; + const original = + group && group !== entry.id ? await ctx.store.getContentEntry(group) : undefined; + const sameSite = original?.siteId === site.id ? original : undefined; + const assets = await assetsFor(ctx, site, [entry, sameSite, site.brand]); + const source = { + site: async () => site, + entryById: async (key: string): Promise => + key === sameSite?.id ? sameSite : undefined, + assets: async () => assets, + }; + const result = await entryStructuredData(source, entry, { origin }); + return { + preset: preset.type, + origin, + document: result.doc ?? null, + issues: [...result.issues, ...presetWarnings(site, entry.collection)], + }; + }, +}; diff --git a/packages/mcp-server/test/e2e.test.ts b/packages/mcp-server/test/e2e.test.ts index f35ea287..1a67f35c 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(109); + expect(tools).toHaveLength(110); 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/listings.test.ts b/packages/mcp-server/test/listings.test.ts new file mode 100644 index 00000000..1a035a3d --- /dev/null +++ b/packages/mcp-server/test/listings.test.ts @@ -0,0 +1,509 @@ +/** + * RFC-0042 §7 over MCP: `list_collections` shows list fields with their operators and the preset's + * paths; `update_collection` takes `listFields`, `expiry` and `structuredData`, refuses bad ones in + * precheck (paths and hints, before a person is asked) and describes them readably in its approval; + * `list_entries` filters, sorts and pages by list fields; `create_page`/`update_page` check a + * `contentList` query in precheck; `preview_structured_data` shows an entry's JSON-LD and issues. + */ +import { type AgentMode, AuditLog, PolicyEngine } from "@cloudhal/agent-runtime"; +import type { + CollectionSettings, + ContentEntry, + FieldDefinition, + Page, + Site, +} from "@cloudhal/core-contracts"; +import { SiteGraphStore, seedStarterSite } from "@cloudhal/core-ts"; +import { MemoryBlobStore } 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_listings1", name: "Organiser" }; +const person = { kind: "human" as const, id: "user_listings01", 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) => ({ en: { label } }); +/** 5 November 2026, 12:00 UTC. */ +const T = Date.UTC(2026, 10, 5, 12, 0, 0); +const HOUR = 3_600_000; +const at = (ms: number) => ({ at: new Date(ms).toISOString(), timeZone: "Europe/Istanbul" }); + +const FIELDS: [FieldDefinition, ...FieldDefinition[]] = [ + { key: "startsAt", type: "datetime", labels: L("Starts at"), localized: false }, + { key: "endsAt", type: "datetime", labels: L("Ends at"), localized: false }, + { key: "venue", type: "text", labels: L("Venue"), localized: false }, + { key: "city", type: "text", labels: L("City"), localized: false }, + { key: "price", type: "number", labels: L("Price"), localized: false }, + { + key: "state", + type: "select", + labels: L("State"), + localized: false, + options: [ + { value: "scheduled", labels: { en: "Scheduled" } }, + { value: "cancelled", labels: { en: "Cancelled" } }, + { value: "sold-out", labels: { en: "Sold out" } }, + ], + }, +]; + +const PRESET = { + type: "Event", + properties: { + startDate: { kind: "field", field: "startsAt" }, + endDate: { kind: "field", field: "endsAt" }, + "location.name": { kind: "field", field: "venue" }, + "location.address.addressLocality": { kind: "field", field: "city" }, + eventStatus: { kind: "field", field: "state" }, + "offers.price": { kind: "field", field: "price" }, + "offers.priceCurrency": { kind: "constant", value: "TRY" }, + }, +} as const; + +interface World { + store: SiteGraphStore; + audit: AuditLog; + site: Site; + gates: { on: boolean }; + /** The seeded post's terms: a published entry needs a primary category. */ + terms: ContentEntry["terms"]; +} + +async function world(events: CollectionSettings = {}): Promise { + const sql = await BetterSqlite3Driver.open(":memory:"); + const gates = { on: true }; + const store = new SiteGraphStore({ + sql, + blobs: new MemoryBlobStore(), + gates: { enforced: () => gates.on }, + }); + await store.init(); + const seeded = await seedStarterSite(store, { name: "Events", slug: "events" }); + const site = await store.updateSite({ + ...seeded, + timeZone: "Europe/Istanbul", + collections: { + ...seeded.collections, + events: { + labels: { en: { singular: "Event", plural: "Events" } }, + fields: FIELDS, + ...events, + }, + }, + }); + const [hello] = await store.listContentEntries(site.id, "posts"); + return { store, audit: new AuditLog(sql), site, gates, terms: hello?.terms }; +} + +function call(w: World, name: string, mode: AgentMode = "edits") { + const ctx: ToolContext = { + store: w.store, + now: () => new Date(T), + 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, title: string, fields: Record, status = "published") { + return w.store.createContentEntry({ + siteId: w.site.id, + collection: "events", + slug: title.toLowerCase().replace(/\s+/g, "-"), + locale: "en", + status, + title, + summary: `About ${title}`, + body: { format: "markdown", value: `All about ${title}.` }, + fields, + tags: [], + ...(w.terms ? { terms: w.terms } : {}), + author: person, + } as never) as Promise; +} + +const LISTED = { listFields: ["startsAt", "state", "price"] } as CollectionSettings; + +describe("listings, expiry and structured data over MCP (RFC-0042 §7)", () => { + let w: World; + beforeEach(async () => { + w = await world(); + }); + + it("list_collections shows list fields with their operators and the preset's paths", async () => { + w = await world({ ...LISTED, structuredData: PRESET } as CollectionSettings); + const r = await call(w, "list_collections")({}); + const list = (r.result as { collections: Record[] }).collections; + const events = list.find((c) => c.key === "events") as Record; + expect(events.listFields).toEqual([ + { + key: "startsAt", + type: "datetime", + ops: ["eq", "ne", "lt", "lte", "gt", "gte", "set", "unset"], + sortable: true, + }, + { key: "state", type: "select", ops: ["eq", "ne", "in", "set", "unset"], sortable: false }, + { + key: "price", + type: "number", + ops: ["eq", "ne", "lt", "lte", "gt", "gte", "set", "unset"], + sortable: true, + }, + ]); + const sd = events.structuredData as { + type: string; + paths: { path: string; required?: true; default?: unknown; vocabulary?: string[] }[]; + warnings: { message: string }[]; + }; + expect(sd.type).toBe("Event"); + const startDate = sd.paths.find((p) => p.path === "startDate"); + expect(startDate?.required).toBe(true); + expect(sd.paths.find((p) => p.path === "name")?.default).toEqual({ + kind: "entry", + property: "title", + }); + expect(sd.paths.find((p) => p.path === "eventStatus")?.vocabulary).toContain("cancelled"); + expect(sd.warnings.map((i) => i.message)).toEqual([ + 'option "sold-out" emits nothing for eventStatus', + ]); + const posts = list.find((c) => c.key === "posts") as Record; + expect(posts.listFields).toBeUndefined(); + expect(posts.structuredData).toBeUndefined(); + }); + + it("update_collection refuses bad settings in precheck with paths and hints", async () => { + const unlisted = await rejection( + call(w, "update_collection")({ collection: "events", listFields: ["startsAt", "nope"] }), + ); + expect(unlisted.code).toBe("unknown_field"); + expect(paths(unlisted)).toEqual(["collections/events/listFields/1"]); + + const expiry = await rejection( + call( + w, + "update_collection", + )({ + collection: "events", + expiry: { field: "venue", action: "unlist" }, + }), + ); + expect(expiry.code).toBe("invalid_expiry"); + expect(paths(expiry)).toEqual(["collections/events/expiry/field"]); + expect(expiry.message).toContain("date or datetime field"); + + const preset = await rejection( + call( + w, + "update_collection", + )({ + collection: "events", + structuredData: { + type: "Event", + properties: { + startDate: { kind: "field", field: "venue" }, + "location.name": { kind: "constant", value: "Hall" }, + }, + }, + }), + ); + expect(preset.code).toBe("structured_data_invalid"); + expect(paths(preset)).toEqual( + expect.arrayContaining([ + "collections/events/structuredData/properties/startDate/field", + "collections/events/structuredData/properties/location.name/value", + "collections/events/structuredData/properties/location.address", + ]), + ); + expect(preset.message).toContain("structuredData.paths"); + + w.gates.on = false; + const gates = await rejection( + call( + w, + "update_collection", + )({ + collection: "events", + expiry: { field: "endsAt", action: "unlist" }, + }), + ); + expect(gates.code).toBe("gates_unsupported"); + expect(gates.message).toContain("cloudhal deploy"); + expect(gates.message).toContain("leave expiry out"); + // Refused before anyone was asked: nothing waits and nothing changed. + expect((await w.store.getSite(w.site.id))?.collections?.events?.expiry).toBeUndefined(); + }); + + it("update_collection's approval describes the changes in words", async () => { + await event(w, "Past gig", { startsAt: at(T - 3 * HOUR), endsAt: at(T - HOUR) }); + await event(w, "Next gig", { startsAt: at(T + HOUR), endsAt: at(T + 3 * HOUR) }); + await event(w, "Old draft", { endsAt: at(T - 5 * HOUR) }, "draft"); + const args = { + collection: "events", + ...LISTED, + expiry: { field: "endsAt", action: "unlist", afterMinutes: 60 }, + structuredData: PRESET, + }; + for (const mode of ["edits", "auto", "bypass"] as AgentMode[]) { + const ask = await call(w, "update_collection", mode)(args); + expect(ask.decision.kind, mode).toBe("approval-required"); + const prompt = ask.approvalPrompt as string; + expect(prompt).toContain( + 'list fields: "Starts at" (startsAt), "State" (state), "Price" (price)', + ); + // Past gig ended an hour ago: the grace of an hour ends now. + expect(prompt).toContain( + 'expiry: Events leave listings, feeds, the sitemap and search (their pages stay) 1 hour after "Ends at" (endsAt); 1 published entry is already past it', + ); + expect(prompt).toContain("structured data: Event, maps startDate ← field startsAt"); + expect(prompt).toContain('offers.priceCurrency ← "TRY"'); + expect(prompt).toContain('structured data warning: option "sold-out" emits nothing'); + } + const done = await call(w, "update_collection")(args, true); + const saved = (done.result as { site: Site; warnings: unknown[] }).site.collections?.events; + expect(saved?.listFields).toEqual(["startsAt", "state", "price"]); + expect(saved?.expiry?.afterMinutes).toBe(60); + expect(saved?.structuredData?.type).toBe("Event"); + expect((done.result as { warnings: unknown[] }).warnings).toHaveLength(1); + + const hide = await call( + w, + "update_collection", + )({ + collection: "events", + expiry: { field: "endsAt", action: "hide" }, + structuredData: null, + }); + expect(hide.approvalPrompt).toContain( + 'expiry: Events disappear from the site (404 on every route) at "Ends at" (endsAt); 1 published entry is already past it', + ); + expect(hide.approvalPrompt).toContain( + "structured data: removed; entry pages go back to BlogPosting", + ); + expect(hide.approvalPrompt).not.toContain("list fields"); + const removed = await call(w, "update_collection")( + { collection: "events", expiry: null, structuredData: null, listFields: null }, + true, + ); + const after = (removed.result as { site: Site }).site.collections?.events; + expect(after?.expiry).toBeUndefined(); + expect(after?.structuredData).toBeUndefined(); + expect(after?.listFields).toBeUndefined(); + }); + + it("refuses to unlist a field contentList sections still use (list_field_in_use)", async () => { + w = await world(LISTED); + const home = (await w.store.listPages(w.site.id)).find((p) => p.path === "/") as Page; + const section = { + id: "sec_upcoming0001", + kind: "contentList", + collection: "events", + limit: 10, + sort: "newest", + where: [{ field: "startsAt", op: "gte", value: "now" }], + }; + await call(w, "update_page")( + { pageId: home.id, rev: home.rev, sections: [...home.sections, section] }, + true, + ); + const r = await rejection( + call(w, "update_collection")({ collection: "events", listFields: ["price"] }), + ); + expect(r.code).toBe("list_field_in_use"); + expect(r.message).toContain("update_page"); + expect((r.details.pages as { path: string }[]).map((p) => p.path)).toEqual(["/"]); + }); + + it("create_page and update_page check contentList queries in precheck", async () => { + w = await world(LISTED); + const sections = [ + { + id: "sec_upcoming0001", + kind: "contentList", + collection: "events", + limit: 10, + sort: "newest", + where: [ + { field: "venue", op: "eq", value: "Hall" }, + { field: "startsAt", op: "gte", value: "tomorrow" }, + ], + orderBy: { field: "state", direction: "asc" }, + }, + ]; + const created = await rejection( + call(w, "create_page")({ path: "/events", title: "Events", sections }), + ); + expect(created.code).toBe("invalid_query"); + expect(paths(created)).toEqual([ + "sections/0/where/0/field", + "sections/0/where/1/value", + "sections/0/orderBy/field", + ]); + expect(created.message).toContain("listFields[].ops"); + expect((await w.store.listPages(w.site.id)).some((p) => p.path === "/events")).toBe(false); + + const ok = await call( + w, + "create_page", + )({ + path: "/events", + title: "Events", + sections: [ + { + ...sections[0], + where: [{ field: "startsAt", op: "gte", value: "now" }], + orderBy: { field: "startsAt", direction: "asc" }, + }, + ], + }); + const page = (ok.result as { page: Page }).page; + const updated = await rejection( + call(w, "update_page")({ pageId: page.id, rev: page.rev, sections }), + ); + expect(updated.code).toBe("invalid_query"); + expect(paths(updated)[0]).toBe("sections/0/where/0/field"); + }); + + it("list_entries filters, sorts and pages by list fields", async () => { + w = await world(LISTED); + await event(w, "Past gig", { startsAt: at(T - 3 * HOUR), state: "scheduled", price: 10 }); + await event(w, "Later gig", { startsAt: at(T + 48 * HOUR), state: "cancelled", price: 30 }); + await event(w, "Soon gig", { startsAt: at(T + 2 * HOUR), state: "scheduled", price: 20 }); + await event(w, "Undated gig", { state: "scheduled" }); + const list = call(w, "list_entries"); + const titles = (r: { result?: unknown }) => + (r.result as { entries: ContentEntry[] }).entries.map((e) => e.title); + + const upcoming = await list({ + collection: "events", + where: [{ field: "startsAt", op: "gte", value: "now" }], + orderBy: { field: "startsAt", direction: "asc" }, + }); + expect(titles(upcoming)).toEqual(["Soon gig", "Later gig"]); + + const cheap = await list({ + collection: "events", + where: [ + { field: "state", op: "in", values: ["scheduled"] }, + { field: "price", op: "lt", value: "25" }, + ], + orderBy: { field: "price", direction: "desc" }, + }); + expect(titles(cheap)).toEqual(["Soon gig", "Past gig"]); + + const first = await list({ + collection: "events", + orderBy: { field: "startsAt", direction: "desc" }, + limit: 2, + }); + const page1 = first.result as { entries: ContentEntry[]; total: number; nextCursor: string }; + expect(titles(first)).toEqual(["Later gig", "Soon gig"]); + expect(page1.total).toBe(4); + const second = await list({ + collection: "events", + orderBy: { field: "startsAt", direction: "desc" }, + limit: 2, + cursor: page1.nextCursor, + }); + // Entries without a value sort last. + expect(titles(second)).toEqual(["Past gig", "Undated gig"]); + expect((second.result as { nextCursor?: string }).nextCursor).toBeUndefined(); + + // Without a field query the answer stays the plain list. + const plain = await list({ collection: "events" }); + expect(plain.result).toEqual({ entries: expect.any(Array) }); + expect(titles(plain)).toHaveLength(4); + + const bad = await rejection( + list({ + collection: "events", + where: [{ field: "venue", op: "eq", value: "x" }], + orderBy: { field: "state", direction: "asc" }, + }), + ); + expect(bad.code).toBe("invalid_query"); + expect(paths(bad)).toEqual(["where/0/field", "orderBy/field"]); + expect(bad.message).toContain("list_collections"); + const cursor = await rejection(list({ collection: "events", limit: 2, cursor: "zzz" })); + expect(paths(cursor)).toEqual(["cursor"]); + }); + + it("preview_structured_data returns the entry's JSON-LD and its issues", async () => { + w = await world({ structuredData: PRESET } as CollectionSettings); + const full = await event( + w, + "Soon gig", + { + startsAt: at(T + 2 * HOUR), + endsAt: at(T + 4 * HOUR), + venue: "Moda Stage", + city: "Istanbul", + state: "cancelled", + price: 250, + }, + "draft", + ); + const r = await call(w, "preview_structured_data")({ entryId: full.id }); + const out = r.result as { + preset: string; + origin: string; + document: Record; + issues: { code: string; message: string; warning?: true }[]; + }; + expect(out.preset).toBe("Event"); + expect(out.document).toMatchObject({ + "@context": "https://schema.org", + "@type": "Event", + name: "Soon gig", + startDate: "2026-11-05T17:00:00+03:00", + eventStatus: "https://schema.org/EventCancelled", + location: { + "@type": "Place", + name: "Moda Stage", + address: { "@type": "PostalAddress", addressLocality: "Istanbul" }, + }, + offers: { "@type": "Offer", price: 250, priceCurrency: "TRY" }, + }); + expect(out.issues).toEqual([expect.objectContaining({ code: "vocabulary", warning: true })]); + + const partial = await event(w, "No date", { venue: "Hall", city: "Izmir" }, "draft"); + const missing = await call(w, "preview_structured_data")({ entryId: partial.id }); + const m = missing.result as { document: unknown; issues: { path: string; message: string }[] }; + expect(m.document).toBeNull(); + expect(m.issues[0]).toMatchObject({ + path: "startDate", + message: "Structured data: startDate missing", + }); + + const post = (await w.store.listContentEntries(w.site.id, "posts"))[0] as ContentEntry; + const none = await call(w, "preview_structured_data")({ entryId: post.id }); + expect(none.result).toMatchObject({ preset: null, document: null }); + expect((none.result as { issues: { code: string }[] }).issues[0]?.code).toBe("no_preset"); + + // Read-only: content:read is enough, and it never waits for anyone. + const tool = byName("preview_structured_data"); + expect(tool.scopes).toEqual(["content:read"]); + expect(tool.annotations.readOnlyHint).toBe(true); + const planned = await call(w, "preview_structured_data", "plan")({ entryId: full.id }); + expect(planned.decision.kind).toBe("allow"); + }); +}); diff --git a/packages/mcp-server/test/tool-order.test.ts b/packages/mcp-server/test/tool-order.test.ts index 82675c62..66543076 100644 --- a/packages/mcp-server/test/tool-order.test.ts +++ b/packages/mcp-server/test/tool-order.test.ts @@ -58,6 +58,7 @@ const ORDER = [ "list_collections", "create_collection", "update_collection", + "preview_structured_data", "rename_field", "change_field_type", "set_field_localized", diff --git a/packages/mcp-server/test/tools.test.ts b/packages/mcp-server/test/tools.test.ts index b22929db..f3264366 100644 --- a/packages/mcp-server/test/tools.test.ts +++ b/packages/mcp-server/test/tools.test.ts @@ -95,6 +95,7 @@ describe("Cloudhal tools (SDK-independent)", () => { "list_collections", "create_collection", "update_collection", + "preview_structured_data", "rename_field", "change_field_type", "set_field_localized",