MP-301a · Module 1

Chained Tool Pipelines

4 min read

Tool chaining is the pattern where the output of one tool becomes the input of the next. In basic MCP usage the LLM orchestrates the chain — it calls tool A, reads the result, then calls tool B with data from A's output. But at the 301 level, you need to understand when to move chaining inside the server. Server-side chaining reduces round-trips, cuts token overhead, and enables atomic multi-step operations that must succeed or fail together. The trade-off is visibility: the LLM cannot inspect intermediate results or change course mid-chain.

A well-designed chain tool exposes its pipeline stages in the response. Instead of returning only the final result, include a "steps" array showing what each stage did, its input, and its output. This gives the LLM enough context to debug failures and explain the result to the user. Without stage visibility, a chain tool is a black box — and black boxes erode trust when things go wrong.

The critical design question is atomicity. If stage 2 of a 4-stage chain fails, what happens to the side effects from stage 1? If you are writing to a database, you need transaction semantics — roll back everything if any stage fails. If you are calling external APIs, rollback may be impossible, so you need compensating actions (undo what stage 1 did) or idempotency guarantees (safe to retry the whole chain). Choose your chain boundaries based on what you can safely undo.

// Server-side chain: lookup → enrich → score
async function enrichContact(email: string) {
  const steps: { stage: string; status: string; data?: unknown }[] = [];

  // Stage 1: Lookup
  const contact = await crm.findByEmail(email);
  if (!contact) {
    return { isError: true, steps, error: `No contact found for ${email}` };
  }
  steps.push({ stage: "lookup", status: "ok", data: { id: contact.id } });

  // Stage 2: Enrich from external API
  let enrichment;
  try {
    enrichment = await clearbit.enrich(email);
    steps.push({ stage: "enrich", status: "ok" });
  } catch (err) {
    steps.push({ stage: "enrich", status: "skipped", data: { reason: (err as Error).message } });
    enrichment = null; // Graceful degradation, not failure
  }

  // Stage 3: Score
  const score = scoreContact(contact, enrichment);
  steps.push({ stage: "score", status: "ok", data: { score } });

  return { contact: { ...contact, ...enrichment, score }, steps };
}
  1. Map the dependency graph Before building a chain, draw which stages depend on which outputs. If two stages are independent, they should be parallel (fan-out), not sequential.
  2. Define rollback semantics For each stage with side effects, document what happens on failure: rollback, compensate, or accept partial state. This determines your error handling strategy.
  3. Expose stage results Return a steps array with each stage's name, status, and relevant data. The LLM and the user both need visibility into what happened inside the chain.