elsa-mcp — overview
A Python MCP server that lets Claude author and read Elsa Workflows 3 definitions against your live Elsa instance.
/elsa-author, /elsa-explain) teach Claude the right order to
call those tools in so workflows end up consistent and the canvas opens cleanly.
1 · The picture
You
Skill
MCP server
Elsa server
2 · Setup
Three pieces of config live in .env:
ELSA_BASE_URL=http://localhost:5257 # Elsa REST endpoint
ELSA_BEARER_TOKEN=eyJhbGciOi… # or ELSA_API_KEY=… (one of them)
ELSA_MODE=draft # readonly | draft | full
Register the MCP server with Claude Code once per machine:
cd /Users/cristina.mudura/repos/neoblue-tech-labs/elsa-mcp
claude mcp add elsa --scope local -- \
$(pwd)/.venv/bin/python3 -m elsa_mcp
Then start Claude in the project directory so the project-scoped skills load:
cd /Users/cristina.mudura/repos/neoblue-tech-labs/elsa-mcp
claude
Inside Claude, sanity-check:
/mcp # expect: elsa ✓ Connected
/skills # expect: elsa-author, elsa-explain
3 · Tools (all 28, grouped by purpose)
Every tool is exposed as mcp__elsa__<name> in Claude. Write/run tools
respect ELSA_MODE (see section 7).
Capability discovery
What this Elsa server can do — expression languages, variable types, storage drivers, strategies, installed features. Run once per session via the bootstrap.| elsa_get_server_capabilities | One-shot bootstrap: every catalogue below in parallel.Optional modules show available: false — skip them. |
| elsa_list_expression_descriptors | Registered expression languages (Literal, JavaScript, Liquid, Variable, Object, …). |
| elsa_list_variable_types | Legal typeName values for workflow variables (primitives plus any custom types loaded server-side). |
| elsa_list_storage_drivers | Legal storageDriverTypeName values for persisting variables across bookmarks. |
| elsa_list_incident_strategies | Workflow-level fault handling (options.incidentStrategyType). |
| elsa_list_workflow_activation_strategies | Workflow activation strategies (options.activationStrategyType). |
| elsa_list_commit_strategies | Commit strategies — pass scope="workflows" or "activities". |
| elsa_list_log_persistence_strategies | Log persistence strategies (per-activity / workflow log config). |
| elsa_list_resilience_strategies | Retry / circuit-breaker strategies. Only when Elsa.Resilience is installed. |
| elsa_list_features | Installed Elsa modules. |
| elsa_get_feature | Lookup one feature by full name. |
Activity discovery
Find activities to use, and learn their exact input contract before emitting JSON.| elsa_search_activities | Substring search across the live activity catalogue. Filter by category / kind. |
| elsa_list_modules | Groups the catalogue by top-level namespace (e.g. Elsa.Http). |
| elsa_get_activity_descriptor | Raw descriptor for one activity + a usage_example skeleton ready to fill in. |
| elsa_get_activity_schema | JSON Schema with x-outcomes, x-embedded-ports, and per-input x-ui-hint / x-is-wrapped / x-default-syntax / x-dynamic-options. |
| elsa_get_activity_property_options | Dynamic-dropdown resolver. POSTs sibling-input context; useful for RunWorkflow.workflowDefinitionId pickers and similar. |
Envelope & layout
The workflow-level wrapper around activities, plus deterministic canvas positioning.| elsa_get_workflow_envelope_schema | JSON Schema for definitionId, variables, inputs, outputs, options, plus reference forms for binding variables / inputs / activity outputs in expressions. |
| elsa_layout_workflow | Sets metadata.designer.position + size on every activity. Happy path on row 0, branches drop below. Recurses into nested Flowcharts.Pure Python; no server round-trip. |
Validate & save writes
Round-trip a draft through the validator, then persist. All requireELSA_MODE=draft or full.
| elsa_validate_workflow | Saves as an unpublished draft purely to surface validation findings, then continues with that draft.In readonly mode only structural checks run. |
| elsa_create_workflow | New definition. publish=false by default. |
| elsa_update_workflow | Save changes to an existing definition_id. |
| elsa_publish_workflow | Promote latest draft → published. Requires ELSA_MODE=full. |
Read
Fetch saved definitions for explanation or further edits.| elsa_get_workflow | Latest, Published, or a specific version of one definition. |
| elsa_list_workflows | List + filter (name_contains, published_only) + paginate. |
Runtime runs code
Actually execute a workflow or one activity in isolation. All requireELSA_MODE=full.
| elsa_dispatch_workflow | Fire-and-forget. Returns an instance id immediately. Use for anything that may suspend. |
| elsa_execute_workflow | Synchronous run. Only safe for short, non-suspending workflows. |
| elsa_test_activity | Run one activity in isolation against a saved definition. Mirrors Studio's "Test" tab. |
Scripting
Per-workflow type definitions for the JavaScript sandbox.| elsa_get_javascript_type_definitions | Server-generated .d.ts covering this workflow's variables, inputs, and activity outputs. Useful for validating JS expressions before saving. |
4 · Skills
Two project-scoped skills live in .claude/skills/. They load automatically
when you launch claude from this directory. You can invoke each explicitly with
its slash command, or just describe what you want — the skill's description
frontmatter makes Claude pick it up implicitly.
/elsa-author
Discovery-first authoring loop. Forces Claude to fetch capabilities, search activities, and read schemas before writing any JSON. Enforces the Input<T> wrapper, the Flowchart-not-Sequence container rule, layout before validate, and never publishes without explicit consent.
Use when
/elsa-author Build a workflow at POST /bibi
that routes by `amount` and returns
{ tier, amount } as JSON.
/elsa-explain
Reads a saved workflow (by id, by name, or from pasted JSON), maps
the envelope, traces the happy path, drills into containers and sub-workflow
callers. Follows RunWorkflow, DispatchWorkflow, and
composite-as-activity references up to depth 2 with cycle protection.
Use when
/elsa-explain bibi-amount-router
/elsa-explain what does the latest
order-flow do?
Both skills are descriptive text, not hooks — a stubborn LLM can bypass them.
If you want hard enforcement (e.g. "every save must follow a validate"), the
right tool is a hook in settings.json. Ask and I can wire one up.
5 · The two flows side by side
Author flow — what /elsa-author drives
Discover server capabilities
elsa_get_server_capabilities One call. Expression languages, variable types, drivers, strategies.
Find activities by behaviour
elsa_search_activities Never invent a type name.
Fetch each activity's schema
elsa_get_activity_schema Read
x-is-wrapped,x-outcomes,x-embedded-ports.Resolve any dynamic dropdowns
elsa_get_activity_property_options Only when
x-dynamic-optionsis set on an input.Assemble the envelope
elsa_get_workflow_envelope_schema Variables/inputs/outputs typed from the live catalogue.
Lay out the canvas
elsa_layout_workflow Happy path row 0, branches below. Recurses into nested Flowcharts.
Validate
elsa_validate_workflow Round-trip through Elsa's validator.
Save
elsa_create_workflow / elsa_update_workflow —
publish=falseby default.Optional: publish or smoke-test
elsa_publish_workflow · elsa_dispatch_workflow · elsa_test_activity — needs
full.
Explain flow — what /elsa-explain drives
Locate the workflow
elsa_get_workflow by id, or elsa_list_workflows to find it by name. Inline JSON skips this step.
Map the envelope
Trigger (
customProperties.canStartWorkflow), inputs, outputs, variables (note any without a storage driver).Classify each activity
elsa_get_activity_descriptor for unknown types. Decide role: trigger / container / sub-workflow caller / plain action.
Trace the happy path
First declared outgoing port at each step, stop at terminals.
Look inside containers
For
If.then/else,ForEach.body,Switchcases: single-activity or nested Flowchart? Recurse.Follow sub-workflow refs (depth 2)
Composite-as-activity (top-level
workflowDefinitionId) orElsa.RunWorkflow/DispatchWorkflow(wrapped Input<String>). Cycle-protected.Summarise: side effects + watch-outs
HTTP calls, dispatched workflows, suspending activities, in-memory variables, unresolvable dynamic refs.
6 · Rules cheat sheet
Container rule (most common failure)
doSingle child at an embedded port
Drop the activity directly into the slot: "then": { "id": "WriteLine1", "type": "Elsa.WriteLine", ... }
doTwo+ children → nested Flowchart
Wrap in Elsa.Flowchart with its own activities[] + connections[]. The layout tool recurses.
don'tUse Elsa.Sequence in embedded ports
Runs fine, looks empty in Studio. Always Flowchart.
watchEmbedded ports ≠ outcome ports
If.then/else hold child activities. True/False are connection ports in connections[]. Different things.
The Input<T> wrapper
Most activity inputs are wrapped. A bare "text": "Hello" is rejected — the
validator wants the three-field envelope:
"text": {
"typeName": "String", ← .NET type the activity wants
"expression": { "type": "Literal", "value": "Hello" }, ← Literal / JavaScript / Liquid / Variable / ...
"memoryReference": { "id": "WriteLine1:text" } ← unique key within the workflow
}
The schema flags which inputs need wrapping (x-is-wrapped: true) and which
don't (e.g. Flowchart.activities is plain JSON).
Two minimal examples
The ask
Build a workflow at POST /ping
that writes "pong" to the console
and returns "ok" as the HTTP response.
What gets created (shape)
{
"definitionId": "ping",
"root": {
"type": "Elsa.Flowchart",
"activities": [
{ "id": "Trigger1", "type": "Elsa.HttpEndpoint",
"customProperties": { "canStartWorkflow": true },
"path": { ...Input<String> envelope... } },
{ "id": "WriteLine1", "type": "Elsa.WriteLine",
"text": { ...Input<String> envelope... } },
{ "id": "Response1", "type": "Elsa.WriteHttpResponse",
"content": { ...Input<String> envelope... } }
],
"connections": [
{ "source": {"activity":"Trigger1","port":"Done"},
"target": {"activity":"WriteLine1","port":"In"} },
{ "source": {"activity":"WriteLine1","port":"Done"},
"target": {"activity":"Response1","port":"In"} }
]
}
}
The ask
POST /bibi with { amount }.
If amount > 100 → write "high" then
return { tier: "high" }.
Otherwise → write "low" then
return { tier: "low" }.
What the If branch looks like
{
"id": "If1",
"type": "Elsa.If",
"condition": { ...Input<Boolean> with JS expression... },
"then": {
"id": "ThenFlow",
"type": "Elsa.Flowchart", ← Flowchart, NOT Sequence
"activities": [
{ "id":"WriteHigh", "type":"Elsa.WriteLine", ... },
{ "id":"RespHigh", "type":"Elsa.WriteHttpResponse", ... }
],
"connections": [
{ "source":{"activity":"WriteHigh","port":"Done"},
"target":{"activity":"RespHigh","port":"In"} }
]
},
"else": { /* same shape with WriteLow + RespLow */ }
}
Happy-path layout, at a glance
7 · Modes
ELSA_MODE in .env controls what the MCP is allowed to do — the
boundary is enforced server-side by the MCP itself, not by Elsa. Reconnect after
changing it.
| ELSA_MODE | discover | validate | create / update (draft) | publish / run |
|---|---|---|---|---|
readonly | yes | structural only | no | no |
draft | yes | yes (round-trip) | yes (publish forced false) | no |
full | yes | yes | yes | yes |
8 · Reconnect cheat-sheet
Anything you change in .env (token, mode, base URL) only takes effect
on the next MCP process. To force a clean restart:
claude mcp remove elsa -s local
claude mcp add elsa --scope local -- \
$(pwd)/.venv/bin/python3 -m elsa_mcp
Or — inside Claude — type /mcp, pick elsa, choose Reconnect. A token-only
refresh sometimes survives the in-memory reconnect, in which case a full remove/add
is the reliable path.