Amazon IoT Core · Device Shadow · Rules Engine · IoT Jobs · Fleet Management · 2026-11-09 · IoT Core arc

Amazon IoT Core for MCP Servers: Fleet Management, Device Shadows, Rules Engine, and Jobs — Complete Playbook

AliveMCP monitors MCP endpoints every 60 seconds. Building the monitoring agent that pings devices, reads their current state, routes high-latency events to alerting pipelines, and deploys config updates to fleets of probe workers without downtime — that is precisely what Amazon IoT Core was designed for. IoT Core is a managed MQTT broker and device management platform that sits at the intersection of real-time event routing and persistent device state — a combination that makes it unusually powerful for MCP servers that need to interact with fleets of hardware or distributed agent workers. The operational complexity is real: IoT Core splits into two distinct SDK clients with separate endpoints (control plane and data plane), maintains a separate policy system for MQTT devices that is distinct from IAM, requires you to discover your account-specific ATS endpoint before making any data plane calls, and uses merge semantics everywhere — in the Device Shadow, Thing attributes, and Job documents — that catch developers who expect replace semantics. This guide synthesizes four deep-dives — Amazon IoT Core fundamentals: protocols, SDK architecture, authentication, and IoT policy documents, IoT Device Shadow: desired vs reported state, delta events, and offline buffering, IoT Rules Engine: topic SQL, rule actions, and substitution templates, and IoT Jobs: remote device management, firmware delivery, and execution tracking — into a complete IoT Core playbook for MCP server developers building fleet management tools and event-driven device architectures on AWS.

TL;DR

Part 1: IoT Core SDK architecture — control plane, data plane, and the ATS endpoint

Amazon IoT Core splits into two separate SDK packages that target different base URLs. Getting the client setup right before writing any business logic prevents a class of confusing errors that look like IAM problems but are actually routing problems.

import { IoTClient, DescribeEndpointCommand, CreateThingCommand } from '@aws-sdk/client-iot';
import { IoTDataPlaneClient, PublishCommand, GetThingShadowCommand } from '@aws-sdk/client-iot-data-plane';

const region = process.env.AWS_REGION ?? 'us-east-1';

// Control plane — standard regional endpoint, same as other AWS services
// Use for: Thing registry, IoT policies, rules, jobs, certificates, Fleet Indexing config
const iotControl = new IoTClient({ region });

// Data plane — requires the account-specific ATS endpoint
// Use for: publish MQTT messages, get/update/delete Device Shadow, retained messages
// Step 1: retrieve the ATS endpoint (one API call; cache the result permanently)
const { endpointAddress } = await iotControl.send(
  new DescribeEndpointCommand({ endpointType: 'iot:Data-ATS' })
);
// endpointAddress: a1b2c3d4e5f678-ats.iot.us-east-1.amazonaws.com

// Step 2: construct the data plane client with the ATS endpoint
const iotData = new IoTDataPlaneClient({
  region,
  endpoint: `https://${endpointAddress}`,
});

Store endpointAddress in an environment variable during your deployment pipeline and pass it to IoTDataPlaneClient at construction rather than fetching it at runtime. The endpoint is stable for the life of the account — it never changes. Calling DescribeEndpointCommand on every Lambda cold start adds an unnecessary 50-100ms of latency. Set it once during infra bootstrap and bake it into your environment config.

Protocol selection: MQTT vs HTTPS for MCP servers

IoT Core supports three connection protocols: MQTT over TLS (port 8883) for embedded devices with persistent connections, MQTT over WebSocket (port 443) for browser clients, and HTTPS via the IoT Data Plane API for server-side applications. MCP servers should always use the HTTPS Data Plane API — the same API the SDK calls when you use IoTDataPlaneClient.

The reason is operational: X.509 certificate management is non-trivial. Certificates expire, need rotation, must be associated with IoT policies, and require a certificate authority chain in the device's trust store. For embedded hardware that connects over MQTT 24/7, the persistent connection and QoS guarantees justify the certificate overhead. For a server-side MCP tool that makes occasional API calls, the operational cost is not justified when IAM credentials and SigV4 signing (handled transparently by the SDK) work perfectly for HTTPS.

IAM policies vs IoT policies

This is the most common source of confusion in IoT Core integrations. There are two completely separate policy systems, and they serve different principals:

Policy type Attached to Controls Used by
IAM policy IAM role, IAM user What AWS API callers can do (REST/HTTPS calls via SDK) MCP servers, Lambda, ECS, EC2 — any AWS SDK caller
IoT policy X.509 certificate or Cognito identity What MQTT devices can do (connect, publish, subscribe, receive) Embedded devices, mobile apps connecting via MQTT

Your MCP server needs an IAM policy with the data plane and control plane actions it uses. It never needs an IoT policy. A minimal IAM policy for an MCP fleet management tool:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "IoTControlPlane",
      "Effect": "Allow",
      "Action": [
        "iot:DescribeEndpoint",
        "iot:CreateThing",
        "iot:DescribeThing",
        "iot:UpdateThing",
        "iot:ListThings",
        "iot:SearchIndex",
        "iot:CreateTopicRule",
        "iot:CreateJob",
        "iot:DescribeJob",
        "iot:ListJobExecutionsForJob",
        "iot:CancelJob",
        "iot:DeleteJob"
      ],
      "Resource": "*"
    },
    {
      "Sid": "IoTDataPlane",
      "Effect": "Allow",
      "Action": [
        "iot:Publish",
        "iot:GetThingShadow",
        "iot:UpdateThingShadow",
        "iot:DeleteThingShadow",
        "iot:ListNamedShadowsForThing"
      ],
      "Resource": [
        "arn:aws:iot:us-east-1:123456789012:topic/*",
        "arn:aws:iot:us-east-1:123456789012:thing/*"
      ]
    }
  ]
}

Part 2: Thing registry, groups, and Fleet Indexing

The IoT Thing registry is a metadata store for every device or logical entity you manage through IoT Core. Things are not required for MQTT communication — a device can connect without being registered — but the registry enables Device Shadows, Job targeting by group, and Fleet Indexing queries across the fleet. For MCP tools that manage a fleet of probe workers or sensor devices, the registry is the source of truth for device identity and attributes.

Registering and updating Things

import {
  IoTClient,
  CreateThingCommand,
  DescribeThingCommand,
  UpdateThingCommand,
  CreateThingGroupCommand,
  AddThingToThingGroupCommand,
} from '@aws-sdk/client-iot';

const iot = new IoTClient({ region: 'us-east-1' });

// Register a Thing — idempotent: re-creating with the same name updates attributes
const { thingName, thingArn } = await iot.send(new CreateThingCommand({
  thingName: 'probe-worker-us-east-1-rack-7',
  thingTypeName: 'MCPProbeWorker',  // optional template; must exist first
  attributePayload: {
    attributes: {
      region: 'us-east-1',
      rack: '7',
      agentVersion: '3.4.1',
      probeCapacity: '50',         // string values only; use Fleet Indexing for range queries
    },
    merge: true,  // true = merge with existing; false = replace all attributes
  },
}));

// Read the current registration
const thing = await iot.send(new DescribeThingCommand({
  thingName: 'probe-worker-us-east-1-rack-7',
}));
console.log(thing.attributes);   // { region: 'us-east-1', rack: '7', ... }
console.log(thing.version);      // integer; use for optimistic locking on UpdateThing

// Update a single attribute — merge:true preserves all other attributes
await iot.send(new UpdateThingCommand({
  thingName: 'probe-worker-us-east-1-rack-7',
  attributePayload: {
    attributes: { agentVersion: '3.5.0' },
    merge: true,
  },
  expectedVersion: thing.version,  // optimistic locking — omit to skip
}));

// Organize Things into groups — groups enable bulk Job targeting
await iot.send(new CreateThingGroupCommand({
  thingGroupName: 'probe-workers-us-east-1',
  thingGroupProperties: {
    thingGroupDescription: 'All MCP probe workers in us-east-1',
    attributePayload: { attributes: { region: 'us-east-1', role: 'probe' } },
  },
}));

await iot.send(new AddThingToThingGroupCommand({
  thingName: 'probe-worker-us-east-1-rack-7',
  thingGroupName: 'probe-workers-us-east-1',
}));

Merge semantics on Thing attributes: merge: true in attributePayload is essential for partial updates. With merge: false (the default), an UpdateThingCommand that only sets agentVersion will silently delete every other attribute on the Thing. Always pass merge: true for partial updates. Pass merge: false only when you intentionally want to replace the entire attribute set.

Fleet Indexing — attribute-based search across the fleet

Without Fleet Indexing, the only way to look up Things is by exact name or by paginating through all Things with ListThingsCommand and filtering client-side. For fleets larger than a few hundred Things, this is impractically slow. Fleet Indexing builds a real-time index over Thing attributes, shadow state, and connectivity state that supports wildcard queries.

import { UpdateIndexingConfigurationCommand, SearchIndexCommand } from '@aws-sdk/client-iot';

// Enable Fleet Indexing (one-time setup; persists across sessions)
await iot.send(new UpdateIndexingConfigurationCommand({
  thingIndexingConfiguration: {
    thingIndexingMode: 'REGISTRY_AND_SHADOW',  // index attributes + shadow state
    thingConnectivityIndexingMode: 'STATUS',    // index connectivity status
  },
}));

// Query with wildcard and compound conditions
const { things } = await iot.send(new SearchIndexCommand({
  indexName: 'AWS_Things',   // always this name
  queryString: 'attributes.agentVersion:3.4.* AND attributes.region:us-east-1',
  maxResults: 100,
}));

// Find disconnected devices
const { things: offline } = await iot.send(new SearchIndexCommand({
  indexName: 'AWS_Things',
  queryString: 'connectivity.connected:false AND attributes.role:probe',
  maxResults: 100,
}));

// Find devices with shadow desired.targetVersion different from reported.currentVersion
const { things: pendingUpdate } = await iot.send(new SearchIndexCommand({
  indexName: 'AWS_Things',
  queryString: 'shadow.desired.targetVersion:[* TO *] AND attributes.role:probe',
  maxResults: 100,
}));

Fleet Indexing adds a per-Thing cost (approximately $0.25 per million thing-hours indexed) and a per-search cost. For small fleets (under 1,000 Things) the cost is negligible — typically under $1/month. For large fleets where attribute-based routing decisions must be made in real time (targeting a Job at all Things running a specific firmware version, for instance), Fleet Indexing is the only practical approach.

Part 3: Device Shadow — desired state, delta computation, and offline buffering

The Device Shadow service is IoT Core's answer to a fundamental device management problem: how do you send configuration changes to a device that may be offline right now but needs to receive and acknowledge the change eventually? The shadow is a persistent JSON document per Thing that holds the desired state (what the server wants) and the reported state (what the device currently is), with a computed delta that tells the device exactly what changed.

Shadow document schema

{
  "state": {
    // desired — written by MCP server; never written by the device
    "desired": {
      "probeInterval": 30,
      "maxConcurrentProbes": 50,
      "logLevel": "info"
    },
    // reported — written by device after applying changes; never written by server
    "reported": {
      "probeInterval": 60,              // device has not yet applied the new interval
      "maxConcurrentProbes": 50,         // already in sync
      "logLevel": "info",                // already in sync
      "agentVersion": "3.4.1",          // device reports its own fields too
      "startedAt": "2026-11-09T10:00:00Z"
    },
    // delta — COMPUTED by IoT Core; never include this in UpdateThingShadow payloads
    // Contains only fields where desired[key] !== reported[key]
    "delta": {
      "probeInterval": 30               // device needs to apply this change
    }
  },
  "metadata": { /* per-field timestamps managed by IoT Core */ },
  "version": 47,                        // incremented on every successful update
  "timestamp": 1731147600
}

The delta is computed field-by-field: only fields that exist in desired and whose values differ from reported appear in the delta. Fields that exist only in reported (like agentVersion) never appear in the delta — the server does not need to react to them. Fields where desired and reported match are excluded from the delta. This means the delta topic carries exactly the minimum payload the device needs to reconcile its state — no noise, no redundant fields.

Reading and writing shadows from an MCP tool

import {
  IoTDataPlaneClient,
  GetThingShadowCommand,
  UpdateThingShadowCommand,
  DeleteThingShadowCommand,
  ListNamedShadowsForThingCommand,
} from '@aws-sdk/client-iot-data-plane';

const iotData = new IoTDataPlaneClient({
  region: 'us-east-1',
  endpoint: `https://${process.env.IOT_ENDPOINT}`,
});

// Read the config shadow — handle missing shadow on first use
async function getProbeConfig(thingName: string) {
  try {
    const response = await iotData.send(
      new GetThingShadowCommand({ thingName, shadowName: 'config' })
    );
    // payload is Uint8Array — must decode before JSON.parse
    return JSON.parse(new TextDecoder().decode(response.payload));
  } catch (err: any) {
    if (err.name === 'ResourceNotFoundException') {
      // Shadow does not exist yet — first UpdateThingShadow creates it
      return null;
    }
    throw err;
  }
}

// Write desired config — merge semantics: only fields you send are changed
async function setProbeConfig(thingName: string, config: Record) {
  await iotData.send(new UpdateThingShadowCommand({
    thingName,
    shadowName: 'config',
    payload: Buffer.from(JSON.stringify({
      state: {
        desired: config,
        // Do NOT include 'reported' in server-side updates
        // The device owns the reported section
      },
    })),
  }));
}

// Versioned update — prevents two concurrent callers from overwriting each other
async function setProbeConfigVersioned(
  thingName: string,
  config: Record
) {
  const shadow = await getProbeConfig(thingName);
  if (!shadow) {
    await setProbeConfig(thingName, config);
    return;
  }

  try {
    await iotData.send(new UpdateThingShadowCommand({
      thingName,
      shadowName: 'config',
      payload: Buffer.from(JSON.stringify({
        state: { desired: config },
        version: shadow.version,  // HTTP 409 if shadow was modified since we read it
      })),
    }));
  } catch (err: any) {
    if (err.$metadata?.httpStatusCode === 409) {
      // Conflict — re-read and retry with the new version
      const fresh = await getProbeConfig(thingName);
      await iotData.send(new UpdateThingShadowCommand({
        thingName,
        shadowName: 'config',
        payload: Buffer.from(JSON.stringify({
          state: { desired: config },
          version: fresh.version,
        })),
      }));
    } else {
      throw err;
    }
  }
}

Named shadows — separating concerns

Each Thing has one classic shadow (accessed without a shadowName) and up to 10 named shadows. Named shadows are independent documents with their own version counters. For MCP probe workers, three named shadows cover the typical workload cleanly:

Shadow name Written by Contains Version conflict risk
config MCP server (desired), device (reported) Probe interval, concurrency limits, log level, endpoint targets Low — updated infrequently by server
telemetry Device only (reported) Current probe counts, success rates, latency percentiles, memory usage Medium — updated every minute by device; keep desired section empty
ota MCP server (desired), device (reported) targetVersion, downloadUrl; device reports currentVersion, updateStatus Low — updated only during deployments

Separating telemetry from config is important for the version counter. If you put device telemetry reports (every 60 seconds) and server config updates in the same shadow, every telemetry update increments the version. By the time the server reads the shadow to get the current version and writes a config update, the version has incremented again, causing a spurious 409. Separate the high-frequency device writes (telemetry) from the low-frequency server writes (config) into different named shadows.

Offline buffering pattern

The shadow persists desired state while the device is offline. This is the killer feature for fleet management tools that need to push configuration to devices without coordinating with their connection schedule.

// MCP fleet management tool — batch config rollout to all offline devices
// Works identically whether the device is currently connected or not
async function rolloutConfigToFleet(thingNames: string[], newConfig: Record) {
  const results = await Promise.allSettled(
    thingNames.map(thingName => setProbeConfig(thingName, newConfig))
  );

  const failed = results
    .map((r, i) => r.status === 'rejected' ? thingNames[i] : null)
    .filter(Boolean);

  if (failed.length > 0) {
    console.error('Config write failed for Things:', failed);
  }
  // Devices that are online receive the delta immediately via MQTT
  // Devices that are offline receive it on reconnect — no retry logic needed
  // Poll shadow.state.reported to track acknowledgment progress
}

// MCP tool: check how many devices have acknowledged a config change
async function getAcknowledgmentProgress(thingNames: string[], expectedInterval: number) {
  const results = await Promise.all(
    thingNames.map(async thingName => {
      const shadow = await getProbeConfig(thingName);
      if (!shadow) return { thingName, status: 'no_shadow' };
      const desired = shadow.state?.desired?.probeInterval;
      const reported = shadow.state?.reported?.probeInterval;
      return {
        thingName,
        status: reported === expectedInterval ? 'acknowledged' : 'pending',
        reportedAt: shadow.metadata?.reported?.probeInterval?.timestamp,
      };
    })
  );
  return results;
}

Detecting stale acknowledgments — devices that have been online but have not applied a config change — requires combining the shadow delta with the metadata timestamps. If metadata.desired.probeInterval.timestamp is 10 minutes ago and metadata.reported.probeInterval.timestamp is 2 hours ago, the device was given a new config 10 minutes ago and has not reported back. This suggests the device is processing the change slowly (acceptable for a firmware reboot) or has silently failed to apply it (investigate the device logs).

Part 4: Rules Engine — routing device telemetry to AWS services

The IoT Rules Engine evaluates every MQTT message published to IoT Core against a set of SQL rules. Matching messages are forwarded to one or more targets: Lambda, SQS, SNS, DynamoDB, S3, Kinesis Firehose, Kinesis Data Streams, or Republish topics. For MCP servers, the Rules Engine handles the event routing layer: probe latency events to Firehose for long-term analysis, alert conditions to Lambda for immediate processing, and device telemetry to DynamoDB for time-series storage.

Creating a rule

import { IoTClient, CreateTopicRuleCommand } from '@aws-sdk/client-iot';

await iot.send(new CreateTopicRuleCommand({
  ruleName: 'HighLatencyProbeAlert',    // alphanumeric + underscore; no hyphens
  topicRulePayload: {
    sql: "SELECT *, topic(2) AS probeTarget, timestamp() AS arrivedAtMs FROM 'probes/+/metrics' WHERE p99LatencyMs > 5000",
    awsIotSqlVersion: '2016-03-23',     // always use this version
    description: 'Route high-latency probe events to Lambda alert handler',
    ruleDisabled: false,

    actions: [
      {
        // Lambda — resource-based policy on the function, NOT a role ARN
        lambda: {
          functionArn: 'arn:aws:lambda:us-east-1:123456789012:function:ProbeLatencyAlert',
        },
      },
      {
        // Firehose — role ARN with iot:Data-ATS trust + firehose:PutRecord permission
        firehose: {
          deliveryStreamName: 'mcp-probe-latency-events',
          roleArn: 'arn:aws:iam::123456789012:role/IoTFirehoseRole',
          separator: '\n',           // produces NDJSON in S3
        },
      },
    ],

    // Error action — fires when any action above fails; always configure one
    errorAction: {
      cloudwatchLogs: {
        logGroupName: '/aws/iot/rules/errors',
        logStreamName: 'HighLatencyProbeAlert',
        roleArn: 'arn:aws:iam::123456789012:role/IoTCWLogsRole',
      },
    },
  },
}));

Topic SQL deep dive

The IoT Rules Engine SQL dialect operates on JSON payloads arriving on MQTT topics. The FROM clause is a topic filter, not a table name. The 2016-03-23 version adds the full function set including topic(n), timestamp(), newuuid(), and parse_time().

-- Extract the device ID from a wildcard topic segment
SELECT
  deviceId,
  p99LatencyMs,
  successRate,
  topic(1) AS deviceIdFromTopic,     -- topic(1) = segment at index 1 (0-indexed)
  topic() AS fullTopic,              -- full MQTT topic string
  timestamp() AS arrivedAtMs,        -- epoch milliseconds
  newuuid() AS eventId               -- idempotency key for downstream deduplication
FROM 'probes/+/metrics'              -- + matches exactly one topic level
WHERE p99LatencyMs > 5000

-- Wildcard # matches all remaining topic levels (must be at end)
-- Use for catching all variants: probes/device-1/metrics, probes/device-1/alerts/critical
SELECT *, topic(1) AS deviceId FROM 'probes/#'
WHERE alert IS NOT NULL

-- Exact topic match — no wildcard; more efficient for known topic names
SELECT * FROM 'probes/broadcast/config-reload'

Substitution templates — the SELECT-alias trap

Substitution templates inject dynamic values into rule action parameters (S3 keys, DynamoDB partition keys, Republish topic names). The trap: templates evaluate against the original message payload, not the SELECT-transformed payload.

// Rule: SELECT p99LatencyMs AS latency FROM 'probes/+/metrics'
// S3 action with substitution template:

// WRONG — ${latency} is undefined because the original message has p99LatencyMs, not latency
{
  s3: {
    key: "metrics/${topic(1)}/${latency}/${timestamp()}.json",   // latency = undefined
    bucketName: 'my-iot-archive',
    roleArn: '...',
  }
}

// CORRECT — reference the original field name in templates
{
  s3: {
    key: "metrics/${topic(1)}/${p99LatencyMs}/${timestamp()}.json",  // correct
    bucketName: 'my-iot-archive',
    roleArn: '...',
  }
}

// DATE-PARTITIONED S3 key using parse_time with Java SimpleDateFormat
{
  s3: {
    // key: metrics/2026/11/09/probe-worker-rack-7/1731147600000.json
    key: "metrics/${parse_time('yyyy', timestamp(), 'UTC')}/${parse_time('MM', timestamp(), 'UTC')}/${parse_time('dd', timestamp(), 'UTC')}/${topic(1)}/${timestamp()}.json",
    bucketName: 'my-iot-archive',
    roleArn: '...',
  }
}

The rule actions reference table shows which actions need a role ARN and which use resource-based policies:

Action IAM model Key config
Lambda Resource-based policy on function (lambda:InvokeFunction for iot.amazonaws.com) functionArn only — no roleArn
SQS Role ARN with sqs:SendMessage; trust iot.amazonaws.com queueUrl, roleArn, useBase64
Kinesis Firehose Role ARN with firehose:PutRecord deliveryStreamName, roleArn, separator: '\n' for NDJSON
DynamoDB v2 Role ARN with dynamodb:PutItem tableName, putItem key config — entire JSON payload is written as item
S3 Role ARN with s3:PutObject bucketName, key (supports substitution templates)
Republish Role ARN with iot:Publish topic (supports templates), qos

Always configure an error action. Without one, failed rule executions produce no observable signal — your IoT messages silently fail to reach their targets. The CloudWatch Logs error action is the simplest to debug with; the SQS DLQ pattern enables replay if the downstream system recovers. Use at minimum one of these in every production rule.

Part 5: IoT Jobs — safe fleet-wide deployments with rollout control

AWS IoT Jobs is the managed service for deploying operations to device fleets — firmware updates, configuration pushes, certificate rotations, remote diagnostics. Each job is a document dispatched to one or more Things or Thing Groups with tracked per-device execution state. MCP fleet management tools typically use Jobs in two roles: as the job creator (the tool that creates, monitors, and cancels jobs) and as the execution monitor (querying per-device status to surface rollout progress to operators).

Creating a job with rollout safety

import { IoTClient, CreateJobCommand } from '@aws-sdk/client-iot';

await iot.send(new CreateJobCommand({
  // jobId: unique within account+region; globally reserved even after deletion
  // Always use timestamp or UUID suffix to avoid ResourceAlreadyExistsException on retry
  jobId: `probe-agent-v3-5-0-${Date.now()}`,

  targets: [
    'arn:aws:iot:us-east-1:123456789012:thinggroup/probe-workers-us-east-1',
  ],

  document: JSON.stringify({
    operation: 'agent-update',
    agentVersion: '3.5.0',
    // S3 key for the agent binary — IoT Core generates presigned URL on demand
    agentKey: 'agent-builds/v3.5.0/probe-agent-linux-amd64',
    checksum: 'sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855',
    rebootRequired: false,
    maxGracefulDrainSeconds: 30,
  }),

  // Presigned URL config — IoT Core generates a time-limited S3 URL the device requests
  presignedUrlConfig: {
    roleArn: 'arn:aws:iam::123456789012:role/IoTJobsPresignedUrlRole',
    expiresInSec: 7200,  // maximum; set high for large binaries over slow connections
  },

  targetSelection: 'SNAPSHOT',  // fixed set at creation time

  // Gradual rollout — exponential rate is safer than fixed for firmware/agent updates
  jobExecutionsRolloutConfig: {
    exponentialRate: {
      baseRatePerMinute: 10,          // start: 10 devices/minute
      incrementFactor: 2,             // double rate each interval
      rateIncreaseCriteria: {
        numberOfSucceededThings: 50,  // double after 50 devices succeed
      },
    },
  },

  // Abort if failure rate exceeds threshold after a meaningful sample
  abortConfig: {
    criteriaList: [
      {
        failureType: 'FAILED',
        action: 'CANCEL',
        thresholdPercentage: 10,       // cancel if >10% fail...
        minNumberOfExecutedThings: 100, // ...but only after ≥100 have attempted
      },
      {
        failureType: 'TIMED_OUT',
        action: 'CANCEL',
        thresholdPercentage: 20,
        minNumberOfExecutedThings: 50,
      },
    ],
  },

  // Per-device in-progress timeout — transitions to TIMED_OUT if device stops reporting
  timeoutConfig: {
    inProgressTimeoutInMinutes: 60,
  },
}));

Job execution state machine

IoT Core tracks one execution record per Thing targeted by the job. Each execution moves through a state machine; terminal states cannot transition further. The critical point: the device must explicitly move itself from QUEUED to IN_PROGRESS. IoT Core never auto-transitions QUEUED to IN_PROGRESS.

State Terminal? Who sets it Notes
QUEUED No IoT Core on job creation QUEUED executions are not subject to inProgressTimeoutInMinutes — only IN_PROGRESS is timed
IN_PROGRESS No Device via StartNextPendingJobExecutionCommand In-progress timeout starts here; device reports progress via UpdateJobExecutionCommand with stepTimeoutInMinutes to extend
SUCCEEDED Yes Device Device reported successful completion with statusDetails
FAILED Yes Device Device reported a failure; errorCode and errorMessage in statusDetails
TIMED_OUT Yes IoT Core Device did not complete within inProgressTimeoutInMinutes after moving to IN_PROGRESS
CANCELED Yes MCP server via CancelJobExecutionCommand Explicitly canceled by management plane

Monitoring rollout progress from an MCP tool

import {
  IoTClient,
  DescribeJobCommand,
  ListJobExecutionsForJobCommand,
  CancelJobCommand,
} from '@aws-sdk/client-iot';

// Get aggregate progress counts for the job
async function getRolloutProgress(jobId: string) {
  const { job } = await iot.send(new DescribeJobCommand({ jobId }));
  const d = job!.jobProcessDetails!;
  return {
    jobStatus: job!.status,  // IN_PROGRESS | COMPLETED | CANCELED
    queued: d.numberOfQueuedThings,
    inProgress: d.numberOfInProgressThings,
    succeeded: d.numberOfSucceededThings,
    failed: d.numberOfFailedThings,
    timedOut: d.numberOfTimedOutThings,
    canceled: d.numberOfCanceledThings,
    successRate: d.numberOfSucceededThings! /
      (d.numberOfSucceededThings! + d.numberOfFailedThings! + d.numberOfTimedOutThings! || 1),
  };
}

// Get individual device failure details for incident investigation
async function getFailedExecutionDetails(jobId: string) {
  const failed: Array<{
    thingArn: string;
    errorCode?: string;
    errorMessage?: string;
  }> = [];

  let nextToken: string | undefined;
  do {
    const { executionSummaries, nextToken: next } = await iot.send(
      new ListJobExecutionsForJobCommand({
        jobId,
        status: 'FAILED',
        maxResults: 250,
        nextToken,
      })
    );
    for (const summary of executionSummaries ?? []) {
      const details = summary.jobExecutionSummary?.statusDetails ?? {};
      failed.push({
        thingArn: summary.thingArn!,
        errorCode: details['errorCode'],
        errorMessage: details['errorMessage'],
      });
    }
    nextToken = next;
  } while (nextToken);

  return failed;
}

// Emergency rollout cancellation
async function abortRollout(jobId: string, reason: string) {
  await iot.send(new CancelJobCommand({
    jobId,
    comment: reason,
    reasonCode: 'MANUAL_ABORT',
  }));
}

Presigned URL delivery — safety-critical details

Presigned URL delivery for firmware binaries has two safety requirements that are easy to overlook:

Always pin the S3 object version ID. Without a versionId in the fileLocation.s3Location, IoT Core fetches the latest version of the S3 object at the time each device requests its presigned URL. If a new firmware binary is uploaded to the same S3 key between job creation and when the last batch of devices downloads it, some devices get the old firmware and some get the new one. Devices in the same job end up on different firmware versions. Pin the versionId to the specific S3 object that was current when the job was created.

Set expiresInSec generously. On a congested cellular connection, downloading 150 MB of firmware at 500 KB/s takes 5 minutes. If the device also does checksum verification and two retry attempts on a failed download, the total time before the presigned URL is used could be 20+ minutes. Set expiresInSec: 7200 (2 hours, the maximum) for firmware delivery jobs. The device can always request a fresh presigned URL if the first one expires — it re-calls DescribeJobExecutionCommand and gets a new URL. The URL expiry does not cause the job execution to fail; it only means the device needs to re-request the URL.

Snapshot vs continuous deployment modes

Mode targetSelection Behavior Use for
Snapshot SNAPSHOT Targets resolved once at job creation; devices added to target groups after creation are excluded Firmware rollouts, certificate rotations — specific before/after fleet state
Continuous CONTINUOUS IoT Core re-evaluates targets dynamically; devices added to group after creation automatically receive the job Baseline config, root CA installation — zero-touch provisioning for new devices

A continuous job never reaches COMPLETED status at the job level. It remains IN_PROGRESS indefinitely, applying to new Things as they are added to the target group. Cancel a continuous job explicitly when you want to stop dispatching — do not rely on the job completing on its own.

Putting it together: an MCP fleet management tool architecture

An MCP server that provides IoT fleet management capabilities typically exposes tools across all four IoT Core service areas:

// MCP tool: register_device — creates Thing, adds to group, initializes shadow
async function registerDevice(params: {
  deviceId: string;
  region: string;
  rack: string;
  agentVersion: string;
}) {
  const thingName = `probe-worker-${params.region}-${params.rack}`;

  // 1. Register in IoT registry
  const { thingArn } = await iot.send(new CreateThingCommand({
    thingName,
    thingTypeName: 'MCPProbeWorker',
    attributePayload: {
      attributes: {
        region: params.region,
        rack: params.rack,
        agentVersion: params.agentVersion,
      },
      merge: true,
    },
  }));

  // 2. Add to regional group (Job targeting + Fleet Indexing group queries)
  await iot.send(new AddThingToThingGroupCommand({
    thingName,
    thingGroupName: `probe-workers-${params.region}`,
  }));

  // 3. Initialize config shadow with defaults
  await iotData.send(new UpdateThingShadowCommand({
    thingName,
    shadowName: 'config',
    payload: Buffer.from(JSON.stringify({
      state: {
        desired: {
          probeInterval: 60,
          maxConcurrentProbes: 25,
          logLevel: 'info',
        },
      },
    })),
  }));

  return { thingArn, thingName };
}

// MCP tool: get_fleet_health — queries Fleet Indexing + shadow telemetry
async function getFleetHealth(region: string) {
  const { things } = await iot.send(new SearchIndexCommand({
    indexName: 'AWS_Things',
    queryString: `attributes.region:${region} AND attributes.role:probe`,
    maxResults: 100,
  }));

  const healthReports = await Promise.all(
    (things ?? []).map(async (thing) => {
      const shadow = await getProbeConfig(thing.thingName!);
      return {
        thingName: thing.thingName,
        connected: thing.connectivity?.connected,
        lastConnected: thing.connectivity?.timestamp,
        config: shadow?.state?.desired,
        telemetry: shadow?.state?.reported,
      };
    })
  );

  return healthReports;
}

The MCP tool layer wraps the four IoT Core service areas into operations that map to operator intent: register a device (registry + shadow init), push config (shadow desired), route telemetry events (rules), deploy an agent update (jobs with rollout safety). Each underlying IoT Core primitive does one thing well — the MCP tool layer is what ties them together into a coherent fleet management API.

Common errors across the IoT Core arc

Error / symptom Cause Fix
UnauthorizedException on data plane calls Wrong endpoint URL — using the control plane endpoint (iot.{region}.amazonaws.com) for data plane operations, or missing IAM action on the data plane resource ARN Verify IoTDataPlaneClient is constructed with the ATS endpoint from DescribeEndpointCommand({ endpointType: 'iot:Data-ATS' }); check IAM policy includes the data plane actions
ResourceNotFoundException on GetThingShadowCommand Shadow does not exist — it is created on the first UpdateThingShadow call, not on Thing registration Catch and treat as null/empty state; create shadow with initial defaults on first call
Shadow payload decode error response.payload is a Uint8Array; calling JSON.parse(response.payload) directly fails Use JSON.parse(new TextDecoder().decode(response.payload)) before parsing
IoT Rule actions produce undefined values in S3 keys or DynamoDB items Substitution template references an aliased field name from SELECT (${temp}) instead of the original payload field (${temperature}) Templates evaluate against the original MQTT payload, not the SELECT output; use original field names in templates
ResourceAlreadyExistsException on CreateJobCommand jobId was already used — IDs are reserved even after deletion Always append a timestamp or UUID to job IDs; keep a job ID registry in your MCP server's database
Job rollout aborts immediately on first failure minNumberOfExecutedThings not set or too low; first failure hits 100% threshold Set minNumberOfExecutedThings to at least 100 or 10% of fleet size; this ensures the abort criterion has statistical meaning
Presigned URL expired mid-download expiresInSec too short for binary size + connection speed Set expiresInSec: 7200 (maximum); device can re-request a fresh URL — expiry does not auto-fail the execution
Delta never clears after device reports back Device reports a different field name or nesting than what was set in desired; exact key path match required Ensure device reports the same field names (case-sensitive, same nesting) as were written to desired; common with camelCase vs snake_case mismatches