Skip to content

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.

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.

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 is announce.
  • {{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 under alias.

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 becomes undefined.
  • 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.

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.

Three reasons:

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

  2. Single observability unit: one pipeline = one parent span with leaf tool spans. Audit emits a parent event because mcp_pipeline is non-idempotent, plus events for non-idempotent leaves. The current audit middleware skips idempotent leaves, including idempotent writes.

  3. Captured intermediate state: announce.message_id gets 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.

Two failure modes:

  • Mid-pipeline: step N fails (validation, precondition, REST error). The pipeline records the error on that step in the steps array, sets aborted: true, and breaks - subsequent steps are never dispatched and never appear in steps at all. Earlier steps’ Discord side effects already happened; the steps array tells you exactly what completed. Set continue_on_error: true on 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');
ConcernFile
Tool definitiontools/meta/pipeline.ts
Sequential executorpipeline/executor.ts
Variable interpolationpipeline/interpolate.ts