From 18a3960e7dad6e7fdca94b440556fd2d76671263 Mon Sep 17 00:00:00 2001 From: Madraka <8898640+Madraka@users.noreply.github.com> Date: Thu, 22 Oct 2026 22:41:54 +0300 Subject: [PATCH] feat(mcp-server): list/create/update_collection; create_entry only in registered collections (RFC-0040 step 4) list_collections (content:read) returns the registry with names, icon, counts, permalink, settings with defaults and the taxonomies that apply. create_collection and update_collection (site:write, approval in every mode, approverScopes site:settings, dryRun) declare and name collections; overlaps, duplicates and a missing default-locale name are refused in the precheck. create_entry's precheck refuses a collection outside the registry (collection_undeclared); inferred collections keep working. Server instructions and the generated tool reference updated. Refs #71 (RFC-0040, ADR-0042) Co-Authored-By: Claude Opus 5.5 Signed-off-by: Madraka <8898640+Madraka@users.noreply.github.com> --- .changeset/collections-mcp.md | 10 + packages/mcp-server/README.md | 7 +- packages/mcp-server/src/collection-tools.ts | 287 ++++++++++++++++++ packages/mcp-server/src/entry-tools.ts | 6 +- .../mcp-server/src/server-instructions.ts | 2 + packages/mcp-server/src/tools.ts | 3 + packages/mcp-server/test/collections.test.ts | 169 +++++++++++ packages/mcp-server/test/e2e.test.ts | 2 +- packages/mcp-server/test/tool-order.test.ts | 3 + packages/mcp-server/test/tools.test.ts | 3 + 10 files changed, 488 insertions(+), 4 deletions(-) create mode 100644 .changeset/collections-mcp.md create mode 100644 packages/mcp-server/src/collection-tools.ts create mode 100644 packages/mcp-server/test/collections.test.ts diff --git a/.changeset/collections-mcp.md b/.changeset/collections-mcp.md new file mode 100644 index 00000000..ac85706d --- /dev/null +++ b/.changeset/collections-mcp.md @@ -0,0 +1,10 @@ +--- +"@cloudhal/mcp-server": minor +--- + +MCP collection tools (RFC-0040 step 4): +- `list_collections` (`content:read`) lists every collection with its names, icon, entry counts by status, permalink, feed, sitemap and sort settings, and the taxonomies that apply. +- `create_collection` declares a collection. It needs names in the default locale and optionally takes an icon, permalink, feed, sitemap and sort. +- `update_collection` changes a collection's names (merged by locale), icon, feed, sitemap or sort. +- Both write tools need `site:write` and wait for a person in every mode, and that person must hold `site:settings`. Overlaps, duplicates and missing names are refused before anyone is asked; `dryRun` checks without asking. +- `create_entry` now refuses a collection outside the registry (`collection_undeclared`) and points to `create_collection`. Collections that already have entries keep working. diff --git a/packages/mcp-server/README.md b/packages/mcp-server/README.md index ae94ff39..bb2329cd 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 -**102 tools**, in the order the server lists them. +**105 tools**, in the order the server lists them. | Tool | Scopes | Kind | Approval | What it does | |---|---|---|---|---| @@ -38,7 +38,7 @@ import { createCloudhalMcpHandler } from "@cloudhal/mcp-server/http"; // fet | `delete_page` | `content:write` | write (destructive) | by the key's mode | Delete a page and remove it from navigation. | | `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), optionally filed under existing terms (`terms`; see list_taxonomies and list_terms). | +| `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. | | `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. | @@ -77,6 +77,9 @@ import { createCloudhalMcpHandler } from "@cloudhal/mcp-server/http"; // fet | `list_redirects` | `site:read` | read | — | The site's redirects (from → to, 301/302, exact/prefix, reason, source node). | | `create_redirect` | `site:write` | write | always a person | Redirect a path the site no longer serves to another path of this site (agents cannot redirect to other hosts). | | `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). | | `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-tools.ts b/packages/mcp-server/src/collection-tools.ts new file mode 100644 index 00000000..438f605d --- /dev/null +++ b/packages/mcp-server/src/collection-tools.ts @@ -0,0 +1,287 @@ +/** + * RFC-0040 §5: the collection registry for agents. `list_collections` reads it; `create_collection` + * and `update_collection` declare and name collections, and wait for a person who may change site + * 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 CollectionInfo, + collectionSettings, + isRegistered, + MAX_COLLECTIONS, +} from "@cloudhal/core-ts/collections"; +import { collectionPermalink } from "@cloudhal/core-ts/permalinks"; +import { + autoSnapshot, + id, + type JsonSchema, + obj, + requireSite, + SLUG_PATTERN, + str, + type ToolContext, + type ToolDefinition, + ToolError, + toToolError, +} from "./common.ts"; +import { SITE, SNAPSHOT } from "./content-shared.ts"; +import { PERMALINK_PATTERN } from "./permalink-tools.ts"; + +const ICONS = [ + "article", + "calendar", + "briefcase", + "users", + "images", + "package", + "book-open", + "megaphone", + "quotes", + "question", + "map-pin", + "files", +]; + +/** Refuses a collection that is not in the site's registry (`collection_undeclared`). */ +export async function requireRegistered( + ctx: ToolContext, + siteId: string, + collection: string, +): Promise { + const registry = await ctx.store.listCollections(siteId); + if (isRegistered(registry, collection)) return; + throw new ToolError( + "collection_undeclared", + `collection "${collection}" does not exist on this site (it has: ${registry.map((c) => c.key).join(", ")}). Write into one of those, or call create_collection first; a person approves new collections`, + { collection, collections: registry.map((c) => c.key) }, + ); +} + +const LABEL: JsonSchema = obj( + { + singular: str("One entry, e.g. Event", { minLength: 1, maxLength: 100 }), + plural: str("The collection, e.g. Events", { minLength: 1, maxLength: 100 }), + description: str("Optional, shown under the list's title", { maxLength: 1000 }), + }, + ["singular", "plural"], +); + +const SETTINGS_INPUT = { + labels: { + type: "object", + description: + "Names per locale (BCP 47 → {singular, plural, description?}); create_collection needs the site's default locale", + additionalProperties: LABEL, + }, + icon: str("Icon name", { enum: ICONS }), + feed: { type: "boolean", description: "Entries appear in the site's RSS feeds (default true)" }, + sitemap: { + type: "boolean", + description: "Published entries appear in sitemap.xml (default true)", + }, + sort: str("Default order where none is given (default newest)", { + enum: ["newest", "oldest", "title"], + }), +} satisfies Record; + +/** The settings an agent's arguments describe (labels merge by locale onto `current`). */ +function settingsOf(a: Record, current: CollectionSettings): CollectionSettings { + const next: CollectionSettings = { ...current }; + if (a.labels) next.labels = { ...current.labels, ...(a.labels as CollectionSettings["labels"]) }; + 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; + return next; +} + +function withCollection(site: Site, key: string, settings: CollectionSettings): Site { + return { ...site, collections: { ...site.collections, [key]: settings } }; +} + +interface Plan { + site: Site; + next: Site; + key: string; + settings: CollectionSettings; + existing: CollectionInfo | undefined; +} + +async function planFor( + a: Record, + ctx: ToolContext, + mode: "create" | "update", +): Promise { + const site = await requireSite(ctx, a.siteId as string); + const key = a.collection as string; + const registry = await ctx.store.listCollections(site.id); + const existing = registry.find((c) => c.key === key); + if (mode === "create" && existing?.declared) + throw new ToolError( + "collection_exists", + `collection "${key}" is already declared; change it with update_collection`, + ); + if (mode === "update" && !existing) + throw new ToolError( + "collection_undeclared", + `collection "${key}" does not exist; create it with create_collection`, + ); + const settings = settingsOf(a, existing?.settings ?? {}); + if (mode === "create" && !settings.labels?.[site.defaultLocale]) + throw new ToolError( + "invalid", + `labels must name the collection in the site's default locale (${site.defaultLocale})`, + ); + return { site, next: withCollection(site, key, settings), key, settings, existing }; +} + +async function check(p: Plan, ctx: ToolContext): Promise { + if (!p.existing?.declared && Object.keys(p.site.collections ?? {}).length >= MAX_COLLECTIONS) + throw new ToolError("invalid", `a site has at most ${MAX_COLLECTIONS} collections`); + try { + await ctx.store.updateSite(p.next, { dryRun: true }); + } catch (e) { + const err = toToolError(e); + if (err instanceof ToolError && (err.code === "path_conflict" || err.code === "reserved_path")) + throw new ToolError( + err.code, + `${err.message}. Choose another key or a permalink: list_collections shows the collections and their addresses, list_taxonomies the archive base paths`, + { ...err.details }, + ); + throw err; + } +} + +/** What a person decides on. */ +function describe(p: Plan, mode: "create" | "update"): string { + 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); + const where = collectionPermalink({ collections: { [p.key]: p.settings } }, p.key); + const head = + mode === "create" + ? `Create the collection ${name} with key "${p.key}"? Its entries live at ${where}. Agents may write entries into it once it exists. The key cannot be renamed later.` + : `Change the collection "${p.key}" to ${name}?`; + const lines = [ + ` feed: ${s.feed ? "yes" : "no"} · sitemap: ${s.sitemap ? "yes" : "no"} · order: ${s.sort}${p.settings.icon ? ` · icon: ${p.settings.icon}` : ""}`, + ]; + const other = Object.keys(p.settings.labels ?? {}).filter((l) => l !== p.site.defaultLocale); + if (other.length) lines.push(` also named in: ${other.join(", ")}`); + return `${head}\n${lines.join("\n")}`; +} + +const COLLECTION_OUT: JsonSchema = obj( + { + key: { type: "string" }, + declared: { type: "boolean" }, + settings: { type: "object" }, + counts: { type: "object" }, + permalink: { type: "string" }, + taxonomies: { type: "array", items: { type: "string" } }, + }, + ["key", "declared", "settings", "counts", "permalink", "taxonomies"], +); + +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"]), + outputSchema: obj({ collections: { type: "array", items: COLLECTION_OUT } }, ["collections"]), + 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 [registry, taxonomies] = await Promise.all([ + ctx.store.listCollections(site.id), + ctx.store.listTaxonomies(site.id), + ]); + return { + collections: registry.map((c) => ({ + ...c, + settings: collectionSettings(site, c.key), + permalink: collectionPermalink(site, c.key), + taxonomies: taxonomies + .filter((t) => !t.collections || t.collections.includes(c.key)) + .map((t) => t.key), + })), + }; + }, +}; + +function writeTool(mode: "create" | "update"): ToolDefinition { + const name = `${mode}_collection`; + return { + name, + title: mode === "create" ? "Create collection" : "Update collection", + 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.", + inputSchema: obj( + { + siteId: id, + collection: str("Collection key: lower-case letters, digits, hyphens; never renamed", { + pattern: SLUG_PATTERN, + maxLength: 64, + }), + ...SETTINGS_INPUT, + ...(mode === "create" + ? { + permalink: str("Template, e.g. /etkinlik/{slug}; default //{slug}", { + pattern: PERMALINK_PATTERN, + }), + } + : {}), + dryRun: { + type: "boolean", + description: "Check without asking anyone or changing anything", + }, + }, + mode === "create" ? ["siteId", "collection", "labels"] : ["siteId", "collection"], + ), + outputSchema: obj( + { prompt: { type: "string" }, dryRun: { type: "boolean" }, site: SITE, snapshot: SNAPSHOT }, + ["prompt", "dryRun"], + ), + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: mode === "update", + openWorldHint: false, + }, + scopes: ["site:write"], + // Only someone who may change site settings in Studio decides it (as for permalinks). + approverScopes: ["site:settings"], + 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), + handler: async (a, ctx) => { + const p = await planFor(a, ctx, mode); + const prompt = describe(p, mode); + if (a.dryRun === true) { + await check(p, ctx); + return { prompt, dryRun: true }; + } + let site: Site; + try { + site = await ctx.store.updateSite(p.next); + } catch (e) { + throw toToolError(e); + } + const snapshot = await autoSnapshot(ctx, site.id, name); + return { prompt, dryRun: false, site, snapshot }; + }, + }; +} + +export function collectionTools(): ToolDefinition[] { + return [listCollectionsTool, writeTool("create"), writeTool("update")]; +} diff --git a/packages/mcp-server/src/entry-tools.ts b/packages/mcp-server/src/entry-tools.ts index 60d3af15..7c569a06 100644 --- a/packages/mcp-server/src/entry-tools.ts +++ b/packages/mcp-server/src/entry-tools.ts @@ -2,6 +2,7 @@ import type { ContentEntry, Site } from "@cloudhal/core-contracts"; import type { CorrectionKind, EntryQuery } from "@cloudhal/core-ts"; +import { requireRegistered } from "./collection-tools.ts"; import { autoSnapshot, entryLimitCheck, @@ -109,7 +110,7 @@ const createEntryTool: ToolDefinition = { name: "create_entry", title: "Create article", description: - "Create a draft content entry (markdown body), 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). Snapshots afterwards.", inputSchema: obj( { siteId: id, @@ -140,6 +141,9 @@ 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"), handler: async (a, ctx) => { const site = await requireSite(ctx, a.siteId as string); await entryLimitCheck(ctx, "create", undefined, { diff --git a/packages/mcp-server/src/server-instructions.ts b/packages/mcp-server/src/server-instructions.ts index 042f2922..e897321a 100644 --- a/packages/mcp-server/src/server-instructions.ts +++ b/packages/mcp-server/src/server-instructions.ts @@ -9,6 +9,8 @@ Building or redesigning pages: call get_design first and follow the site's desig Categories and tags: before you file an article, call list_taxonomies (which taxonomies are required, how many terms an article takes, who may add terms) and list_terms for each one (or resolve_terms for the names you have in mind). Reuse existing terms: set them on create_entry / update_entry with \`terms\`, e.g. {"category": {"primary": "News"}, "tag": {"add": ["AI"]}}; ids, slugs, names and synonyms all work, and nothing is ever created implicitly. Propose a new tag with propose_term only when no existing term fits; it stays private until a person approves it. Categories are added by people only: if none fits, say so and ask. A review or published article needs a primary term in every required taxonomy. Renaming, merging, retiring or moving terms, taxonomy settings and redirects (site paths only) always wait for a person; taxonomy_report suggests clean-ups to propose to them. +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. + 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 73afd4f1..e4908147 100644 --- a/packages/mcp-server/src/tools.ts +++ b/packages/mcp-server/src/tools.ts @@ -31,6 +31,7 @@ import { analyticsTools } from "./analytics-tools.ts"; import { assetTools } from "./asset-tools.ts"; import { bodyTools } from "./body-tools.ts"; import { chromeTools } from "./chrome-tools.ts"; +import { collectionTools } from "./collection-tools.ts"; import { commentTools } from "./comment-tools.ts"; import { type ToolContext, type ToolDefinition, ToolError, toToolError } from "./common.ts"; import { designTools } from "./design-tools.ts"; @@ -86,6 +87,8 @@ export function defineTools(): ToolDefinition[] { // Taxonomy, term and redirect tools (RFC-0002 §6.2), then menu tools (RFC-0003 §6.1) and // media tools (RFC-0004 §6.2), follow the article tools. ...taxonomyTools(), + // RFC-0040 §5: the collection registry; new collections are a person's decision. + ...collectionTools(), // RFC-0038 §5: where a collection's entries live (always a person's decision). ...permalinkTools(), ...menuTools(), diff --git a/packages/mcp-server/test/collections.test.ts b/packages/mcp-server/test/collections.test.ts new file mode 100644 index 00000000..8239b6d7 --- /dev/null +++ b/packages/mcp-server/test/collections.test.ts @@ -0,0 +1,169 @@ +/** + * RFC-0040 §5 over MCP: `list_collections` shows the registry; `create_collection` and + * `update_collection` wait for a person in every mode, refuse overlaps and duplicates before anyone + * is asked, and declare the collection; `create_entry` refuses a collection outside the registry. + */ +import { type AgentMode, AuditLog, PolicyEngine } from "@cloudhal/agent-runtime"; +import type { Site } from "@cloudhal/core-contracts"; +import { 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_collection", name: "Organiser" }; +const tools = defineTools(); +const byName = (n: string) => tools.find((t) => t.name === n) as (typeof tools)[number]; +const policy = new PolicyEngine(toolPolicies(tools)); +const EVENTS = { en: { singular: "Event", plural: "Events" } }; + +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 site = await seedStarterSite(store, { name: "Kinds", slug: "kinds" }); + 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 }> { + try { + await p; + } catch (e) { + return e as { code: string; message: string }; + } + throw new Error("expected a rejection"); +} + +describe("collections over MCP (RFC-0040 §5)", () => { + let w: World; + beforeEach(async () => { + w = await world(); + }); + + it("list_collections shows posts with counts, its permalink and taxonomies", async () => { + const r = await call(w, "list_collections")({}); + expect(r.decision.kind).toBe("allow"); + const [posts] = (r.result as { collections: Record[] }).collections; + expect(posts).toMatchObject({ + key: "posts", + declared: false, + permalink: "/posts/{slug}", + settings: { feed: true, sitemap: true, sort: "newest" }, + }); + expect(posts?.taxonomies).toEqual(expect.arrayContaining(["category", "tag"])); + }); + + it("create_collection always waits for a person who may change site settings", async () => { + const tool = byName("create_collection"); + expect(tool.scopes).toEqual(["site:write"]); + expect(tool.approverScopes).toEqual(["site:settings"]); + for (const mode of ["edits", "auto", "bypass"] as const) { + const r = await call(w, "create_collection", mode)({ collection: "events", labels: EVENTS }); + expect(r.decision.kind, mode).toBe("approval-required"); + expect(r.approvalPrompt).toContain( + 'Create the collection "Events" (one: "Event") with key "events"', + ); + expect(r.approvalPrompt).toContain("cannot be renamed"); + } + expect((await w.store.getSite(w.site.id))?.collections).toBeUndefined(); + const dry = await call( + w, + "create_collection", + )({ collection: "events", labels: EVENTS, dryRun: true }); + expect(dry.decision.kind).toBe("allow"); + expect((await w.store.getSite(w.site.id))?.collections).toBeUndefined(); + }); + + it("create_entry refuses an undeclared collection until create_collection is approved", async () => { + const entry = { collection: "events", slug: "launch", title: "Launch", body: "Soon." }; + const refused = await rejection(call(w, "create_entry")(entry)); + expect(refused.code).toBe("collection_undeclared"); + expect(refused.message).toContain("create_collection"); + // Existing collections (inferred: posts) keep working. + const post = await call(w, "create_entry")({ slug: "second", title: "Second", body: "Hi." }); + expect(post.decision.kind).toBe("allow"); + + const done = await call(w, "create_collection")( + { collection: "events", labels: EVENTS, icon: "calendar", sort: "oldest" }, + true, + ); + expect((done.result as { site: Site }).site.collections?.events).toEqual({ + labels: EVENTS, + icon: "calendar", + sort: "oldest", + }); + const created = await call(w, "create_entry")(entry); + expect(created.decision.kind).toBe("allow"); + }); + + it("refuses overlaps, duplicates and a missing default-locale name before asking", async () => { + await w.store.updateSite({ ...w.site, collections: { posts: { permalink: "/blog/{slug}" } } }); + const clash = await rejection( + call(w, "create_collection")({ collection: "blog", labels: EVENTS }), + ); + expect(clash.code).toBe("path_conflict"); + const archive = await rejection( + call(w, "create_collection")({ collection: "tag", labels: EVENTS }), + ); + expect(archive.code).toBe("path_conflict"); + const exists = await rejection( + call(w, "create_collection")({ collection: "posts", labels: EVENTS }), + ); + expect(exists.code).toBe("collection_exists"); + const unnamed = await rejection( + call( + w, + "create_collection", + )({ collection: "jobs", labels: { tr: { singular: "İş", plural: "İşler" } } }), + ); + expect(unnamed.code).toBe("invalid"); + }); + + it("update_collection names an inferred collection and merges labels by locale", async () => { + const first = await call(w, "update_collection")( + { collection: "posts", labels: { en: { singular: "Story", plural: "Stories" } } }, + true, + ); + expect((first.result as { site: Site }).site.collections?.posts?.labels?.en?.plural).toBe( + "Stories", + ); + const second = await call(w, "update_collection")( + { + collection: "posts", + labels: { tr: { singular: "Haber", plural: "Haberler" } }, + feed: false, + }, + true, + ); + expect((second.result as { site: Site }).site.collections?.posts).toEqual({ + labels: { + en: { singular: "Story", plural: "Stories" }, + tr: { singular: "Haber", plural: "Haberler" }, + }, + feed: false, + }); + const missing = await rejection( + call(w, "update_collection")({ collection: "jobs", feed: false }), + ); + expect(missing.code).toBe("collection_undeclared"); + }); +}); diff --git a/packages/mcp-server/test/e2e.test.ts b/packages/mcp-server/test/e2e.test.ts index cadfadd9..f713c333 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(102); + expect(tools).toHaveLength(105); 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/tool-order.test.ts b/packages/mcp-server/test/tool-order.test.ts index e6dc2e57..d4728208 100644 --- a/packages/mcp-server/test/tool-order.test.ts +++ b/packages/mcp-server/test/tool-order.test.ts @@ -55,6 +55,9 @@ const ORDER = [ "list_redirects", "create_redirect", "delete_redirect", + "list_collections", + "create_collection", + "update_collection", "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 d8327f8a..b4bbb0d7 100644 --- a/packages/mcp-server/test/tools.test.ts +++ b/packages/mcp-server/test/tools.test.ts @@ -92,6 +92,9 @@ describe("Cloudhal tools (SDK-independent)", () => { "list_redirects", "create_redirect", "delete_redirect", + "list_collections", + "create_collection", + "update_collection", "set_collection_permalink", "list_menus", "get_menu",