@leafslip/mcp
v0.4.0
Published
MCP server for .form - let an agent create and read forms.
Readme
@leafslip/mcp
A Model Context Protocol server. It gives an agent these tools:
| Tool | What it does |
| --- | --- |
| ask_human | Start here. You are blocked; ask a person and carry on with their answer. Writes the form, emails a named person, chases the next if they stay quiet, and hands back what they said — one call, no slug. Needs LEAFSLIP_TOKEN |
| ask_status | Check a question you asked with wait: false, or one that had not been answered yet |
| create_form | Publish a form, get a URL to send to a person |
| read_responses | Read the answers that came back |
| await_response | Block until somebody answers. Returns a cursor if nobody has yet, so an agent can stop and resume hours later without missing or repeating an answer |
| update_response | Say what is happening to a response you picked up. The person who submitted it watches it on the page they were left holding, and is emailed when you finish; done and blocked close the thread and are final |
| request_approval | Ask named people for a decision and chase them — the first is emailed a link of their own, the next is asked if they stay quiet, and it closes at the deadline. An expired request is not an approval. Needs LEAFSLIP_TOKEN |
| cancel_approval | Withdraw a request you no longer need |
| invite_to_form | Invite people to a @private form. Each gets a link of their own, and you never see the links |
| list_invites | Who is invited, and who has answered — the answers themselves carry no identity |
| revoke_invite | Withdraw one invitation |
| update_form | Change a form without changing its URL. Pass base_source and a save made in the meantime is refused with the current source, not overwritten |
| await_response | Block until somebody answers, instead of polling |
| request_approval | Ask named people by email, chase them, and expire — never approve by silence |
| cancel_approval | Withdraw a request that stopped mattering |
| check_form | Validate a .form document without publishing |
| list_forms | Find a form published in an earlier session |
| list_channels | See where this account can send answers, and the line to write |
| create_channel | Save a Slack channel, address or endpoint and get the line to paste |
| remove_channel | Forget one; forms naming it stop delivering there |
ask_human is the one worth reading twice. An agent cannot send an email or
interrupt anybody, so a URL it prints into a log is a decision that never
arrives; this is the tool that makes the question reach a person. Reach for it
the moment you would otherwise stop — a permission you do not have, a
credential nobody gave you, a choice that is genuinely the user's. It covers
"may I drop this table" and "red or blue" alike: pass options and the answer
comes back as the one they picked, or leave them off for a yes/no and it comes
back as a boolean.
create_form and request_approval are still there for callers that want to
write the form themselves and ask separately. ask_human now uses the shared
POST /api/ask endpoint instead of performing those two creations itself.
Structured questions and request-specific answers
Use questions instead of legacy question/options for typed human input:
{
"questions": [
{
"id": "colour",
"prompt": "Which colour?",
"kind": "choice",
"options": [{ "id": "blue", "label": "Blue" }, { "id": "red", "label": "Red" }],
"required": true,
"allowOther": true
},
{ "id": "seats", "prompt": "How many seats?", "kind": "number", "minimum": 1, "integer": true }
],
"subject": "Checkout settings",
"approvers": ["[email protected]"],
"visibility": "invite",
"idempotency_key": "run-8842-checkout-settings",
"wait": false
}Kinds are text, choice, multi, boolean, number, and decision.
Optional fields include required, help, default, numeric minimum,
maximum, and integer, choice allowOther, and option description.
Choice defaults are a single option ID; multi-select defaults are arrays of option
IDs. Structured asks are invite-only. Use an explicit text kind for free text;
omitting legacy options still means a decision, for compatibility.
An answered request carries an answers map, for example
{"colour": {"selected": ["blue"]}, "seats": 3}. Keys are the question IDs and
selections are option IDs, never display labels. Choice/multi values contain
selected and optional other; text, number, and boolean values retain their
types. The legacy answer record is also returned when available. Both come
from the exact request's submission: no read_responses follow-up is needed,
and another request's latest form response is not a substitute.
approved is present only for an answered single decision. A boolean fact,
choice, or multi-question bundle does not imply permission. An absent approval
boolean is neither approval nor a person's rejection.
Creation does not wait on HTTP: ask_human gets the request id first, then
waits through GET /api/approvals/:id unless you passed wait: false. Reuse the
same idempotency_key for a retry of identical question/policy input; conflicting
content is refused. A replay may already be answered, even with wait: false.
The status is open, answered, expired, or cancelled. Only open means
keep waiting. An incomplete wait returns answered: false and the request id;
call ask_status again if you are blocked. Expiry/cancellation carry no approval
boolean and are terminal, not reasons to keep polling. An HTTP failure is an
unknown outcome, not a “no”: the tool returns an error with the known request id
for recovery rather than implying the human's answer was lost.
A wait has a thirty-minute elapsed budget, including HTTP and response-body time.
Each network call is bounded. Progress notifications keep a waiting client informed
only when its call supplied a progress token; they continue during HTTP waits.
If your process will exit before a person can answer, persist the request id or
use resume_url/resume_state rather than creating the question again.
Installing MCP exposes tools for the model to choose; it does not itself replace
an agent's native question UI or guarantee the model will call ask_human.
Why
A program can do most of a job and then hit something it cannot decide: an approval, a preference, a fact nobody wrote down. The usual answers are a Slack message nobody parses or a hand-rolled endpoint. This makes the question a form: the agent describes what it needs, hands a person a link, and reads the answer.
Installing it
In Claude Code, as a plugin — which ships the built server, so nothing is fetched from a registry:
/plugin marketplace add na5co/theform
/plugin install leafslip@leafslipIn Codex, Cursor, Claude Desktop or Gemini CLI, which have no plugin format but launch a server the same way:
npx @leafslip/cli installIt writes only the clients you actually have, shows the change first, and backs
up anything it touches. See plugin/README.md.
Running it by hand
Point your client at the published server:
{
"mcpServers": {
"leafslip": {
"command": "npx",
"args": ["-y", "@leafslip/mcp"],
"env": { "LEAFSLIP_TOKEN": "tok_…" }
}
}
}To run it from a checkout instead:
npm install && npm run build --workspace @leafslip/mcpand use "command": "node", "args": ["/absolute/path/to/packages/mcp/dist/server.js"].
LEAFSLIP_URL defaults to https://leafslip.app, so the server works with no configuration. Point it at a local worker during development. Set LEAFSLIP_TOKEN to an
API token (leafslip login, then leafslip token) so the forms an agent creates
belong to your account. Without one they are anonymous and stop accepting
responses after seven days — create_form says so in its result, since an
agent cannot see a form stop working.
Edit keys
create_form returns a slug and a URL, never the form's edit key. The key stays
inside the server process and is looked up by slug when a tool needs it, so it
cannot reach a transcript. It is sent as a header, never in a URL, so it does
not reach an access log or a Referer.
A key lives only as long as the process. With LEAFSLIP_TOKEN that does not
matter: forms belong to the account, so read_responses, update_form and
list_forms work on anything it owns, whenever. Without a token an agent can
only reach forms it created in the same session — which is usually not enough,
because the whole point is to ask a person something and come back later for
the answer.
Asking, then coming back
The shape this is for:
create_form -> hand the URL to a person -> ... time passes ...
list_forms -> find it again -> read_responsesA form can also carry what it was created for. Declare a @hidden field and put
the value in the link, and it comes back with the answer:
Deploy approval
Approve the release to production?
Approve?
- Yes
- No
@required
run_id
@hiddencreate_form returns the URL; append ?run_id=8842 before handing it over, and
every response names the run it belongs to. read_responses reports those
values under context as well as in data. See Context in SPEC.md.
For a form that should wake the agent instead of being polled, add
@webhook https://... to the source: the same context arrives in the body.
No dependencies
The protocol is implemented directly. The surface needed here is a handshake and three methods, and the reference SDK would be this project's only runtime dependency, for more bytes than the whole package.
