elsa-mcp — overview

A Python MCP server that lets Claude author and read Elsa Workflows 3 definitions against your live Elsa instance.

Claude doesn't talk to Elsa directly. It talks to the elsa-mcp Python server over stdio. The server exposes 28 tools that wrap Elsa's REST API and add a few smarts on top — descriptor caching, JSON-Schema projection, dynamic capability discovery, deterministic happy-path layout. Two skills (/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

Plain-English prompt in Claude Code, e.g. "build a workflow at POST /bibi" or "explain bibi-amount-router".

Skill

Project-scoped Markdown that pins Claude to a discovery-first flow. Two of them.
.claude/skills/

MCP server

Python process. 28 tools: discovery, schema, layout, validate, save, run.
src/elsa_mcp/

Elsa server

Live workflow runtime + REST API. Holds the catalogue, saves drafts, runs them.
http://localhost:5257

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

11 tools 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_capabilitiesOne-shot bootstrap: every catalogue below in parallel.Optional modules show available: false — skip them.
elsa_list_expression_descriptorsRegistered expression languages (Literal, JavaScript, Liquid, Variable, Object, …).
elsa_list_variable_typesLegal typeName values for workflow variables (primitives plus any custom types loaded server-side).
elsa_list_storage_driversLegal storageDriverTypeName values for persisting variables across bookmarks.
elsa_list_incident_strategiesWorkflow-level fault handling (options.incidentStrategyType).
elsa_list_workflow_activation_strategiesWorkflow activation strategies (options.activationStrategyType).
elsa_list_commit_strategiesCommit strategies — pass scope="workflows" or "activities".
elsa_list_log_persistence_strategiesLog persistence strategies (per-activity / workflow log config).
elsa_list_resilience_strategiesRetry / circuit-breaker strategies. Only when Elsa.Resilience is installed.
elsa_list_featuresInstalled Elsa modules.
elsa_get_featureLookup one feature by full name.

Activity discovery

5 tools Find activities to use, and learn their exact input contract before emitting JSON.
elsa_search_activitiesSubstring search across the live activity catalogue. Filter by category / kind.
elsa_list_modulesGroups the catalogue by top-level namespace (e.g. Elsa.Http).
elsa_get_activity_descriptorRaw descriptor for one activity + a usage_example skeleton ready to fill in.
elsa_get_activity_schemaJSON 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_optionsDynamic-dropdown resolver. POSTs sibling-input context; useful for RunWorkflow.workflowDefinitionId pickers and similar.

Envelope & layout

2 tools The workflow-level wrapper around activities, plus deterministic canvas positioning.
elsa_get_workflow_envelope_schemaJSON Schema for definitionId, variables, inputs, outputs, options, plus reference forms for binding variables / inputs / activity outputs in expressions.
elsa_layout_workflowSets 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

4 tools Round-trip a draft through the validator, then persist. All require ELSA_MODE=draft or full.
elsa_validate_workflowSaves as an unpublished draft purely to surface validation findings, then continues with that draft.In readonly mode only structural checks run.
elsa_create_workflowNew definition. publish=false by default.
elsa_update_workflowSave changes to an existing definition_id.
elsa_publish_workflowPromote latest draft → published. Requires ELSA_MODE=full.

Read

2 tools Fetch saved definitions for explanation or further edits.
elsa_get_workflowLatest, Published, or a specific version of one definition.
elsa_list_workflowsList + filter (name_contains, published_only) + paginate.

Runtime runs code

3 tools Actually execute a workflow or one activity in isolation. All require ELSA_MODE=full.
elsa_dispatch_workflowFire-and-forget. Returns an instance id immediately. Use for anything that may suspend.
elsa_execute_workflowSynchronous run. Only safe for short, non-suspending workflows.
elsa_test_activityRun one activity in isolation against a saved definition. Mirrors Studio's "Test" tab.

Scripting

1 tool Per-workflow type definitions for the JavaScript sandbox.
elsa_get_javascript_type_definitionsServer-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

  1. Discover server capabilities

    elsa_get_server_capabilities One call. Expression languages, variable types, drivers, strategies.

  2. Find activities by behaviour

    elsa_search_activities Never invent a type name.

  3. Fetch each activity's schema

    elsa_get_activity_schema Read x-is-wrapped, x-outcomes, x-embedded-ports.

  4. Resolve any dynamic dropdowns

    elsa_get_activity_property_options Only when x-dynamic-options is set on an input.

  5. Assemble the envelope

    elsa_get_workflow_envelope_schema Variables/inputs/outputs typed from the live catalogue.

  6. Lay out the canvas

    elsa_layout_workflow Happy path row 0, branches below. Recurses into nested Flowcharts.

  7. Validate

    elsa_validate_workflow Round-trip through Elsa's validator.

  8. Save

    elsa_create_workflow / elsa_update_workflowpublish=false by default.

  9. Optional: publish or smoke-test

    elsa_publish_workflow · elsa_dispatch_workflow · elsa_test_activity — needs full.

Explain flow — what /elsa-explain drives

  1. Locate the workflow

    elsa_get_workflow by id, or elsa_list_workflows to find it by name. Inline JSON skips this step.

  2. Map the envelope

    Trigger (customProperties.canStartWorkflow), inputs, outputs, variables (note any without a storage driver).

  3. Classify each activity

    elsa_get_activity_descriptor for unknown types. Decide role: trigger / container / sub-workflow caller / plain action.

  4. Trace the happy path

    First declared outgoing port at each step, stop at terminals.

  5. Look inside containers

    For If.then/else, ForEach.body, Switch cases: single-activity or nested Flowchart? Recurse.

  6. Follow sub-workflow refs (depth 2)

    Composite-as-activity (top-level workflowDefinitionId) or Elsa.RunWorkflow / DispatchWorkflow (wrapped Input<String>). Cycle-protected.

  7. 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

A · Straight chain (HTTP trigger → WriteLine → response)
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"} }
    ]
  }
}
B · Branching with a nested Flowchart in If.then
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

┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ happy path │ Trigger1 │ ─▶ │ If1 │ ─▶ │ WriteHi │ ─▶ │ RespHigh │ (row 0) └──────────┘ └────┬─────┘ └──────────┘ └──────────┘ │ │ (False outcome) ▼ ┌──────────┐ ┌──────────┐ branch │ WriteLo │ ─▶ │ RespLow │ (row 1) └──────────┘ └──────────┘

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_MODEdiscovervalidatecreate / update (draft)publish / run
readonlyyesstructural onlynono
draftyesyes (round-trip)yes (publish forced false)no
fullyesyesyesyes

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.