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
246 lines
12 KiB
Markdown
246 lines
12 KiB
Markdown
# 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
|
|
|
|
```typescript title="src/plugin.ts"
|
|
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:
|
|
|
|
```typescript
|
|
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 `admin` scope;
|
|
- the route's declared EmDash RBAC `permission`, defaulting to `plugins:manage`;
|
|
- `X-EmDash-Request: 1` for 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:
|
|
|
|
```typescript
|
|
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:
|
|
|
|
```typescript
|
|
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:
|
|
|
|
```typescript
|
|
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, otherwise `null`;
|
|
- `userAgent`: the `User-Agent` value, otherwise `null`;
|
|
- `referer`: the `Referer` value, otherwise `null`;
|
|
- `geo`: Cloudflare country, region, and city when available, otherwise `null`.
|
|
|
|
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](./content.md) 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`:
|
|
|
|
```typescript title="src/plugin.ts"
|
|
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: true` for 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.
|