pnpm create emdash@latest potluck --pm pnpm --install --yes, per https://docs.emdashcms.com/ ("Create your first EmDash site"); the --yes defaults are cloudflare + blog. Replaces the old madmode/ starter (never committed). Generated-by: create-emdash 1.2.0, run by Claude Opus 5.5
12 KiB
API routes and MCP tools
Sandboxed plugin routes are mounted at /_emdash/api/plugins/<slug>/<route-name>. Define them in the typed default export from src/plugin.ts.
Define a route
import type { SandboxedPlugin } from "emdash/plugin";
import { z } from "zod";
const submissionInput = z.object({
formId: z.string().min(1),
limit: z.coerce.number().int().min(1).max(100).default(50),
});
const plugin: SandboxedPlugin = {
routes: {
submissions: {
permission: "content:read",
handler: async (routeCtx, ctx) => {
const parsed = submissionInput.safeParse(routeCtx.input);
if (!parsed.success) return { ok: false, error: "INVALID_INPUT" };
const result = await ctx.storage.submissions.query({
where: { formId: parsed.data.formId },
limit: parsed.data.limit,
});
return { ok: true, ...result };
},
},
},
};
export default plugin;
The handler receives two arguments:
interface SandboxedRouteContext {
input: unknown;
request: {
url: string;
method: string;
headers: Record<string, string>;
};
requestMeta?: {
ip: string | null;
userAgent: string | null;
referer: string | null;
geo: { country: string | null; region: string | null; city: string | null } | null;
};
user?: UserInfo;
}
routeCtx.request is a serialized record in both sandbox runners, not a WHATWG Request. Header keys are lowercased. Read parsed request data from routeCtx.input; the body has already been consumed by the host.
The published authoring type currently declares requestMeta as unknown, although both runners send the normalized shape shown above. Narrow it before reading fields when TypeScript cannot infer the shape.
Input sources and validation
Undeclared routes keep the original input contract: the host parses JSON for POST, PUT, and
PATCH, and parses the query string for GET, HEAD, and DELETE. Repeated query keys become
arrays. Validate the resulting unknown value inside the handler.
Declare request.body as none, json, text, bytes, or form-data to select a parser. Bodies
are buffered with a 1 MiB default maximum; maxBytes can raise the limit to 8 MiB. Use
pluginRoute() from emdash/plugin to infer PluginRouteQuery, string, Uint8Array, or
PluginFormData from the mode. JSON remains unknown for validation.
Form data accepts multipart and URL-encoded requests. The multipart limits are 100 parts, 1 MiB per part, and 255 UTF-8 bytes for a safe filename. The total request must also fit the route body limit.
Declare request header names explicitly in request.headers. Only those safe headers reach the
handler. Credentials, cookies, Cloudflare Access headers, proxy authorization, Set-Cookie, and
the EmDash CSRF header cannot be declared and never cross the sandbox boundary.
The plugin CLI probe currently does not retain a route entry's input schema, so do not rely on route-level Zod validation for a built sandboxed plugin. An MCP tool still requires its own Zod input schema.
Authentication, permission, and CSRF
Routes are private unless they set public: true.
A private route requires:
- an authenticated session, or a token with the
adminscope; - the route's declared EmDash RBAC
permission, defaulting toplugins:manage; X-EmDash-Request: 1for cookie-authenticated calls, for every HTTP method.
The host resolves these checks before invoking the plugin. routeCtx.user then contains the authenticated caller for a user-bound request:
interface UserInfo {
id: string;
email: string;
name: string | null;
role: number;
createdAt: string;
}
Caller identity is not gated by users:read; it identifies the current authorized caller. ctx.users is a directory lookup and does require users:read. routeCtx.user is absent on public routes and on machine-token calls without a bound user.
A public route skips authentication, permission, and token-scope checks. It is internet-facing, so validate input, check the intended HTTP method, and verify webhook signatures or shared tokens where applicable.
Public exposure is reviewed during installation. Adding a public route or changing a private route to public requires approval again on update.
HTTP methods
The route name selects one handler. Declare methods to have the host reject other methods with
405 Method Not Allowed and an Allow header before the handler runs:
methods: ["POST"],
handler: async (routeCtx, ctx) => {
// Validate input, then mutate.
},
Routes without methods remain method-agnostic. Check the method inside legacy handlers that mutate
state, or add a declaration.
Results and errors
Return a JSON-serializable value. The HTTP endpoint wraps it in EmDash's { success: true, data } envelope.
Return a stable application-level error object for expected validation and domain failures. Throw only for unexpected failures, and keep exception messages free of credentials, personal data, paths, and stack traces.
Declare response: "raw" for unwrapped responses. The handler must return pluginResponse() from
emdash/plugin with a text or Uint8Array body. Raw response bodies are buffered up to 8 MiB.
The response header allowlist is Accept-Ranges, Content-Disposition, Content-Encoding,
Content-Language, Content-Range, Content-Type, ETag, Last-Modified, Location, and
Retry-After. The host removes other plugin-supplied headers, adds security headers, and applies the
route-owned cache policy.
Raw routes cannot return active same-origin types, including HTML, JavaScript or ECMAScript, XHTML,
SVG, XML, CSS, WebAssembly, multipart/related, and multipart/x-mixed-replace. A raw route also
cannot back an MCP tool. Do not return or throw a WHATWG Response; it is not the portable response
wire.
Public caching
Core accepts cacheControl on a public route:
routes: {
catalog: {
public: true,
cacheControl: "public, max-age=60, stale-while-revalidate=300",
handler: async () => ({ items: [] }),
},
},
The value is applied only to successful public GET and HEAD responses. Private responses, errors, and other methods remain private, no-store. The plugin CLI carries cacheControl through the probe, bundle manifest, registry artifact, and generated descriptor.
Request metadata
Both runners send the same normalized metadata:
ip: a trusted client address when the platform or operator configured a trusted proxy header, otherwisenull;userAgent: theUser-Agentvalue, otherwisenull;referer: theReferervalue, otherwisenull;geo: Cloudflare country, region, and city when available, otherwisenull.
Do not treat userAgent, referer, or geographic values as authenticated identity. Use routeCtx.user for the caller.
Content reads
With content:read, both sandbox runners match the trusted read contract. ctx.content.get() and ctx.content.list() return content identity, slug, status, locale, data, created/updated/published/scheduled timestamps, and SEO metadata when enabled.
list() accepts limit, cursor, where, and orderBy. Field filters, status filters, ordering, and cursor pagination reach the host repository on both runners; they are not evaluated inside the plugin isolate. getTranslations() and getPublicUrl() use the same content:read authority. Collection discovery uses ctx.schema with schema:read. Read Content, schema, translations, and publication for the complete contract.
Publication actions
With content:publish, call getVersioned() before publish(), unpublish(), schedule(), or unschedule(), then pass the returned _rev. Each successful mutation returns the item and its next _rev. content:restore separately provides getTrashedVersioned() and restore() without granting ordinary content reads. These actions execute through the host runtime, including policy and after-hooks; a stale revision or recursive action rejects without changing the entry.
Redirects
Declare redirects:read to list redirect rules with cursor pagination and read one rule with its opaque _rev. Declare redirects:write to create, update, or delete rules; it implies read access and authorizes the plugin to change where visitors are sent.
Pass _rev back unchanged for update() and delete(). A stale value fails with CONFLICT; re-read the current rule before retrying. The revision tracks redirect configuration, not visitor hit counting. Redirect writes use the host redirect handlers, including path-pattern, destination-parameter, duplicate-source, and terminal-status validation. Self-loop and multi-hop-loop validation runs when a rule is created or its source or destination changes. An enabled-only update can reactivate a pre-existing loop, which the Redirects page reports. Plugin input cannot set the host-owned auto marker.
External HTTP responses
ctx.http.fetch() returns a buffered WHATWG Response in both sandbox runners. ok, status, statusText, headers, url, redirected, text(), json(), arrayBuffer(), blob(), and clone() use the standard Web API and preserve the same values across runners.
Request and response bodies are each limited to 8 MiB of decoded bytes. The bridge enforces the limit while reading the body rather than trusting Content-Length. Responses are buffered rather than streamed to plugin code.
Expose a route as an MCP tool
MCP exposure is explicit. The following tool calls the private route as <pluginId>__createEvent:
import type { SandboxedPlugin } from "emdash/plugin";
import { z } from "zod";
const createEventInput = z.object({
title: z.string().min(1),
startsAt: z.string().datetime(),
});
const plugin: SandboxedPlugin = {
routes: {
"events/create": {
permission: "content:create",
handler: async (routeCtx) => {
const parsed = createEventInput.safeParse(routeCtx.input);
if (!parsed.success) return { ok: false, error: "INVALID_EVENT" };
return { ok: true, id: crypto.randomUUID() };
},
},
},
mcp: {
tools: {
createEvent: {
description: "Create a calendar event requested by the user.",
route: "events/create",
input: createEventInput,
output: z.object({ ok: z.boolean(), id: z.string().optional() }),
destructive: false,
},
},
},
};
export default plugin;
An MCP tool must:
- use a tool name containing only letters, digits,
_, or-; - reference an existing private route;
- reference a route with an explicit valid
permission; - reference a JSON route, not a route with
response: "raw"; - declare an input Zod schema;
- set
destructive: truefor deletion, overwrite, publishing, charging, or another difficult-to-reverse action.
The optional output schema becomes structured MCP output. The bundle converts both schemas to JSON Schema.
Installation and updates show the exact MCP tools for consent. Adding a tool or changing a route from private to public requires fresh approval. After installation, an administrator separately enables plugin MCP tools. A caller then needs the route permission and either the mcp:tools scope or mcp:tools:<pluginId>.
The production core parser, shared plugin-types parser, plugin CLI artifact, and generated descriptor preserve MCP declarations and route permission/cache metadata. @emdash-cms/plugin-test exposes the parsed manifest so tests can assert that transport. Its invokeRoute() still bypasses the HTTP catch-all, so use the host for plugin execution and bridge behavior, not route authorization, response caching, MCP registration, or consent behavior.