Guide · AWS Bedrock

Bedrock Batch Inference in MCP Servers — CreateModelInvocationJob, S3 JSONL, async results

AWS Bedrock batch inference lets MCP server tools submit thousands of model invocation requests as a single asynchronous job, processing them at up to 50% cost reduction compared to on-demand API calls. Instead of blocking on each inference call, the MCP tool uploads a JSONL file to S3, creates a job, and returns immediately — the model runs in the background, and results land in an output S3 prefix when complete. This architecture is ideal for MCP tools that perform bulk analysis: classifying a corpus of documents, generating summaries for a dataset, evaluating tool outputs, or running nightly report generation. This guide covers the complete workflow: S3 JSONL input format, IAM role setup for cross-service access, job creation and state polling, EventBridge-based completion notification, result processing, and error record handling.

TL;DR

Upload a JSONL file to S3 (one JSON object per line, each with a recordId and modelInput matching the model's native request format). Call CreateModelInvocationJob with an IAM role that has bedrock:InvokeModel + S3 read/write. Poll GetModelInvocationJob until status is Completed or Failed — or use an EventBridge rule on batchInference/jobStateChange for push notification. Read output JSONL from the output S3 prefix. Failed records appear inline with an error field — process successes and log failures. Max 50k records per job; max 1 active job per account per region at a time (others queue).

When to use batch inference vs on-demand streaming

Bedrock batch inference is optimized for throughput, not latency. It is the right choice when:

On-demand streaming is the right choice when the MCP tool must return results within a single tool-call timeout (typically 30–120 seconds), or when requests are user-interactive. Do not use batch inference when you need results in the same session that submitted the job.

CriterionBatch inferenceOn-demand (Converse/InvokeModel)
LatencyMinutes to hours (async)Milliseconds to seconds (sync)
Cost50% of on-demand for supported modelsFull on-demand price
Max request count50,000 records per jobUnlimited (rate-limited by quota)
Best forBulk analysis, reporting, offline classificationInteractive tools, real-time agent loops
Concurrency modelUp to 100 parallel records per jobPer-account TPM/RPM quota

S3 input JSONL format

The input file is a JSONL (newline-delimited JSON) file uploaded to an S3 bucket. Each line is a self-contained JSON object with two required fields: recordId (a unique string identifier for this record) and modelInput (the raw model request body, exactly as you would send to InvokeModel for that model family).

// Generate a JSONL batch input file for Claude via Bedrock batch inference
// modelInput uses the Anthropic Messages API format (same as InvokeModel)

interface BatchRecord {
  recordId: string;
  modelInput: {
    anthropic_version: string;
    max_tokens: number;
    messages: Array<{ role: 'user' | 'assistant'; content: string }>;
    system?: string;
  };
}

function buildBatchInputLine(
  recordId: string,
  prompt: string,
  systemPrompt?: string,
): string {
  const record: BatchRecord = {
    recordId,
    modelInput: {
      anthropic_version: 'bedrock-2023-05-31',
      max_tokens: 1024,
      messages: [{ role: 'user', content: prompt }],
      ...(systemPrompt ? { system: systemPrompt } : {}),
    },
  };
  return JSON.stringify(record);
}

// Example: generate summaries for a list of MCP server error logs
function buildErrorSummaryBatch(
  errorLogs: Array<{ serverId: string; log: string }>,
): string {
  const systemPrompt = 'You are an expert at analyzing MCP server error logs. '
    + 'Summarize the root cause in one sentence and suggest a fix.';

  return errorLogs
    .map(({ serverId, log }) =>
      buildBatchInputLine(serverId, `Error log:\n${log}`, systemPrompt)
    )
    .join('\n');
}

// Upload to S3
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';

const s3 = new S3Client({ region: 'us-east-1' });

async function uploadBatchInput(
  bucket: string,
  key: string,
  jsonlContent: string,
): Promise {
  await s3.send(new PutObjectCommand({
    Bucket: bucket,
    Key: key,
    Body: jsonlContent,
    ContentType: 'application/jsonlines',
  }));
  console.log(`Uploaded batch input: s3://${bucket}/${key}`);
}

Important constraints: each record's modelInput must be valid JSON matching the model-specific request format. For Claude models via Bedrock, this is the Anthropic Messages API format (not the Converse API format). For Amazon Titan, it is the Titan-specific body. The S3 file must be readable by the IAM role you pass to the batch job — same account S3 access is typical, but cross-account is also supported with bucket policies.

IAM role and CreateModelInvocationJob

Bedrock batch inference requires a dedicated IAM role that Bedrock can assume via a trust relationship. This role needs bedrock:InvokeModel on the target model plus s3:GetObject on the input bucket and s3:PutObject on the output bucket.

// IAM trust policy for the Bedrock batch inference role
// (Set up in AWS Console or CDK — this is the trust policy JSON)
const trustPolicy = {
  Version: '2012-10-17',
  Statement: [{
    Effect: 'Allow',
    Principal: { Service: 'bedrock.amazonaws.com' },
    Action: 'sts:AssumeRole',
    Condition: {
      StringEquals: { 'aws:SourceAccount': ACCOUNT_ID },
      ArnLike: { 'aws:SourceArn': `arn:aws:bedrock:us-east-1:${ACCOUNT_ID}:*` },
    },
  }],
};

// Permission policy attached to the role
const permissionPolicy = {
  Version: '2012-10-17',
  Statement: [
    {
      Sid: 'BedrockInvoke',
      Effect: 'Allow',
      Action: ['bedrock:InvokeModel'],
      Resource: [
        'arn:aws:bedrock:us-east-1::foundation-model/anthropic.claude-3-5-sonnet-20241022-v2:0',
        'arn:aws:bedrock:us-east-1::foundation-model/anthropic.claude-3-haiku-20240307-v1:0',
      ],
    },
    {
      Sid: 'S3Input',
      Effect: 'Allow',
      Action: ['s3:GetObject', 's3:ListBucket'],
      Resource: [
        `arn:aws:s3:::${BATCH_INPUT_BUCKET}`,
        `arn:aws:s3:::${BATCH_INPUT_BUCKET}/*`,
      ],
    },
    {
      Sid: 'S3Output',
      Effect: 'Allow',
      Action: ['s3:PutObject'],
      Resource: [`arn:aws:s3:::${BATCH_OUTPUT_BUCKET}/*`],
    },
  ],
};

// Create the batch job
import {
  BedrockClient,
  CreateModelInvocationJobCommand,
  GetModelInvocationJobCommand,
} from '@aws-sdk/client-bedrock';

const bedrock = new BedrockClient({ region: 'us-east-1' });

async function submitBatchJob(
  inputS3Uri: string,   // s3://bucket/path/input.jsonl
  outputS3Uri: string,  // s3://bucket/path/output/ (prefix, not file)
  jobName: string,
): Promise {
  const response = await bedrock.send(new CreateModelInvocationJobCommand({
    modelId: 'anthropic.claude-3-5-sonnet-20241022-v2:0',
    jobName,
    roleArn: process.env.BEDROCK_BATCH_ROLE_ARN!,
    inputDataConfig: {
      s3InputDataConfig: {
        s3Uri: inputS3Uri,
        s3InputFormat: 'JSONLines',
      },
    },
    outputDataConfig: {
      s3OutputDataConfig: {
        s3Uri: outputS3Uri,
      },
    },
  }));

  const jobArn = response.jobArn;
  if (!jobArn) throw new Error('No jobArn in CreateModelInvocationJob response');
  console.log(`Batch job created: ${jobArn}`);
  return jobArn;
}

The jobName must be unique within the account. A common pattern is to use a timestamp plus a hash of the input content: mcp-analysis-${Date.now()}-${hash}. Job ARNs are the durable identifier for the job — store the ARN returned by CreateModelInvocationJobCommand to poll status and retrieve results later.

Job polling and EventBridge completion notification

For an MCP tool that submits a batch job and needs to return a job handle to the user, the tool should return the job ARN immediately and expose a separate polling tool. For background pipelines that trigger downstream actions on completion, EventBridge is a better pattern than polling.

// Poll job status — use this in a background worker, not in a synchronous MCP tool handler
type JobStatus = 'Submitted' | 'InProgress' | 'Completed' | 'Failed' | 'Stopped';

async function pollJobUntilComplete(
  jobArn: string,
  pollIntervalMs = 30_000,
  timeoutMs = 6 * 60 * 60 * 1000, // 6 hours max
): Promise {
  const start = Date.now();

  while (Date.now() - start < timeoutMs) {
    const response = await bedrock.send(new GetModelInvocationJobCommand({
      jobIdentifier: jobArn,
    }));

    const status = response.status as JobStatus;
    console.log(`Job ${jobArn}: ${status}`);

    if (status === 'Completed' || status === 'Failed' || status === 'Stopped') {
      return status;
    }

    await new Promise(r => setTimeout(r, pollIntervalMs));
  }

  throw new Error(`Job ${jobArn} did not complete within timeout`);
}

// EventBridge rule for push notification (set up via CDK or Console)
// Event pattern:
// {
//   "source": ["aws.bedrock"],
//   "detail-type": ["Batch Inference Job State Change"],
//   "detail": {
//     "status": ["Completed", "Failed"]
//   }
// }
// Target: Lambda function that reads output S3 and processes results

// MCP tool: submit job and return ARN for later retrieval
async function submitAnalysisJob(
  documents: Array<{ id: string; content: string }>,
): Promise<{ jobArn: string; inputKey: string }> {
  const batchContent = buildErrorSummaryBatch(
    documents.map(d => ({ serverId: d.id, log: d.content }))
  );

  const inputKey = `batch-inputs/${Date.now()}.jsonl`;
  await uploadBatchInput(BATCH_BUCKET, inputKey, batchContent);

  const jobArn = await submitBatchJob(
    `s3://${BATCH_BUCKET}/${inputKey}`,
    `s3://${BATCH_BUCKET}/batch-outputs/${Date.now()}/`,
    `mcp-analysis-${Date.now()}`,
  );

  return { jobArn, inputKey };
}

Processing output results and error records

When the job completes, Bedrock writes a JSONL output file to the S3 output prefix. Each line corresponds to an input record and contains either a successful model output or an error. Both successes and errors use the same JSON structure.

import { GetObjectCommand } from '@aws-sdk/client-s3';

interface BatchOutputRecord {
  recordId: string;
  modelOutput?: {
    id: string;
    type: 'message';
    role: 'assistant';
    content: Array<{ type: 'text'; text: string }>;
    model: string;
    stop_reason: string;
    usage: { input_tokens: number; output_tokens: number };
  };
  error?: {
    errorCode: string;
    errorMessage: string;
  };
}

async function processJobOutput(
  outputS3Uri: string,
): Promise<{
  successes: Array<{ recordId: string; text: string }>;
  failures: Array<{ recordId: string; errorCode: string; errorMessage: string }>;
}> {
  // Parse s3://bucket/prefix/ → list objects
  const [bucket, prefix] = outputS3Uri.replace('s3://', '').split(/\/(.*)/);

  // List output objects (Bedrock writes one or more JSONL files per job)
  const { ListObjectsV2Command } = await import('@aws-sdk/client-s3');
  const { Contents } = await s3.send(new ListObjectsV2Command({ Bucket: bucket, Prefix: prefix }));

  const successes: Array<{ recordId: string; text: string }> = [];
  const failures: Array<{ recordId: string; errorCode: string; errorMessage: string }> = [];

  for (const obj of Contents ?? []) {
    if (!obj.Key?.endsWith('.jsonl')) continue;

    const { Body } = await s3.send(new GetObjectCommand({ Bucket: bucket, Key: obj.Key }));
    const text = await Body?.transformToString('utf-8') ?? '';

    for (const line of text.split('\n').filter(Boolean)) {
      const record: BatchOutputRecord = JSON.parse(line);

      if (record.error) {
        failures.push({
          recordId: record.recordId,
          errorCode: record.error.errorCode,
          errorMessage: record.error.errorMessage,
        });
      } else if (record.modelOutput?.content?.[0]?.text) {
        successes.push({
          recordId: record.recordId,
          text: record.modelOutput.content[0].text,
        });
      }
    }
  }

  console.log(`Processed: ${successes.length} successes, ${failures.length} failures`);
  return { successes, failures };
}

Common error codes in the output: ThrottledException (record failed due to throttling — retry it via on-demand), ModelTimeoutException (model took too long for this record — reduce max_tokens or simplify the prompt), ValidationException (malformed modelInput — fix the input record). Failed records do not cause the entire job to fail — the job status is still Completed even if some records errored. Check the failure count before declaring success.

Supported models and job limits

ModelBatch inference supportCost vs on-demand
Claude 3 HaikuSupported50% discount
Claude 3 SonnetSupported50% discount
Claude 3.5 SonnetSupported50% discount
Claude 3.5 HaikuSupported50% discount
Amazon Titan Text PremierSupported50% discount
Meta Llama 3 70BSupported50% discount
Mistral LargeNot supported—
Cohere Command R+Not supported—

Key limits: 50,000 records per job; 1 concurrent running job per account per region (additional jobs queue); job expires after 7 days if not completed (failed records cause quota consumption but the job can complete with partial results). For production pipelines, set up CloudWatch Alarms on the EventBridge job-state-change events and alert on Failed jobs.