Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/add-mcp-schema-updates.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"emdash": minor
---

Adds MCP tools for safely updating collections and fields without deleting and recreating their schemas.
45 changes: 44 additions & 1 deletion docs/src/content/docs/reference/mcp-server.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ Tokens are scoped to limit what operations a client can perform. Scopes are requ
| `media:read` | List and get media items. |
| `media:write` | Register (create), update, and delete media metadata. |
| `schema:read` | List collections and get collection schemas. |
| `schema:write` | Create and delete collections and fields. |
| `schema:write` | Create, update, and delete collections and fields. |
| `taxonomies:manage` | Create, update, and delete taxonomy terms. |
| `menus:manage` | Create, update, and delete navigation menus and their items. |
| `settings:read` | Read site-wide settings. |
Expand Down Expand Up @@ -324,6 +324,27 @@ Create a new content collection. This creates a database table and schema defini

**Scope:** `schema:write` | **Minimum role:** Admin

#### `schema_update_collection`

Update an existing collection without deleting its table, fields, or content. Only provided settings change; omitted settings keep their current values. The collection slug cannot be changed.

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `slug` | `string` | Yes | Collection slug to update |
| `label` | `string` | No | New plural display name |
| `labelSingular` | `string` | No | New singular display name |
| `description` | `string` | No | New collection description |
| `icon` | `string` | No | New admin UI icon name |
| `supports` | `string[]` | No | Complete feature list to enable; omitted preserves the current list |
| `urlPattern` | `string \| null` | No | New public URL pattern; `null` clears it |
| `hasSeo` | `boolean` | No | Whether the collection supports SEO metadata |
| `commentsEnabled` | `boolean` | No | Whether comments are enabled |
| `commentsModeration` | `string` | No | Moderation policy: `all`, `first_time`, or `none` |
| `commentsClosedAfterDays` | `integer` | No | Close comments after this many days; `0` keeps them open |
| `commentsAutoApproveUsers` | `boolean` | No | Automatically approve comments from authenticated users |

**Scope:** `schema:write` | **Minimum role:** Admin

#### `schema_delete_collection`

Delete a collection and its database table. This is irreversible and deletes all content in the collection.
Expand Down Expand Up @@ -359,6 +380,28 @@ For `select` and `multiSelect` types, provide allowed values in `validation.opti

**Scope:** `schema:write` | **Minimum role:** Admin

#### `schema_update_field`

Update an existing field without deleting its column or stored values. Only provided settings change; omitted settings keep their current values. `string`, `text`, and `slug` may be changed between one another. Other type changes and settings that require content or column migration are rejected with migration guidance. Invalid regular expressions and contradictory validation ranges are rejected before any schema metadata changes.

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `collection` | `string` | Yes | Collection containing the field |
| `fieldSlug` | `string` | Yes | Field slug to update |
| `label` | `string` | No | New display name |
| `type` | `string` | No | New field type; only `string`, `text`, and `slug` aliases can be changed in place |
| `required` | `boolean` | No | Whether the field is required; changing it requires a manual content migration |
| `unique` | `boolean` | No | Whether field values must be unique; changing it requires a manual content migration |
| `defaultValue` | `any` | No | Default value for new content |
| `validation` | `object \| null` | No | Validation constraints; `null` clears them |
| `widget` | `string` | No | Admin editor widget name |
| `options` | `object` | No | Widget configuration |
| `sortOrder` | `integer` | No | Field order in the content editor |
| `searchable` | `boolean` | No | Whether full-text search indexes the field |
| `translatable` | `boolean` | No | Whether values vary by locale; changing to `false` requires a manual content migration |

**Scope:** `schema:write` | **Minimum role:** Admin

#### `schema_delete_field`

Remove a field from a collection. This drops the column and deletes all data in that field. Irreversible.
Expand Down
12 changes: 9 additions & 3 deletions packages/core/src/api/handlers/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { invalidateCollectionCache } from "../../object-cache/index.js";
import {
SchemaRegistry,
SchemaError,
invalidateSchemaCache,
type Collection,
type Field,
type CreateCollectionInput,
Expand All @@ -19,6 +20,11 @@ import {
} from "../../schema/index.js";
import type { ApiResult } from "../types.js";

function invalidateFieldCaches(collectionSlug: string): void {
invalidateCollectionCache(collectionSlug);
invalidateSchemaCache(collectionSlug);
}

export interface CollectionListResponse {
items: Collection[];
}
Expand Down Expand Up @@ -317,7 +323,7 @@ export async function handleSchemaFieldCreate(
const item = await registry.createField(collectionSlug, input);

// Content snapshots embed field values; a column change invalidates them.
invalidateCollectionCache(collectionSlug);
invalidateFieldCaches(collectionSlug);

return {
success: true,
Expand Down Expand Up @@ -357,7 +363,7 @@ export async function handleSchemaFieldUpdate(
const registry = new SchemaRegistry(db);
const item = await registry.updateField(collectionSlug, fieldSlug, input);

invalidateCollectionCache(collectionSlug);
invalidateFieldCaches(collectionSlug);

return {
success: true,
Expand Down Expand Up @@ -396,7 +402,7 @@ export async function handleSchemaFieldDelete(
const registry = new SchemaRegistry(db);
await registry.deleteField(collectionSlug, fieldSlug);

invalidateCollectionCache(collectionSlug);
invalidateFieldCaches(collectionSlug);

return {
success: true,
Expand Down
45 changes: 43 additions & 2 deletions packages/core/src/api/schemas/schema.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { z } from "zod";

import { compileUrlPattern } from "../../schema/url-pattern.js";
import { slugPattern } from "./common.js";

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -49,6 +50,17 @@ const repeaterSubFieldSchema = z.object({
options: z.array(z.string()).optional(),
});

const urlPatternValue = z.string().superRefine((pattern, ctx) => {
try {
compileUrlPattern(pattern);
} catch {
ctx.addIssue({
code: "custom",
message: "Invalid URL pattern",
});
}
});

const fieldValidation = z
.object({
required: z.boolean().optional(),
Expand All @@ -71,6 +83,35 @@ const fieldValidation = z
.max(64, "allowedMimeTypes may contain at most 64 entries")
.optional(),
})
.superRefine((validation, ctx) => {
for (const [minimum, maximum] of [
["min", "max"],
["minLength", "maxLength"],
["minItems", "maxItems"],
] as const) {
const minimumValue = validation[minimum];
const maximumValue = validation[maximum];
if (minimumValue !== undefined && maximumValue !== undefined && minimumValue > maximumValue) {
ctx.addIssue({
code: "custom",
path: [maximum],
message: `${maximum} must be greater than or equal to ${minimum}`,
});
}
}

if (validation.pattern !== undefined) {
try {
RegExp(validation.pattern);
} catch {
ctx.addIssue({
code: "custom",
path: ["pattern"],
message: "Invalid validation pattern",
});
}
}
})
.optional();

const fieldWidgetOptions = z.record(z.string(), z.unknown()).optional();
Expand All @@ -84,7 +125,7 @@ export const createCollectionBody = z
icon: z.string().optional(),
supports: z.array(collectionSupportValues).optional(),
source: z.string().regex(collectionSourcePattern).optional(),
urlPattern: z.string().optional(),
urlPattern: urlPatternValue.optional(),
hasSeo: z.boolean().optional(),
})
.meta({ id: "CreateCollectionBody" });
Expand All @@ -96,7 +137,7 @@ export const updateCollectionBody = z
description: z.string().optional(),
icon: z.string().optional(),
supports: z.array(collectionSupportValues).optional(),
urlPattern: z.string().nullish(),
urlPattern: urlPatternValue.nullish(),
hasSeo: z.boolean().optional(),
commentsEnabled: z.boolean().optional(),
commentsModeration: z.enum(["all", "first_time", "none"]).optional(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ import {
} from "#api/index.js";
import { parseBody, parseQuery, isParseError } from "#api/parse.js";
import { collectionGetQuery, updateCollectionBody } from "#api/schemas.js";
import type { UpdateCollectionInput } from "#schema/types.js";

export const prerender = false;

Expand Down Expand Up @@ -53,12 +52,7 @@ export const PUT: APIRoute = async ({ params, request, locals }) => {
const body = await parseBody(request, updateCollectionBody);
if (isParseError(body)) return body;

const result = await handleSchemaCollectionUpdate(
emdash.db,
slug,
// eslint-disable-next-line typescript/no-unsafe-type-assertion -- parseBody validates via Zod
body as UpdateCollectionInput,
);
const result = await handleSchemaCollectionUpdate(emdash.db, slug, body);
emdash.invalidateUrlPatternCache();
return unwrapResult(result);
};
Expand Down
111 changes: 111 additions & 0 deletions packages/core/src/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ import {
CONTENT_TYPE_RE,
contentBylineInputSchema,
contentSeoInput,
updateCollectionBody,
updateFieldBody,
} from "#api/schemas.js";

import type { MediaUsageRepairRequest } from "../api/schemas/media-usage.js";
Expand Down Expand Up @@ -107,6 +109,63 @@ const mediaUsageRepairToolSchema = z
}
});

const schemaUpdateCollectionToolSchema = z.object({
slug: z.string().describe("Collection slug to update; the slug itself cannot be changed"),
label: updateCollectionBody.shape.label.describe("New plural display name"),
labelSingular: updateCollectionBody.shape.labelSingular.describe("New singular display name"),
description: updateCollectionBody.shape.description.describe("New collection description"),
icon: updateCollectionBody.shape.icon.describe("New admin UI icon name"),
supports: updateCollectionBody.shape.supports.describe(
"Complete feature list to enable; omit to preserve the current list",
),
urlPattern: updateCollectionBody.shape.urlPattern.describe(
"New public URL pattern; pass null to clear it",
),
hasSeo: updateCollectionBody.shape.hasSeo.describe(
"Whether the collection supports SEO metadata",
),
commentsEnabled: updateCollectionBody.shape.commentsEnabled.describe(
"Whether comments are enabled for this collection",
),
commentsModeration: updateCollectionBody.shape.commentsModeration.describe(
"Comment moderation policy",
),
commentsClosedAfterDays: updateCollectionBody.shape.commentsClosedAfterDays.describe(
"Close comments after this many days; 0 keeps them open",
),
commentsAutoApproveUsers: updateCollectionBody.shape.commentsAutoApproveUsers.describe(
"Whether comments from authenticated users are automatically approved",
),
});

const schemaUpdateFieldToolSchema = z.object({
collection: z.string().describe("Collection slug containing the field"),
fieldSlug: z.string().describe("Field slug to update; the slug itself cannot be changed"),
label: updateFieldBody.shape.label.describe("New display name"),
type: updateFieldBody.shape.type.describe(
"New field type; only string, text, and slug aliases can be changed in place",
),
required: updateFieldBody.shape.required.describe(
"Whether the field is required; changing this requires a manual content migration",
),
unique: updateFieldBody.shape.unique.describe(
"Whether field values must be unique; changing this requires a manual content migration",
),
defaultValue: updateFieldBody.shape.defaultValue.describe("Default value for new content"),
validation: updateFieldBody.shape.validation.describe(
"Validation constraints; pass null to clear existing constraints",
),
widget: updateFieldBody.shape.widget.describe("Admin editor widget name"),
options: updateFieldBody.shape.options.describe("Widget configuration"),
sortOrder: updateFieldBody.shape.sortOrder.describe("Field order in the content editor"),
searchable: updateFieldBody.shape.searchable.describe(
"Whether full-text search indexes the field",
),
translatable: updateFieldBody.shape.translatable.describe(
"Whether values vary by locale; changing to false requires a manual content migration",
),
});

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -1737,6 +1796,33 @@ export function createMcpServer(
},
);

server.registerTool(
"schema_update_collection",
{
title: "Update Collection",
description:
"Update an existing collection without deleting its table, fields, or content. " +
"Only provided settings change; omitted settings keep their current values. " +
"The collection slug cannot be changed.",
inputSchema: schemaUpdateCollectionToolSchema,
annotations: { destructiveHint: false },
},
async (args, extra) => {
requireScope(extra, "schema:write");
requireRole(extra, Role.ADMIN);
const ec = getEmDash(extra);
const { slug, ...input } = args;
try {
const { handleSchemaCollectionUpdate } = await import("../api/handlers/schema.js");
const result = await handleSchemaCollectionUpdate(ec.db, slug, input);
if (result.success) ec.invalidateUrlPatternCache();
return unwrap(result);
} catch (error) {
return respondHandlerError(error, "SCHEMA_UPDATE_ERROR");
}
},
);

server.registerTool(
"schema_create_field",
{
Expand Down Expand Up @@ -1868,6 +1954,31 @@ export function createMcpServer(
},
);

server.registerTool(
"schema_update_field",
{
title: "Update Field",
description:
"Update an existing field without deleting its column or stored values. Only " +
"provided settings change; omitted settings keep their current values. Changes " +
"that require a content or column migration are rejected with migration guidance.",
inputSchema: schemaUpdateFieldToolSchema,
annotations: { destructiveHint: false },
},
async (args, extra) => {
requireScope(extra, "schema:write");
requireRole(extra, Role.ADMIN);
const ec = getEmDash(extra);
const { collection, fieldSlug, ...input } = args;
try {
const { handleSchemaFieldUpdate } = await import("../api/handlers/schema.js");
return unwrap(await handleSchemaFieldUpdate(ec.db, collection, fieldSlug, input));
} catch (error) {
return respondHandlerError(error, "SCHEMA_FIELD_UPDATE_ERROR");
}
},
);

// =====================================================================
// Media tools
// =====================================================================
Expand Down
16 changes: 2 additions & 14 deletions packages/core/src/query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import {
} from "./object-cache/index.js";
import { requestCached } from "./request-cache.js";
import { getRequestContext } from "./request-context.js";
import { compileUrlPattern } from "./schema/url-pattern.js";
import type { TaxonomyTerm } from "./taxonomies/types.js";
import { isMissingTableError } from "./utils/db-errors.js";
import {
Expand Down Expand Up @@ -1298,19 +1299,6 @@ export interface ResolvePathResult<T = Record<string, unknown>> {
params: Record<string, string>;
}

/** Matches `{paramName}` placeholders in URL patterns */
const URL_PARAM_PATTERN = /\{(\w+)\}/g;

/** Convert a URL pattern like "/blog/{slug}" to a regex and param name list */
function patternToRegex(pattern: string): { regex: RegExp; paramNames: string[] } {
const paramNames: string[] = [];
const regexStr = pattern.replace(URL_PARAM_PATTERN, (_match, name: string) => {
paramNames.push(name);
return "([^/]+)";
});
return { regex: new RegExp(`^${regexStr}$`), paramNames };
}

/** Cached compiled URL patterns for resolveEmDashPath */
interface CachedPattern {
slug: string;
Expand Down Expand Up @@ -1365,7 +1353,7 @@ export async function resolveEmDashPath<T = Record<string, unknown>>(
cachedUrlPatterns = [];
for (const collection of collections) {
if (!collection.urlPattern) continue;
const { regex, paramNames } = patternToRegex(collection.urlPattern);
const { regex, paramNames } = compileUrlPattern(collection.urlPattern);
cachedUrlPatterns.push({ slug: collection.slug, regex, paramNames });
}
}
Expand Down
Loading
Loading