Five tools cover forms and five cover assets. Between them they can design a form from scratch, read everything people have submitted to it, and find, inspect, rename, relabel and delete the images, videos, audio files and documents in your library. What they cannot do is put a file into the library — uploading is not on the MCP surface — and none of them puts a form live by itself.
Reading forms
list_forms
GET /api/forms · Returns { forms } · Also available in the Leed Assistant
Takes no parameters. Returns form metadata — formId, formName, formPrototype, description, isDirty and modification stamps — not the field definitions. Deleted forms are excluded. This is the call that resolves a human name like “Contact us” into the formId every other tool here wants.
get_form
GET /api/forms/:formId · Returns { form } · Also available in the Leed Assistant
| Parameter | Type | Required | Default | Notes |
|---|---|---|---|---|
formId | string | Yes | — | From list_forms |
The complete form, including its fields array. Read this before editing: update_form_draft merges what you send, but the fields array is replaced wholesale when you send it, so a model that wants to add one field must send all of them.
Creating and editing a form
create_form and update_form_draft share one field surface. The difference is which parts of it are mandatory.
| Field | Type | Required on create | Meaning |
|---|---|---|---|
formPrototype | string | Yes | The prototype the form is built from; slugified on save |
formName | string | Yes | The form’s name in the CMS |
button | string | Yes | Submit button label |
successCallback | string | Yes | Where the visitor goes after a successful submission |
failureCallback | string | Yes | Where the visitor goes when submission fails |
fields | array of field objects | Yes | The form’s inputs, in order |
heading | string | No | Heading rendered above the form |
description | string (≤ 400 chars) | No | Short description; also shown in form lists |
details | string | No | Longer supporting copy |
welcomeBackMessage | string | No | Shown instead of the form to a visitor already identified |
leedAutofill | boolean | No | Pre-fill from what Leed already knows about the visitor |
autofill | "linkedin" or null | No | Third-party autofill provider; send null to clear it |
assetId | string or null | No | Asset associated with the form |
templateId | string or null | No | Template the form renders with |
emailTemplateId | integer or null | No | The email template a submission is answered with; takes precedence over templateId |
emailInputs | object or null | No | Values for that email template’s blocks that are set on the form, keyed by the template’s input keys |
The system columns — protected, isDirty, disabledAt and disabledBy — are not part of either tool’s input. The route marks a written form as needing publication itself, and a form cannot be made protected through the API.
The field object, in full
Every entry in fields is an object with this shape. formFieldId, name, required and type are mandatory on each one.
| Key | Type | Required | Meaning |
|---|---|---|---|
formFieldId | string | Yes | Stable id for the field |
name | string | Yes | The submitted key — this is what maps onto a contact record |
type | text | textarea | tel | email | checkbox | radio | select | countries | Yes | Input type |
required | boolean | Yes | Whether the visitor must fill it in |
labelBefore | string | No | Label rendered before the input |
labelAfter | string | No | Label rendered after the input |
placeHolder | string | No | Placeholder text |
value | string | No | Default value |
pattern | string | No | Validation pattern |
rows | number | No | Rows for a textarea |
multiple | boolean | No | Allow multiple selections |
selected | boolean | No | Pre-selected state |
options | array of { label, value } | No | Choices for select, radio and checkbox |
oninput / onchange | string | No | Client-side handlers |
Which name values map onto which contact fields — and which are simply captured as extra data — is enumerated in the Form Field Reference.
create_form
POST /api/forms · Returns { form } · Also available in the Leed Assistant
Takes the whole surface above; the six fields marked required must be present. Creating a form also creates its default form action, so the form is immediately wired up to record submissions once it is published.
{
"formPrototype": "contact",
"formName": "Contact us",
"heading": "Talk to us",
"button": "Send",
"successCallback": "/thanks/",
"failureCallback": "/contact/?error=1",
"fields": [
{
"formFieldId": "email",
"name": "email",
"type": "email",
"required": true,
"labelBefore": "Work email"
},
{
"formFieldId": "message",
"name": "message",
"type": "textarea",
"required": false,
"labelBefore": "How can we help?",
"rows": 4
}
]
}update_form_draft
PUT /api/forms/:formId · Returns { form } · Also available in the Leed Assistant
| Parameter | Type | Required | Default | Notes |
|---|---|---|---|---|
formId | string | Yes | — | Which form to edit |
| any field from the table above | — | No | unchanged | Partial — send only what changes |
Two forms of refusal are worth knowing. A form marked protected — Leed creates one of these automatically for the Docs MCP reader sign-in — returns 403 form_protected and cannot be edited through this tool at all. And sending a fields array replaces the existing one, so a partial fields array truncates the form.
What you cannot do
Deleting a form is absent from the MCP surface, not merely restricted: delete_form is a registered tool that a client is never shown, so a model cannot invoke it even by name. It is available in the Leed Assistant, where it stops and waits for your approval — see Leed Assistant. There is no per-form publish tool either; a form goes live with the rest of your pending changes.
Reading submissions
list_form_fills
GET /api/forms/:formId/fills · Returns { fills } · MCP only
| Parameter | Type | Required | Default | Notes |
|---|---|---|---|---|
formId | string | Yes | — | Resolve it with list_forms first |
limit | integer, 1–100 | No | no limit | Omitting it returns every submission |
offset | integer, ≥ 0 | No | 0 | Rows to skip |
What a submission records, why a preview site never captures one, and how a fill updates the matching contact are all covered in Form Submissions.
Assets
list_assets
GET /api/assets/:type? · Returns { assets, count } · MCP only
| Parameter | Type | Required | Default | Notes |
|---|---|---|---|---|
type | image, video, audio or document | No | all types | Narrows to one media family |
labels | string | No | — | Comma-separated label ids; an asset carrying any of them matches |
q | string | No | — | Case-insensitive substring match on name and filename |
includeDeleted | boolean | No | false | Include soft-deleted assets |
limit | integer, 1–200 | No | no limit | Omitting it returns every match |
offset | integer, ≥ 0 | No | 0 | Rows to skip |
The filters combine with AND, newest first. count is the total matching the filters, which is how you learn there is more behind a page. Each row carries trackingCounts — how many pages, forms, email templates and profiles use the asset — and never carries extracted document text or transcriptions; read those with get_asset. Requires asset:read.
get_asset
GET /api/assets/id/:assetId · Returns { asset } · MCP only
One asset by id; no type is needed, because an asset id is unique across all four types. The record adds tracking, the ids of everything using the asset, and — on a plan that includes content extraction — a document’s text or an audio or video file’s transcription and transcriptionSummary. Below that plan those are withheld and the response carries contentExtractionGated: true. A soft-deleted asset is not found here.
update_asset
PATCH /api/assets/:assetId · Returns { asset } · MCP only
| Parameter | Type | Required | Default | Notes |
|---|---|---|---|---|
assetId | string | Yes | — | — |
name | string | No | unchanged | Display name |
altText | string | No | unchanged | Images only — a 400 on any other type |
labels | string[] | No | unchanged | Replaces the whole label set; [] clears it |
These three are the only asset fields any MCP tool can write. A locked label the asset already carries survives a labels replace. Requires asset:update, Collaborator or above.
change_asset_labels
POST /api/assets/labels · MCP only · Confirm-gated, route mode
Adds addLabels and removes removeLabels on 1–200 assetIds at once, never replacing a whole set. Assets are processed one by one, so a bad id fails only its own line; an unknown label id refuses the whole call before anything is written. Unlike update_asset, naming a locked label in removeLabels does take it off. The first call is a dry run; repeat it with confirm: true to apply.
delete_asset
DELETE /api/assets/:assetId · Returns { asset } · MCP only · Confirm-gated, local mode
A soft delete: the record is kept with a deletedAt stamp, and the asset drops out of list_assets unless includeDeleted is set. It is refused with a 409 while any page, form, email template or profile still references the asset, and it needs asset:delete — Coordinator or above, a higher bar than updating.
Uploading is not an MCP action — here is what it is instead
An AI that has just generated an image, or that you have handed a file to, still needs a real answer. The simplest is the Leed CLI: leed asset upload <file> uploads a local file, deduplicates it against the library and returns ready-to-paste Leed Markdown. Underneath, uploading is a three-step authenticated HTTP flow against the same API the CMS itself uses. It runs under the same session or token as everything else and carries the same asset:create permission check, so it is not a way around any control — it is simply a surface MCP does not cover.
# 1. Ask for a one-shot direct-upload URL from Cloudflare Images.
curl -s -H "Authorization: Bearer $LEED_TOKEN" \
https://app.leed.ai/api/assets/create/image
# → { "id": "<imageId>", "uploadURL": "https://upload.imagedelivery.net/...", ... }
# 2. PUT the bytes to that URL. No Leed auth header here — the URL is the credential.
curl -s -X PUT --upload-file ./diagram.png "$UPLOAD_URL"
# 3. Register the asset with Leed so it appears in the library.
curl -s -X POST -H "Authorization: Bearer $LEED_TOKEN" \
-H "content-type: application/json" \
https://app.leed.ai/api/assets \
-d '{
"type": "image",
"imageId": "<imageId from step 1>",
"name": "Architecture diagram",
"filename": "diagram.png",
"altText": "Three services connected to one database",
"originalWidth": 1600,
"originalHeight": 900,
"svg": false,
"labels": []
}'
# → the created asset, including its assetIdaltText is required on an image and must be non-empty — Leed generates one automatically for uploads that come through the CMS, but the direct API call will not do it for you. Only once step 3 returns an assetId can a page body reference the image, as {data-assetid="…"}.
Documents, audio and video each have their own GET /api/assets/create/<type> step with different query parameters, and video finalizes through a separate call rather than POST /api/assets. If you are doing this by hand rather than by script, the CMS path is faster and is described in Uploading Images; Asset Library covers what happens to a file after it lands.
If the tool you came here for is not on this page, the Operator MCP Tool Index lists every tool alphabetically with the page that documents its parameters.