Pipeline
mcp_pipeline is the meta-tool that executes N tool calls in a single MCP
request, with later steps referring to earlier results via interpolation
syntax. It exists because some workflows are one operator intent but require
multiple Discord REST calls - e.g. “post the announcement, schedule the
event, then grant the role.” Without a pipeline, the agent makes
three round-trips and has to handle partial failures itself; with one, the
server captures intermediate state and propagates failures cleanly. It does
not provide transactions or rollback: completed Discord side effects remain
when a later step fails.
The flow
Section titled “The flow”sequenceDiagram participant Agent participant Pipeline as mcp_pipeline participant ToolA as messages_send participant ToolB as events_create participant ToolC as members_add_role participant Discord
Agent->>Pipeline: tools/call mcp_pipeline (3 steps) Pipeline->>ToolA: step 1 ToolA->>Discord: POST /messages Discord-->>ToolA: {id: "111122223333444455", ...} ToolA-->>Pipeline: result Pipeline->>Pipeline: interpolate {{step_0.message_id}} into step 2 Pipeline->>ToolB: step 2 ToolB->>Discord: POST /scheduled-events Discord-->>ToolB: {id: "...", ...} ToolB-->>Pipeline: result Pipeline->>ToolC: step 3 ToolC->>Discord: PUT /members/.../roles/... Discord-->>ToolC: 204 ToolC-->>Pipeline: result Pipeline-->>Agent: { steps: [...], variables, total_duration_ms, aborted }Sequential by default. Each step can read any earlier step’s output via
the {{step_id.path}} syntax; later steps see the resolved values.
See pipeline-multistep recipe
for a worked example.
Variable interpolation
Section titled “Variable interpolation”Source: packages/mcp-core/src/pipeline/interpolate.ts
Every step has an id. Give it one explicitly with "id": "announce", and
reference it as {{announce.field}}. If you omit id, the executor assigns
step_<index> using a 0-based index - the first step is step_0, the
second step_1.
Syntax:
{{announce.field}}- read a field on the step whose id isannounce.{{announce.field.subfield}}- dotted path traversal.{{announce.array[0]}}- bracket index for arrays.{{announce.array[0].id}}- combine.save_as: "alias"on a step also publishes its result underalias.
Resolution happens at dispatch time for each step: the args are walked
recursively and every string is scanned for {{...}}.
Unresolvable paths do not raise and do not abort. Two behaviors, depending on the shape of the string:
- Whole-string template (
"{{missing.path}}") - the arg becomesundefined. - Embedded template (
"see {{missing.path}} now") - the literal{{missing.path}}text is left in place.
Either way the step still runs, so a typo’d path surfaces as a downstream validation error or as literal braces posted into Discord - not as an interpolation error.
Resolved values keep their type - {{announce.count}} evaluating to a number
yields a real number in the next step’s args, not the string "42", as long
as the template is the whole string.
Recursion guard
Section titled “Recursion guard”If you find yourself wanting nested pipelines, the right answer is to flatten: write one pipeline whose steps happen to be what the inner would have run. The interpolation syntax is powerful enough to express most nested-pipeline shapes as a flat sequence.
Why it exists
Section titled “Why it exists”Three reasons:
-
Cohesive intent: “announce + schedule + grant” is one operator action, not three. Capturing it as one MCP call gives the agent a clean success/failure contract instead of three independent round-trips with their own error states.
-
Single observability unit: one pipeline = one parent span with leaf tool spans. Audit emits a parent event because
mcp_pipelineis non-idempotent, plus events for non-idempotent leaves. The current audit middleware skips idempotent leaves, including idempotent writes. -
Captured intermediate state:
announce.message_idgets passed into step 2 without the agent ever seeing it. Reduces token usage on the agent side (no need to round-trip “what’s the message ID? OK now use it”) and eliminates whole classes of “agent dropped a snowflake mid-prompt” bugs.
Failure behavior
Section titled “Failure behavior”Two failure modes:
- Mid-pipeline: step N fails (validation, precondition, REST error).
The pipeline records the error on that step in the
stepsarray, setsaborted: true, and breaks - subsequent steps are never dispatched and never appear instepsat all. Earlier steps’ Discord side effects already happened; thestepsarray tells you exactly what completed. Setcontinue_on_error: trueon a step to keep going past its failure instead. - Bulkhead saturation: if a leaf fails with
BULKHEAD_FULL, the pipeline propagates it. The bulkhead is the outermost fast-reject layer, so its rejection never enters the inner retry policy; the pipeline itself does not retry the step. The caller can back off and start a new call later.
Interpolation is not a failure mode - see Variable interpolation for what an unresolvable path does instead.
The result envelope shape:
{ "steps": [ { "id": "announce", "tool": "messages_send", "status": "success", "result": { "...": "..." }, "duration_ms": 142 }, { "id": "event", "tool": "events_create", "status": "error", "error": { "code": "DISCORD_PERMISSION_DENIED", "message": "...", "retriable": false }, "duration_ms": 88 } ], "variables": { "announce": { "...": "..." } }, "total_duration_ms": 230, "aborted": true}Per-step status is "success", "error", or "skipped" (an if condition
that evaluated falsy). result is present only on success, error only on
error. variables is the interpolation namespace at completion - successful
steps only.
To decide whether to retry / surface to the human, branch on:
const failed = result.aborted === true || result.steps.some((s) => s.status === 'error');Source map
Section titled “Source map”| Concern | File |
|---|---|
| Tool definition | tools/meta/pipeline.ts |
| Sequential executor | pipeline/executor.ts |
| Variable interpolation | pipeline/interpolate.ts |
Related
Section titled “Related”pipeline-multisteprecipe - worked example with three real tool calls.- Operations → Resilience - the bulkhead is shared across pipeline children, not amplified.
- Architecture → Components V2 - common pipeline destination is
components_v2_send.