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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .changeset/collections-stage3-mcp.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions packages/astro-renderer/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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"
Expand Down
5 changes: 3 additions & 2 deletions packages/mcp-server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import { createCloudhalMcpHandler } from "@cloudhal/mcp-server/http"; // fet
## Tool reference

<!-- tools:start (generated by scripts/mcp-tools-doc.ts; do not edit) -->
**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 |
|---|---|---|---|---|
Expand Down Expand Up @@ -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 (/&lt;prefix>/{slug}, default /&lt;key>… |
| `update_collection` | `site:write` | write | always a person | Change a collection's names (merged per locale), icon, feed, sitemap, 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… |
Expand Down
2 changes: 1 addition & 1 deletion packages/mcp-server/src/collection-fields.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)))
Expand Down
280 changes: 280 additions & 0 deletions packages/mcp-server/src/collection-listings.ts
Original file line number Diff line number Diff line change
@@ -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<string, JsonSchema>;
const SETTINGS_PROPS = (DEFS.CollectionSettings as { properties: Record<string, JsonSchema> })
.properties;
const nullable = (ref: string, description: string, $defs: Record<string, unknown>) => ({
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<string, JsonSchema> = {
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<string, unknown>,
): CollectionSettings {
const out: Record<string, unknown> = { ...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<number> {
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<string> {
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<string[]> {
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),
},
}
: {}),
};
}
Loading
Loading