Guide · Job Queues

MCP Tools for Inngest — step functions, step.sleep, waitForEvent, serverless retries

Three Inngest behaviours are critical for MCP tool integrations: Inngest functions are re-executed from the beginning on every step continuation — all code before the first step.run() runs on every resume, so side-effects like logging, counters, or API calls outside of step.run() blocks execute multiple times; wrap every side-effectful operation in step.run('step-name', async () => { ... }) which memoizes the result and skips re-execution on subsequent resumes; step.waitForEvent suspends the function until an event arrives or the timeout expires, and a timeout returns null rather than throwing — MCP tools that dispatch work and wait for a completion event must explicitly handle the null case or they silently proceed as if the work completed successfully; and events sent from within a running function via inngest.send() are buffered until the current step completes — sending an event and then calling step.waitForEvent for that same event in the same step never resolves because the event is not delivered until after the step finishes.

TL;DR

Setup: const inngest = new Inngest({ id: 'mcp-server' }). Function: inngest.createFunction({ id, trigger: { event: 'app/task.requested' } }, async ({ event, step }) => { ... }). Steps: await step.run('name', async () => expensiveOp()). Sleep: await step.sleep('wait', '5m'). Wait for event: const evt = await step.waitForEvent('match', { event: 'app/task.done', timeout: '10m', if: 'event.data.id == async.data.id' }). Send event: await inngest.send({ name: 'app/task.requested', data: { ... } }).

Client setup and serving the handler

Inngest runs functions in your own infrastructure — Inngest's cloud calls your HTTP endpoint when events trigger functions. For MCP servers, expose the Inngest handler alongside your MCP tool endpoints. In development, the Inngest Dev Server (npx inngest-cli@latest dev) replaces the Inngest cloud and runs locally, enabling fast iteration without deploying.

import { Inngest } from 'inngest';
import { serve } from 'inngest/express';  // or inngest/next, inngest/hono, etc.
import express from 'express';

// Create the Inngest client — use signing key in production
export const inngest = new Inngest({
  id: 'mcp-agent-server',
  signingKey: process.env.INNGEST_SIGNING_KEY,  // required in production
  eventKey: process.env.INNGEST_EVENT_KEY,       // required to send events from server
});

const app = express();

// Register Inngest functions at /api/inngest
// Inngest cloud calls this endpoint to execute functions
app.use(
  '/api/inngest',
  serve({
    client: inngest,
    functions: [
      generateReportFn,
      processAgentTaskFn,
      sendFollowUpFn,
    ],
    // signingKey is read from inngest client — no need to repeat here
  })
);

// Your MCP tools are registered separately
// app.use('/mcp', mcpHandler);

app.listen(3000);

In production, Inngest verifies all requests to your endpoint using the INNGEST_SIGNING_KEY to prevent unauthorized function invocations. In local development with the Dev Server, signing is bypassed. The Dev Server also provides a UI at http://localhost:8288 showing function runs, event history, and step-level execution details — the primary debugging tool for Inngest functions.

Step functions — memoization and idempotent re-execution

Inngest functions are serverless and stateless — after each step.run(), the function state is serialized to Inngest's cloud and the serverless function exits. On the next step, Inngest calls your endpoint again with the accumulated state. Previously completed step.run() results are injected back into the function context and their callbacks are not re-executed. This means all code outside step blocks runs on every continuation.

export const processAgentTaskFn = inngest.createFunction(
  {
    id: 'process-agent-task',
    retries: 3,  // function-level retry count (applies to non-step code)
  },
  { event: 'app/agent.task.requested' },
  async ({ event, step, logger }) => {
    // WARNING: this code runs on EVERY step resume — do NOT put side effects here
    // logger.info is OK — it's idempotent
    logger.info(`Processing task ${event.data.taskId}`);

    // Step 1: Fetch data — memoized; runs only once even if function is retried
    const rawData = await step.run('fetch-data', async () => {
      return await fetchFromExternalAPI(event.data.sourceUrl);
      // If this throws, Inngest retries this step (not the whole function)
    });

    // Step 2: Transform — receives rawData from memoized step 1 result
    const transformed = await step.run('transform-data', async () => {
      return transformData(rawData);
    });

    // Step 3: Sleep before sending (rate limiting pattern)
    await step.sleep('rate-limit-pause', '2s');

    // Step 4: Send results — retried independently if it fails
    const result = await step.run('send-results', async () => {
      return await sendToDestination(transformed, event.data.destinationId);
    });

    // Return value is stored in the function run record
    return { taskId: event.data.taskId, status: 'completed', resultId: result.id };
  }
);

Each step.run() can have its own retry count: step.run('name', fn, { retries: 5 }). Step retries are independent — if step 3 fails and retries, steps 1 and 2 are not re-executed. This makes Inngest suitable for expensive operations like LLM API calls where you want to avoid re-running earlier steps on downstream failures.

Event-driven patterns — sendEvent and waitForEvent

MCP tools can trigger Inngest functions by sending events and can wait for completion events. The combination of inngest.send() from an MCP tool and step.waitForEvent() in a function creates an async request-response pattern suitable for long-running agent workflows. The if expression in waitForEvent correlates events using a CEL expression comparing the triggering event data to the awaited event data.

// MCP tool: dispatch work and return immediately with a run ID
async function mcpDispatchWorkTool(taskData: object): Promise<{ runId: string }> {
  const [{ id: runId }] = await inngest.send({
    name: 'app/agent.task.requested',
    data: taskData,
  });
  return { runId };
}

// Function that waits for an external approval event
export const approvalWorkflowFn = inngest.createFunction(
  { id: 'approval-workflow' },
  { event: 'app/approval.requested' },
  async ({ event, step }) => {
    // Send request to approver
    await step.run('notify-approver', async () => {
      await sendApprovalEmail(event.data.approverEmail, event.data.requestId);
    });

    // Wait up to 24 hours for the approver to respond
    const approvalEvent = await step.waitForEvent('wait-for-approval', {
      event: 'app/approval.responded',
      timeout: '24h',
      // CEL expression: match on requestId in the awaited event
      if: 'event.data.requestId == async.data.requestId',
    });

    // approvalEvent is null if timeout elapsed
    if (approvalEvent === null) {
      await step.run('handle-timeout', async () => {
        await notifyTimeout(event.data.requestId);
      });
      return { status: 'timed_out' };
    }

    if (approvalEvent.data.approved) {
      return { status: 'approved', approvedBy: approvalEvent.data.approverEmail };
    } else {
      return { status: 'rejected', reason: approvalEvent.data.reason };
    }
  }
);

// MCP tool: submit approval response (triggers waitForEvent resolution)
async function mcpSubmitApprovalTool(requestId: string, approved: boolean): Promise {
  await inngest.send({
    name: 'app/approval.responded',
    data: { requestId, approved, approverEmail: 'approver@example.com' },
  });
}

The if expression in waitForEvent uses CEL (Common Expression Language). event refers to the event being evaluated as a potential match, and async refers to the original triggering event that started the function. Without an if expression, the first event with the matching name resolves the wait — this can cause incorrect correlations in multi-tenant or high-volume systems. Always include a correlation expression for production use.

Concurrency, throttle, and rate limiting

Inngest supports concurrency limits (maximum simultaneous running function instances), throttle (maximum starts per time window), and debounce (coalesce rapid events into one execution). These controls are defined in the function configuration and enforced by Inngest's cloud — no queue infrastructure required in your application.

export const rateLimitedFn = inngest.createFunction(
  {
    id: 'rate-limited-task',
    // Concurrency: max 5 simultaneous runs of this function
    concurrency: {
      limit: 5,
      // Optional: per-key concurrency using event data
      // key: 'event.data.userId',  // max 5 concurrent runs PER user
    },
    // Throttle: max 100 starts per minute (token bucket)
    throttle: {
      limit: 100,
      period: '1m',
      // key: 'event.data.userId',  // 100 starts per minute per user
    },
    // Debounce: if multiple events arrive within 5s, execute only for the last one
    // debounce: { period: '5s', key: 'event.data.entityId' },
    retries: 2,
  },
  { event: 'app/data.process.requested' },
  async ({ event, step }) => {
    const result = await step.run('process', async () => {
      return processData(event.data);
    });
    return result;
  }
);

// Fan-out pattern: send multiple events to trigger parallel function instances
async function mcpFanOutTool(items: string[]): Promise<{ eventIds: string[] }> {
  const events = items.map(item => ({
    name: 'app/data.process.requested' as const,
    data: { itemId: item },
  }));
  const results = await inngest.send(events);
  return { eventIds: results.map(r => r.id) };
}

Throttle and concurrency limits queue excess events/runs rather than dropping them. If 200 events arrive and the throttle is 100/minute, the second 100 are queued and start in the next minute window — they are not lost. This is different from a rate limiter that rejects excess requests. Use debounce for entity update handlers where only the final state matters (e.g., syncing a document after every keystroke — debounce ensures only the last edit triggers a sync).

Health probe — function run status and Dev Server

Inngest does not expose a self-hosted health endpoint — its functions run in your serverless environment and are invoked by Inngest's cloud. For MCP server health monitoring, verify that your endpoint is reachable and that the Inngest signing key is valid. The Inngest SDK exposes a /api/inngest introspection response that can serve as a health signal.

import { InngestCommHandler } from 'inngest/express';

// Health check: verify Inngest endpoint is responding
async function checkInngestHealth(endpointUrl: string): Promise {
  try {
    // GET to the Inngest endpoint returns function registration metadata
    const response = await fetch(`${endpointUrl}/api/inngest`, {
      method: 'GET',
      headers: { 'Content-Type': 'application/json' },
    });

    if (!response.ok) {
      return { alive: false, error: `HTTP ${response.status}` };
    }

    const data = await response.json();
    return {
      alive: true,
      ready: true,
      functionCount: data.functions?.length ?? 0,
      appId: data.id,
    };
  } catch (err) {
    return { alive: false, error: String(err) };
  }
}

// Check run status via Inngest REST API (requires API key)
async function getRunStatus(runId: string): Promise {
  const response = await fetch(
    `https://api.inngest.com/v1/runs/${runId}`,
    {
      headers: { Authorization: `Bearer ${process.env.INNGEST_API_KEY}` },
    }
  );
  if (!response.ok) {
    return { error: `HTTP ${response.status}` };
  }
  const run = await response.json();
  // run.status: 'Running' | 'Completed' | 'Failed' | 'Cancelled'
  return { runId, status: run.status, endedAt: run.ended_at };
}

		

For MCP servers that dispatch Inngest work and need to report job status back to agents, store the Inngest run ID returned by inngest.send() and poll the Inngest REST API or use the Inngest webhook (inngest.createFunction({ id: 'on-run-completed', trigger: { event: 'inngest/function.finished' } })) to receive completion notifications without polling. The inngest/function.finished system event carries the original function's return value and error information.

Related guides