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
10 changes: 10 additions & 0 deletions .changeset/collections-mcp.md
Original file line number Diff line number Diff line change
@@ -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.
7 changes: 5 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) -->
**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 |
|---|---|---|---|---|
Expand All @@ -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. |
Expand Down Expand Up @@ -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 (/&lt;prefix>/{slug}, default /&lt;key>… |
| `update_collection` | `site:write` | write | always a person | Change a collection's names (merged per locale), icon, feed, sitemap or default sort (RFC-0040). |
| `set_collection_permalink` | `site:write` | write | always a person | Move where a collection's entries live (RFC-0038): permalink "/blog/{slug}" puts collection "posts" at /blog/&lt;slug>; null goes back to the default /&lt;collection>/{slug}. |
| `list_menus` | `site:read` | read | — | The site's navigation menus (primary = header, footer, sidebar, custom keys) with id, rev, maxDepth, item count and how many items are hidden on the site. |
| `get_menu` | `site:read` | read | — | One menu: the stored tree (send its rev back when writing) and, per item for a locale, whether the site shows it (`state`), why not (`reason`: missing, unpublished, other-locale,… |
Expand Down
287 changes: 287 additions & 0 deletions packages/mcp-server/src/collection-tools.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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<string, JsonSchema>;

/** The settings an agent's arguments describe (labels merge by locale onto `current`). */
function settingsOf(a: Record<string, unknown>, 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<string, unknown>)[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<string, unknown>,
ctx: ToolContext,
mode: "create" | "update",
): Promise<Plan> {
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<void> {
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 (/<prefix>/{slug}, default /<key>/{slug}), feed, sitemap and default sort. Its address may not overlap a page, archive, another collection, /search or a locale prefix. A person who may change site settings approves it in every mode; dryRun: true checks without asking. Then create_entry with this collection. Declaring a collection that already has entries names it."
: "Change a collection's names (merged per locale), icon, feed, sitemap or default sort (RFC-0040). The permalink changes only through set_collection_permalink (it moves URLs). A person who may change site settings approves it; dryRun: true checks without asking.",
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 /<key>/{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")];
}
6 changes: 5 additions & 1 deletion packages/mcp-server/src/entry-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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, {
Expand Down
2 changes: 2 additions & 0 deletions packages/mcp-server/src/server-instructions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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\`; /<collection>/{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.
Expand Down
3 changes: 3 additions & 0 deletions packages/mcp-server/src/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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(),
Expand Down
Loading
Loading